1
0
antisocial-ytm/script.js

461 lines
17 KiB
JavaScript

// ==UserScript==
// @name antisocial.moe - youtube music scrobbler
// @namespace http://tampermonkey.net/
// @version 2.1
// @description Scrobbles songs from YouTube Music to antisocial.moe, with progress bar monitoring
// @match https://music.youtube.com/*
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @connect antisocial.moe
// ==/UserScript==
(function() {
'use strict';
/**
* ============================
* Helper Functions
* ============================
*/
/**
* Recursively searches for elements matching the selector within shadow DOMs.
* @param {string} selector - The CSS selector to match.
* @param {Document|ShadowRoot} root - The root to start searching from.
* @returns {Element|null} - The first matching element or null if not found.
*/
function querySelectorDeep(selector, root = document) {
const result = root.querySelector(selector);
if (result) return result;
// Traverse shadow roots
const allElements = root.querySelectorAll('*');
for (let el of allElements) {
if (el.shadowRoot) {
const shadowResult = querySelectorDeep(selector, el.shadowRoot);
if (shadowResult) return shadowResult;
}
}
return null;
}
/**
* Extracts the current song information from the YouTube Music player bar.
* Implements multiple extraction methods to handle different HTML structures.
* @returns {Object|null} An object containing title, author, and album, or null if not found.
*/
function getSongInfo() {
// Primary Extraction Method
let titleElement = querySelectorDeep('.ytmusic-player-bar .title');
let bylineElement = querySelectorDeep('.ytmusic-player-bar .byline');
if (titleElement && bylineElement) {
const title = titleElement.textContent.trim();
// The byline typically has the format: "Author • Album • Year"
const bylineText = bylineElement.textContent.trim();
const parts = bylineText.split('•').map(part => part.trim());
const author = parts[0] || 'Unknown Artist';
const album = parts[1] || 'Unknown Album';
return { title, author, album };
}
// Fallback Extraction Method: Parse from 'title' attribute of byline
if (bylineElement && bylineElement.getAttribute('title')) {
const bylineTitle = bylineElement.getAttribute('title').trim();
const parts = bylineTitle.split('•').map(part => part.trim());
const author = parts[0] || 'Unknown Artist';
const album = parts[1] || 'Unknown Album';
// Attempt to find the song title from an alternative element or attribute
let title = 'Unknown Title';
if (titleElement) {
title = titleElement.textContent.trim();
} else {
// Try to extract from other possible attributes or elements if available
const parent = bylineElement.parentElement;
if (parent && parent.getAttribute('title')) {
title = parent.getAttribute('title').trim();
}
}
return { title, author, album };
}
// Additional Fallbacks can be added here as needed
return null;
}
/**
* Determines whether the current song is playing.
* @returns {boolean} True if playing, false if paused or unable to determine.
*/
function isPlaying() {
// The play/pause button has id="play-pause-button"
const playPauseButton = querySelectorDeep('#play-pause-button');
if (playPauseButton) {
const titleAttr = playPauseButton.getAttribute('title');
const ariaLabel = playPauseButton.getAttribute('aria-label');
// If the button's title or aria-label is "Pause", it means the song is currently playing
if (titleAttr && titleAttr.toLowerCase() === 'pause') {
return true;
}
if (ariaLabel && ariaLabel.toLowerCase() === 'pause') {
return true;
}
}
return false;
}
/**
* Sends a scrobble request to the specified endpoint.
* @param {string} title - The title of the song.
* @param {string} author - The author/artist of the song.
* @param {string} album - The album name of the song.
*/
function scrobble(title, author, album) {
const AUTH_KEY = GM_getValue('AUTH_KEY', '');
if (!AUTH_KEY) {
console.error('AUTH_KEY is not set. Please set it using the Tampermonkey menu.');
return;
}
const url = 'https://antisocial.moe/wp-json/image-scrobble/v1/scrobble';
const payload = {
song_name: title,
author: author,
album_name: album
};
GM_xmlhttpRequest({
method: 'POST',
url: url,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${AUTH_KEY}`
},
data: JSON.stringify(payload),
onload: function(response) {
if (response.status >= 200 && response.status < 300) {
console.log(`Scrobble successful: "${title}" by ${author} from "${album}"`);
} else {
console.error(`Scrobble failed with status ${response.status}:`, response.responseText);
console.debug('Payload:', payload);
}
// Update lastScrobbledSong regardless of scrobble success to prevent duplicate attempts
lastScrobbledSong = { title, author, album };
// Reset scrobbleScheduled
scrobbleScheduled = false;
},
onerror: function(error) {
console.error('Scrobble request error:', error);
// Update lastScrobbledSong to prevent further attempts
lastScrobbledSong = { title, author, album };
// Reset scrobbleScheduled
scrobbleScheduled = false;
}
});
}
/**
* ============================
* Scrobbling Logic
* ============================
*/
function areSongsEqual(song1, song2) {
if (!song1 || !song2) return false;
return song1.title.toLowerCase() === song2.title.toLowerCase() &&
song1.author.toLowerCase() === song2.author.toLowerCase() &&
song1.album.toLowerCase() === song2.album.toLowerCase();
}
// Variable to keep track of the last scrobbled song
let lastScrobbledSong = null;
// Flag to indicate if a scrobble is already scheduled
let scrobbleScheduled = false;
// Timer for scrobbling after 10 seconds of playback
let playbackTimer = null;
/**
* ============================
* Progress Monitoring Logic
* ============================
*/
/**
* Gets the current progress percentage of the song.
* @returns {number|null} Progress percentage (0-100) or null if not available.
*/
function getProgressPercentage() {
const progressBar = querySelectorDeep('#progress-bar');
if (progressBar) {
const valueNow = parseInt(progressBar.getAttribute('aria-valuenow'), 10);
const valueMax = parseInt(progressBar.getAttribute('aria-valuemax'), 10);
if (!isNaN(valueNow) && !isNaN(valueMax) && valueMax > 0) {
return (valueNow / valueMax) * 100;
}
}
return null;
}
// Variable to keep track of last progress percentage
let lastProgressPercentage = null;
/**
* Handles progress changes and detects jumps from >95% to <5%.
* @param {number} newPercentage - The new progress percentage.
*/
function handleProgressChange(newPercentage) {
if (lastProgressPercentage === null) {
lastProgressPercentage = newPercentage;
return;
}
if (lastProgressPercentage > 95 && newPercentage < 5) {
console.log('Progress jumped from >95% to <5%. Sending new scrobble.');
const songInfo = getSongInfo();
if (songInfo) {
scrobble(songInfo.title, songInfo.author, songInfo.album);
} else {
console.warn('Cannot scrobble: song information not found.');
}
}
lastProgressPercentage = newPercentage;
}
/**
* Initializes a MutationObserver to monitor the progress bar's aria-valuenow.
*/
function initProgressObserver() {
const progressBar = querySelectorDeep('#progress-bar');
if (!progressBar) {
console.error('Progress bar not found. Progress monitoring not initialized.');
return;
}
// Initialize lastProgressPercentage
lastProgressPercentage = getProgressPercentage();
console.debug('Initial progress percentage:', lastProgressPercentage);
// Create observer
const progressObserver = new MutationObserver((mutations) => {
for (let mutation of mutations) {
if (mutation.type === 'attributes' && mutation.attributeName === 'aria-valuenow') {
const newPercentage = getProgressPercentage();
if (newPercentage !== null) {
console.debug(`Progress updated: ${newPercentage.toFixed(2)}%`);
handleProgressChange(newPercentage);
}
}
}
});
// Observe aria-valuenow
progressObserver.observe(progressBar, { attributes: true, attributeFilter: ['aria-valuenow'] });
console.log('Progress observer initialized.');
}
/**
* ============================
* Scrobbling Logic (continued)
* ============================
*/
/**
* Handles the scrobbling logic when a song change or playback state change is detected.
* Scrobbles the song only if it has been playing for more than 10 seconds and is currently playing.
*/
function handleScrobble() {
const currentKey = GM_getValue('AUTH_KEY', '');
if (!currentKey) {
return;
}
const songInfo = getSongInfo();
if (songInfo) {
// Log current and detected song information
console.debug('Current scrobbled song:', lastScrobbledSong);
console.debug('Detected song:', songInfo);
// Check if the song has changed to avoid duplicate scrobbles
if (!areSongsEqual(lastScrobbledSong, songInfo)) {
console.log(`New song detected: "${songInfo.title}" by ${songInfo.author} from "${songInfo.album}"`);
// Check if the song is currently playing
if (isPlaying() && !scrobbleScheduled) {
scrobbleScheduled = true;
// Update lastScrobbledSong to prevent repeated attempts
// Note: We update it here to prevent multiple timers for the same song
lastScrobbledSong = songInfo;
// Clear any existing timer
if (playbackTimer) {
clearTimeout(playbackTimer);
playbackTimer = null;
scrobbleScheduled = false;
}
playbackTimer = setTimeout(() => {
if (isPlaying()) {
scrobble(songInfo.title, songInfo.author, songInfo.album);
playbackTimer = null;
} else {
console.log('Playback stopped before scrobble threshold.');
scrobbleScheduled = false;
}
}, 10000); // 10 seconds
console.log('Started 10-second timer for scrobble.');
} else {
console.debug(`isPlaying: ${isPlaying()} scrobbleScheduled: ${scrobbleScheduled}`)
}
} else {
console.debug('Same song, no action needed.');
}
} else {
console.warn('Song information not found.');
}
}
/**
* ============================
* Debouncing Mechanism
* ============================
*/
/**
* Debounce mechanism to prevent rapid consecutive scrobbles.
* Waits for 1 second after the last mutation before handling scrobble.
*/
let debounceTimeout = null;
function debouncedHandleScrobble() {
if (debounceTimeout) {
clearTimeout(debounceTimeout);
}
debounceTimeout = setTimeout(() => {
handleScrobble();
}, 1000); // 1 second debounce
}
/**
* ============================
* MutationObserver Setup
* ============================
*/
/**
* Initializes the MutationObserver to monitor changes in the player bar and play/pause button.
*/
function initObserver() {
const observer = new MutationObserver((mutations) => {
for (let mutation of mutations) {
// Detect changes in child elements (e.g., song title updates)
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
debouncedHandleScrobble();
break; // No need to process further mutations for this batch
}
// Detect attribute changes (e.g., play/pause button title changes)
if (mutation.type === 'attributes' && (mutation.attributeName === 'title' || mutation.attributeName === 'aria-label')) {
debouncedHandleScrobble();
break;
}
}
});
// Configuration for the observer
const config = { childList: true, subtree: true, attributes: true, attributeFilter: ['title', 'aria-label'] };
// Start observing the entire document
observer.observe(document.body, config);
console.log('YouTube Music Scrobbler with Conditional Scrobbling is now active.');
}
/**
* ============================
* Initialization
* ============================
*/
/**
* Waits for the player bar to be available in the DOM before initializing the observer.
*/
function waitForPlayerBar() {
const interval = setInterval(() => {
const playerBar = querySelectorDeep('.ytmusic-player-bar');
if (playerBar) {
clearInterval(interval);
initObserver();
// Initial scrobble handling
handleScrobble();
// Initialize progress observer
initProgressObserver();
}
}, 1000); // Check every second
// Optionally, add a timeout to stop checking after a certain period
// setTimeout(() => {
// clearInterval(interval);
// console.error('Player bar not found. Scrobbler not initialized.');
// }, 30000); // 30 seconds
}
/**
* Prompts the user to enter their AUTH_KEY and stores it using GM_setValue.
*/
function setAuthKey() {
const currentKey = GM_getValue('AUTH_KEY', '');
const newKey = prompt('Enter your AUTH_KEY for antisocial.moe:', currentKey);
if (newKey !== null) { // User didn't cancel
if (newKey.trim() === '') {
alert('AUTH_KEY cannot be empty.');
return;
}
GM_setValue('AUTH_KEY', newKey.trim());
alert('AUTH_KEY has been updated.');
console.log('AUTH_KEY has been set/updated.');
}
}
/**
* Registers a menu command to allow users to set/edit the AUTH_KEY.
*/
function registerMenuCommands() {
GM_registerMenuCommand('Set antisocial.moe Auth Key', setAuthKey);
}
/**
* Optionally, prompt the user to set the AUTH_KEY if it's not already set.
*/
function promptForAuthKeyIfNeeded() {
const authKey = GM_getValue('AUTH_KEY', '');
if (!authKey) {
alert('Please set your antisocial.moe AUTH_KEY using the Tampermonkey menu (right-click the Tampermonkey icon > Your Script > "Set antisocial.moe Auth Key").');
}
}
// Register the menu command for setting the AUTH_KEY
registerMenuCommands();
// Start the script by waiting for the player bar to load
waitForPlayerBar();
// Prompt the user to set the AUTH_KEY if it's not set
promptForAuthKeyIfNeeded();
})();