461 lines
17 KiB
JavaScript
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();
|
|
|
|
})();
|