// ==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(); })();