commit 0b6058965fc8cb9de8b4adcd9fc7ea8b24fd0a38 Author: anon Date: Wed Oct 9 20:17:26 2024 +0000 initial commit diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..2d1266d --- /dev/null +++ b/readme.md @@ -0,0 +1,29 @@ +# youtube music scrobbler for antisocial.moe + +so i made this script that scrobbles your youtube music plays to my blog, antisocial.moe. not sure if anyone else will use it, but whatever. + +## installation + +1. install tampermonkey, violentmonkey, or any userscript manager. +2. create a new script and paste in the code from the script file. +3. save it. + +## setup + +you need to set your `AUTH_KEY` for it to work. + +1. after installing the script, click on the userscript manager icon (it's probably in your browser toolbar). +2. find the script (probably called "antisocial.moe - youtube music scrobbler"). +3. click on "Set antisocial.moe Auth Key" in the menu. +4. enter your `AUTH_KEY`. if you don't have one, well, i guess you need to get one somehow. + +## usage + +just play music on youtube music, and it'll scrobble the songs to antisocial.moe after they've been playing for 10 seconds. if you skip songs before 10 seconds, it won't scrobble them. + +## notes + +- only scrobbles when the song is playing. +- waits 10 seconds before scrobbling to avoid spamming with skipped tracks. +- if it's not working, check the console for errors. maybe i screwed something up. +- probably needs more testing, but it works for me. diff --git a/script.js b/script.js new file mode 100644 index 0000000..2cbf03d --- /dev/null +++ b/script.js @@ -0,0 +1,376 @@ +// ==UserScript== +// @name antisocial.moe - youtube music scrobbler +// @namespace http://tampermonkey.net/ +// @version 2.0 +// @author +// @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; + + /** + * 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}"`); + + // 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; + } + + // Check if the song is currently playing + if (isPlaying()) { + if (!scrobbleScheduled) { + scrobbleScheduled = true; + 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('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.'); + } + + /** + * 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(); + } + }, 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 + } + + /** + * ============================ + * UI for Managing AUTH_KEY + * ============================ + */ + + /** + * 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); + } + + /** + * ============================ + * Initialization + * ============================ + */ + + // Register the menu command for setting the AUTH_KEY + registerMenuCommands(); + + // 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").'); + } + } + + // 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(); + +})(); +