2024-10-09 22:17:26 +02:00
// ==UserScript==
// @name antisocial.moe - youtube music scrobbler
// @namespace http://tampermonkey.net/
2024-10-15 16:56:50 +02:00
// @version 2.1
// @description Scrobbles songs from YouTube Music to antisocial.moe, with progress bar monitoring
2024-10-09 22:17:26 +02:00
// @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 ;
2024-10-15 16:56:50 +02:00
/ * *
* === === === === === === === === === =
* 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 )
* === === === === === === === === === =
* /
2024-10-09 22:17:26 +02:00
/ * *
* 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
2024-10-15 16:56:50 +02:00
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 } ` )
2024-10-09 22:17:26 +02:00
}
} 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.' ) ;
}
2024-10-15 16:56:50 +02:00
/ * *
* === === === === === === === === === =
* Initialization
* === === === === === === === === === =
* /
2024-10-09 22:17:26 +02:00
/ * *
* 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 ( ) ;
2024-10-15 16:56:50 +02:00
// Initialize progress observer
initProgressObserver ( ) ;
2024-10-09 22:17:26 +02:00
}
} , 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 ) ;
}
/ * *
2024-10-15 16:56:50 +02:00
* Optionally , prompt the user to set the AUTH _KEY if it ' s not already set .
2024-10-09 22:17:26 +02:00
* /
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").' ) ;
}
}
2024-10-15 16:56:50 +02:00
// Register the menu command for setting the AUTH_KEY
registerMenuCommands ( ) ;
2024-10-09 22:17:26 +02:00
// 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 ( ) ;
} ) ( ) ;