Imports YouTube videos to MusicBrainz as a new standalone recording
// ==UserScript==
// @name YouTube: MusicBrainz Importer
// @namespace https://musicbrainz.org/user/chaban
// @version 2.7.4
// @description Imports YouTube videos to MusicBrainz as a new standalone recording
// @tag ai-created
// @author nikki, RustyNova, chaban
// @license MIT
// @match *://www.youtube.com/*
// @match *://musicbrainz.org/recording/create*
// @connect googleapis.com
// @connect musicbrainz.org
// @connect listenbrainz.org
// @icon https://www.google.com/s2/favicons?sz=256&domain=youtube.com
// @grant GM.xmlHttpRequest
// @grant GM.getValue
// @grant GM.setValue
// @grant GM.registerMenuCommand
// @run-at document-end
// @noframes
// @require https://update.greasyfork.org/scripts/552392/1689509/MusicBrainz%20API%20Module.js
// ==/UserScript==
//**************************************************************************//
// Based on the "Import videos from YouTube as release" script by RustyNova
// and the original "Import videos from YouTube as recording" script by nikki et al.
//**************************************************************************//
(function () {
'use strict';
/**
* Localization module to handle translations.
*/
const L10n = {
_language: (document.documentElement.lang || navigator.language || navigator.userLanguage).split('-')[0],
_strings: {
en: {
loading: 'Loading...',
addRecording: 'Add Recording',
updateLength: 'Update Length',
onMB: 'On MB ✓',
onMBMulti: 'On MB (Multi) ✓',
addRecordingTitle: 'Add to MusicBrainz as recording',
updateLengthTitle: 'The linked MusicBrainz recording is missing its length. Click to update it to {length}s.',
linkedToRecordingTitle: 'This YouTube video is linked to MusicBrainz recording: {title}',
linkedToMultiTitle: 'This YouTube video is linked to multiple recordings on MusicBrainz.\nClick to view URL entity page.',
errorVideoNotFound: 'Video Not Found / YT API Error',
errorApiRateLimit: '{apiName} Rate Limit / Server Error',
errorApiNetwork: '{apiName} Network Error',
errorProcessing: 'Processing Error',
// Playlist specific strings
createPlaylist: 'Create LB Playlist',
syncPlaylist: 'Sync LB Playlist',
onLB: 'On LB (Playlist) ✓',
createPlaylistTitle: 'Create a new ListenBrainz playlist from this video\'s tracklist.',
syncPlaylistTitle: 'This playlist is marked as [INCOMPLETE] on ListenBrainz. Click to sync with the current tracklist.',
linkedToPlaylistTitle: 'This video is linked to a ListenBrainz playlist: {title}',
playlistInProgress: 'Processing...',
tokenMissing: 'Set LB Token!',
tokenMissingTitle: 'Click to set your ListenBrainz token',
tokenMissing: 'Set LB Token!',
tokenMissingTitle: 'Click to set your ListenBrainz token',
viewReport: 'View Report',
viewReportTitle: 'View list of unmatched/unparsed tracks from the video description.',
},
de: {
loading: 'Wird geladen...',
addRecording: 'Aufnahme hinzufügen',
updateLength: 'Länge aktualisieren',
onMB: 'Auf MB ✓',
onMBMulti: 'Auf MB (Multi) ✓',
addRecordingTitle: 'Als Aufnahme zu MusicBrainz hinzufügen',
updateLengthTitle: 'Bei der verknüpften MusicBrainz-Aufnahme fehlt die Länge. Klicken, um sie auf {length}s zu aktualisieren.',
linkedToRecordingTitle: 'Dieses YouTube-Video ist mit der MusicBrainz-Aufnahme verknüpft: {title}',
linkedToMultiTitle: 'Dieses YouTube-Video ist mit mehreren Aufnahmen auf MusicBrainz verknüpft.\nKlicken, um die URL-Entitätsseite anzuzeigen.',
errorVideoNotFound: 'Video nicht gefunden / YT API-Fehler',
errorApiRateLimit: '{apiName} Ratenlimit / Serverfehler',
errorApiNetwork: '{apiName} Netzwerkfehler',
errorProcessing: 'Verarbeitungsfehler',
// Playlist specific strings
createPlaylist: 'LB-Playlist erstellen',
syncPlaylist: 'LB-Playlist synchronisieren',
onLB: 'Auf LB (Playlist) ✓',
createPlaylistTitle: 'Eine neue ListenBrainz-Playlist aus der Trackliste dieses Videos erstellen.',
syncPlaylistTitle: 'Diese Playlist ist auf ListenBrainz als [INCOMPLETE] markiert. Klicken, um mit der aktuellen Trackliste zu synchronisieren.',
linkedToPlaylistTitle: 'Dieses Video ist mit einer ListenBrainz-Playlist verknüpft: {title}',
playlistInProgress: 'Verarbeite...',
tokenMissing: 'LB-Token setzen!',
tokenMissingTitle: 'Klicken, um Ihr ListenBrainz-Token festzulegen',
viewReport: 'Bericht anzeigen',
viewReportTitle: 'Liste der nicht zugeordneten und nicht verarbeiteten Titel aus der Videobeschreibung anzeigen.',
}
},
getString: function (key, substitutions) {
const langStrings = this._strings[this._language] || this._strings.en;
let str = langStrings[key] || this._strings.en[key] || `L10N_ERROR: ${key}`;
if (substitutions) {
for (const subKey in substitutions) {
str = str.replace(`{${subKey}}`, substitutions[subKey]);
}
}
return str;
}
};
/**
* Configuration object to centralize all constants and selectors.
*/
const Config = {
SHORT_APP_NAME: 'UserJS.YoutubeImport',
GOOGLE_API_KEY: 'AIzaSyC5syukuFyCSoRvMr42Geu_d_1c_cRYouU',
MUSICBRAINZ_API_ROOT: 'https://musicbrainz.org/ws/2/',
LISTENBRAINZ_API_ROOT: 'https://api.listenbrainz.org/1/',
TOKEN_STORAGE_KEY: 'listenbrainz_user_token',
YOUTUBE_API_ROOT: 'https://www.googleapis.com/youtube/v3/',
YOUTUBE_API_VIDEO_PARTS: 'snippet,id,contentDetails',
MAX_RETRIES: 5,
INITIAL_RETRY_DELAY_MS: 1000,
RETRY_BACKOFF_FACTOR: 2,
SELECTORS: {
BUTTON_DOCK: '#top-row.ytd-watch-metadata #owner.ytd-watch-metadata',
MUSICBRAINZ_MAIN_VIDEO_CHECKBOX: '[name="edit-recording.video"]',
MUSICBRAINZ_EXTERNAL_LINKS_EDITOR: '#external-links-editor',
MUSICBRAINZ_INDIVIDUAL_VIDEO_CHECKBOX: '.relationship-item input[type="checkbox"]',
},
CLASS_NAMES: {
CONTAINER: 'musicbrainz-userscript-container',
BUTTON: 'search-button',
BUTTON_READY: 'mb-ready',
BUTTON_ADDED: 'mb-added',
BUTTON_ERROR: 'mb-error',
BUTTON_INFO: 'mb-info',
BUTTON_UPDATE: 'mb-update', // Class for the update button
PLAYLIST_BUTTON: 'playlist-button',
PLAYLIST_BUTTON_SYNC: 'lb-sync',
},
MUSICBRAINZ_FREE_STREAMING_LINK_TYPE_ID: '268',
MUSICBRAINZ_FREE_STREAMING_RELATION_TYPE_ID: '7e41ef12-a124-4324-afdb-fdbae687a89c',
};
const USER_AGENT = `${Config.SHORT_APP_NAME}/${GM_info.script.version} ( ${GM_info.script.namespace} )`;
/**
* Manages the ListenBrainz user token.
*/
const TokenManager = {
_token: null,
async init() {
this._token = await GM.getValue(Config.TOKEN_STORAGE_KEY, null);
GM.registerMenuCommand('Set ListenBrainz Token', () => this.getToken(true));
},
getTokenValue() {
return this._token;
},
async getToken(forcePrompt = false) {
if (!this._token || forcePrompt) {
const success = await this.setToken();
if (!success) {
return null;
}
}
return this._token;
},
async setToken() {
const token = prompt('Please enter your ListenBrainz User Token:', this._token || '');
if (token && token.trim()) {
this._token = token.trim();
await GM.setValue(Config.TOKEN_STORAGE_KEY, this._token);
alert('ListenBrainz token saved!');
return true;
}
return false;
}
};
/**
* General utility functions.
*/
const Utils = {
/**
* Waits for an element matching the given CSS selector to appear in the DOM.
* @param {string} selector - The CSS selector of the element to wait for.
* @param {number} timeout - The maximum time (in milliseconds) to wait for the element.
* @returns {Promise<Element>} A promise that resolves with the element once found, or rejects on timeout.
*/
waitForElement: function (selector, timeout = 15000) {
return new Promise((resolve, reject) => {
const element = document.querySelector(selector);
if (element) {
resolve(element);
return;
}
let observer;
const timer = setTimeout(() => {
if (observer) observer.disconnect();
reject(new Error(`Timeout waiting for element with selector: ${selector}`));
}, timeout);
observer = new MutationObserver((mutations, obs) => {
const targetElement = document.querySelector(selector);
if (targetElement) {
clearTimeout(timer);
obs.disconnect();
resolve(targetElement);
}
});
observer.observe(document.documentElement, {
childList: true,
subtree: true
});
});
},
/**
* Performs an asynchronous HTTP request using GM.xmlHttpRequest with retry logic and exponential backoff.
* @param {Object} details - The GM.xmlHttpRequest details object (method, url, headers, data).
* @param {string} apiName - Name of the API for logging (e.g., "YouTube API", "MusicBrainz API").
* @param {number} [currentRetry=0] - The current retry attempt.
* @returns {Promise<Object>} A promise that resolves with the response object or rejects on error/exhausted retries.
*/
gmXmlHttpRequest: function (details, apiName, currentRetry = 0) {
const headers = {
"Referer": location.origin,
"Origin": location.origin,
...(details.headers || {})
};
return new Promise((resolve, reject) => {
GM.xmlHttpRequest({
method: details.method || 'GET',
url: details.url,
headers: headers,
data: details.data || null,
anonymous: details.anonymous || false,
onload: (response) => {
if (response.status >= 200 && response.status < 300) {
resolve(response);
} else if (response.status === 503 && currentRetry < Config.MAX_RETRIES) {
const delay = Config.INITIAL_RETRY_DELAY_MS * Math.pow(Config.RETRY_BACKOFF_FACTOR, currentRetry);
console.warn(`[${GM.info.script.name}] ${apiName} returned 503. Retrying in ${delay}ms (attempt ${currentRetry + 1}/${Config.MAX_RETRIES}).`);
setTimeout(() => {
Utils.gmXmlHttpRequest(details, apiName, currentRetry + 1)
.then(resolve)
.catch(reject);
}, delay);
} else {
if (!(response.status === 404 && apiName === 'MusicBrainz API')) {
console.error(`[${GM.info.script.name}] ${apiName} request failed with status ${response.status}.`);
}
const error = new Error(`Request to ${apiName} failed with status ${response.status}: ${response.responseText}`);
error.status = response.status;
error.apiName = apiName;
reject(error);
}
},
onerror: (response) => {
console.error(`[${GM.info.script.name}] ${apiName} network error:`, response);
const error = new Error(`Network error for ${apiName}: ${response.statusText}`);
error.status = response.status;
error.apiName = apiName;
reject(error);
},
ontimeout: () => {
console.error(`[${GM.info.script.name}] ${apiName} request timed out.`);
const error = new Error(`Request to ${apiName} timed out`);
error.status = 408;
error.apiName = apiName;
reject(error);
}
});
});
},
/**
* Converts ISO8601 duration to milliseconds using a single regular expression.
* Handles durations with date and time parts (e.g., P1DT12H30M5.5S).
* https://en.wikipedia.org/wiki/ISO_8601#Durations
* @param {string} str - The ISO8601 duration string.
* @returns {number} The duration in milliseconds, or NaN if invalid.
*/
ISO8601toMilliSeconds: function (str) {
// This single regex captures the optional Day part, and the optional Time part (which must be preceded by 'T').
// Groups: 1=Days, 2=Hours, 3=Minutes, 4=Seconds
const regex = /^P(?:(\d*\.?\d*)D)?(?:T(?:(\d*\.?\d*)H)?(?:(\d*\.?\d*)M)?(?:(\d*\.?\d*)S)?)?$/;
const matches = str.replace(',', '.').match(regex);
// If the regex doesn't match, or if it matches but finds no duration components (e.g., input is "P" or "PT"), return NaN.
if (!matches || matches.slice(1).every(part => part === undefined)) {
return NaN;
}
const days = parseFloat(matches[1] || 0);
const hours = parseFloat(matches[2] || 0);
const minutes = parseFloat(matches[3] || 0);
const seconds = parseFloat(matches[4] || 0);
const totalSeconds = (days * 86400) + (hours * 3600) + (minutes * 60) + seconds;
return totalSeconds * 1000;
},
/**
* Parses a block of text for track information using multiple regex patterns.
* @param {string} text The raw text (e.g., YouTube description).
* @returns {{parsedTracks: Array<Object>, unparsedLines: Array<string>}}
*/
parseTracklist: function(text) {
if (!text) {
return { parsedTracks: [], unparsedLines: [] };
}
const tracklistPatterns = [
{ // Format: StartTime - EndTime Artist - Title
regex: /^((?:\d+:)?\d+:\d+)\s*[-–—]\s*(?:\d+:)?\d+\s+(.+?)\s*[-–—]\s*(.+)$/,
map: (match) => ({ timestampStr: match[1], artist: match[2], title: match[3] })
},
{ // Format: Timestamp - Artist - Title
regex: /^((?:\d+:)?\d+:\d+)\s*[-–—]\s*(.+?)\s*[-–—]\s*(.+)$/,
map: (match) => ({ timestampStr: match[1], artist: match[2], title: match[3] })
},
{ // Format: Timestamp [Artist] - Title or Timestamp Artist - Title
regex: /^((?:\d+:)?\d+:\d+)\s+(?:\[(.+?)\]|(.+?))\s*[-–—]\s*(.+)$/,
map: (match) => ({ timestampStr: match[1], artist: match[2] || match[3], title: match[4] })
},
{ // Format: Artist - Title (Timestamp)
regex: /^(.+?)\s*[-–—]\s*(.+?)\s+\(?((\d+:)?\d+:\d+)\)?$/,
map: (match) => ({ artist: match[1], title: match[2], timestampStr: match[3] })
}
];
const lines = text.split('\n').map(line => line.trim()).filter(Boolean);
const parsedTracks = [];
const unparsedLines = [];
for (const line of lines) {
let matched = false;
for (const pattern of tracklistPatterns) {
const match = line.match(pattern.regex);
if (match) {
const { timestampStr, artist, title } = pattern.map(match);
const timeParts = timestampStr.split(':').map(Number);
let timestampSeconds = 0;
if (timeParts.length === 2) { // MM:SS
timestampSeconds = timeParts[0] * 60 + timeParts[1];
} else if (timeParts.length === 3) { // HH:MM:SS
timestampSeconds = timeParts[0] * 3600 + timeParts[1] * 60 + timeParts[2];
}
parsedTracks.push({
artist: artist.trim(),
title: title.trim(),
timestamp: timestampStr.trim(),
timestampSeconds,
originalLine: line
});
matched = true;
break; // Pattern matched, move to the next line
}
}
if (!matched) {
unparsedLines.push(line);
}
}
return { parsedTracks, unparsedLines };
},
/**
* Finds the Longest Common Subsequence (LCS) of two arrays.
* @param {Array<any>} arr1
* @param {Array<any>} arr2
* @returns {Array<any>}
*/
findLCS: function(arr1, arr2) {
const m = arr1.length;
const n = arr2.length;
const dp = Array(m + 1).fill(0).map(() => Array(n + 1).fill(0));
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (arr1[i - 1] === arr2[j - 1]) {
dp[i][j] = 1 + dp[i - 1][j - 1];
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
// Backtrack from dp[m][n] to reconstruct the LCS
const lcs = [];
let i = m, j = n;
while (i > 0 && j > 0) {
if (arr1[i - 1] === arr2[j - 1]) {
lcs.unshift(arr1[i - 1]);
i--; j--;
} else if (dp[i - 1][j] > dp[i][j - 1]) {
i--;
} else {
j--;
}
}
return lcs;
},
/**
* Groups a sorted list of deletion indices into consecutive chunks for batching.
* @param {number[]} indices - A list of indices to delete, sorted in descending order.
* @returns {Array<{index: number, count: number}>} An array of chunks to delete.
*/
groupDeletions: function(indices) {
if (indices.length === 0) {
return [];
}
const groups = [];
let currentGroup = { index: indices[0], count: 1 };
for (let i = 1; i < indices.length; i++) {
if (indices[i] === currentGroup.index - 1) {
currentGroup.index = indices[i];
currentGroup.count++;
} else {
groups.push(currentGroup);
currentGroup = { index: indices[i], count: 1 };
}
}
groups.push(currentGroup);
return groups;
}
};
/**
* Handles all interactions with the YouTube Data API.
*/
const YouTubeAPI = {
_videoDataCache: new Map(),
/**
* Fetches video data from the YouTube Data API.
* @param {string} videoId - The YouTube video ID.
* @returns {Promise<Object|null>} A promise that resolves with the video data, or null if not found/error.
*/
fetchVideoData: async function (videoId) {
if (this._videoDataCache.has(videoId)) {
const cachedData = this._videoDataCache.get(videoId);
console.log(`[${GM.info.script.name}] YouTube API response found in cache for video ID: ${videoId}.`);
return cachedData !== false ? cachedData : null;
}
const url = new URL('videos', Config.YOUTUBE_API_ROOT);
url.searchParams.append('part', Config.YOUTUBE_API_VIDEO_PARTS);
url.searchParams.append('id', videoId);
url.searchParams.append('key', Config.GOOGLE_API_KEY);
console.log(`[${GM.info.script.name}] Calling YouTube API for video ID:`, videoId);
try {
const response = await Utils.gmXmlHttpRequest({
method: 'GET',
url: url.toString(),
}, 'YouTube API');
const parsedFullResponse = JSON.parse(response.responseText);
if (parsedFullResponse.items && parsedFullResponse.items.length > 0) {
const videoData = parsedFullResponse.items[0];
this._videoDataCache.set(videoId, videoData);
return videoData;
} else {
console.log(`[${GM.info.script.name}] YouTube API returned no items for video ID: ${videoId}.`);
this._videoDataCache.set(videoId, false);
return null;
}
} catch (error) {
console.error(`[${GM.info.script.name}] Error fetching YouTube video data for ${videoId}:`, error);
this._videoDataCache.set(videoId, false);
throw error;
}
},
};
/**
* Handles all interactions with the ListenBrainz API.
*/
const rateLimitState = {
isBlocked: false,
resetTime: 0,
};
const ListenBrainzAPI = {
_searchCache: new Map(),
/**
* Generic helper for making requests to the ListenBrainz API.
* @param {string} endpoint - The API endpoint path.
* @param {Object} options - Configuration for the request.
* @param {string} options.token - The user's ListenBrainz token.
* @param {string} [options.method='GET'] - The HTTP method.
* @param {Object|null} [options.body=null] - The JSON body for POST requests.
* @returns {Promise<Object>} The parsed JSON response.
*/
async apiRequest(endpoint, { token, method = 'GET', body = null }) {
if (rateLimitState.isBlocked && Date.now() < rateLimitState.resetTime) {
const secondsRemaining = Math.ceil((rateLimitState.resetTime - Date.now()) / 1000);
const errorMessage = `Rate limited. Wait ${secondsRemaining}s.`;
console.error(`[${GM.info.script.name}] ${errorMessage}`);
throw new Error(errorMessage);
}
rateLimitState.isBlocked = false;
const url = Config.LISTENBRAINZ_API_ROOT + endpoint;
const headers = new Headers();
if (token) headers.append('Authorization', `Token ${token}`);
if (body) headers.append('Content-Type', 'application/json');
try {
const response = await Utils.gmXmlHttpRequest({
method,
url,
headers: Object.fromEntries(headers.entries()),
data: body ? JSON.stringify(body) : null,
}, 'ListenBrainz API');
const remaining = response.responseHeaders.match(/x-ratelimit-remaining:\s*(\d+)/i);
const resetIn = response.responseHeaders.match(/x-ratelimit-reset-in:\s*(\d+)/i);
if (remaining && resetIn && parseInt(remaining[1], 10) === 0) {
const resetInMs = parseInt(resetIn[1], 10) * 1000;
rateLimitState.isBlocked = true;
rateLimitState.resetTime = Date.now() + resetInMs;
}
if (response.status === 429) {
const retryAfter = response.responseHeaders.match(/retry-after:\s*(\d+)/i) || resetIn;
const retryAfterMs = parseInt(retryAfter ? retryAfter[1] : '10', 10) * 1000;
rateLimitState.isBlocked = true;
rateLimitState.resetTime = Date.now() + retryAfterMs;
throw new Error(`Rate limit exceeded. Wait ${retryAfterMs/1000}s.`);
}
return response.responseText ? JSON.parse(response.responseText) : {};
} catch (error) {
console.error(`[${GM.info.script.name}] ListenBrainz API Error:`, error);
throw error;
}
},
async searchPlaylists(query) {
if (this._searchCache.has(query)) {
return this._searchCache.get(query);
}
const token = await TokenManager.getToken();
if (!token) throw new Error("ListenBrainz token not set.");
const endpoint = `playlist/search?query=${encodeURIComponent(query)}&count=100`;
const data = await this.apiRequest(endpoint, { token });
this._searchCache.set(query, data);
return data;
},
async lookupTrack(artist, title) {
const endpoint = `metadata/lookup/?artist_name=${encodeURIComponent(artist)}&recording_name=${encodeURIComponent(title)}&metadata=false&inc=artist`;
const data = await this.apiRequest(endpoint, {});
return data.recording_mbid ? { title, creator: artist, identifier: `https://musicbrainz.org/recording/${data.recording_mbid}` } : null;
},
async createPlaylist(token, title, annotation, tracks, isPublic) {
const jspf = { playlist: { title, track: tracks, annotation, extension: { "https://musicbrainz.org/doc/jspf#playlist": { public: isPublic } } } };
return this.apiRequest('playlist/create', { method: 'POST', token, body: jspf });
},
async fetchPlaylist(token, mbid) {
const data = await this.apiRequest(`playlist/${mbid}`, { token });
return data.playlist;
},
async addMetadataToPlaylist(token, mbid, existingPlaylist, description) {
const jspf = {
playlist: {
title: existingPlaylist.title,
annotation: existingPlaylist.annotation || '',
extension: {
"https://musicbrainz.org/doc/jspf#playlist": {
public: existingPlaylist.extension["https://musicbrainz.org/doc/jspf#playlist"].public,
additional_metadata: { "youtube_description": description }
}
}
}
};
return this.apiRequest(`playlist/edit/${mbid}`, { method: 'POST', token, body: jspf });
},
async deletePlaylistItems(token, mbid, index, count) {
if (count === 0) return;
return this.apiRequest(`playlist/${mbid}/item/delete`, { method: 'POST', token, body: { index, count } });
},
async addPlaylistItemAtOffset(token, mbid, offset, tracks) {
const jspf = { playlist: { track: tracks } };
return this.apiRequest(`playlist/${mbid}/item/add/${offset}`, { method: 'POST', token, body: jspf });
},
/**
* Edits a playlist's core metadata, including title, annotation, visibility, and description.
* This function replaces the previous addMetadataToPlaylist.
* @param {string} token - The user's ListenBrainz token.
* @param {string} mbid - The MBID of the playlist to edit.
* @param {Object} details - The metadata to update.
* @param {string} details.title - The new title.
* @param {string} details.annotation - The new annotation.
* @param {boolean} details.isPublic - The public status.
* @param {string} details.description - The full description to store in additional_metadata.
*/
async editPlaylistMetadata(token, mbid, { title, annotation, isPublic, description }) {
const jspf = {
playlist: {
title,
annotation: annotation || '',
extension: {
"https://musicbrainz.org/doc/jspf#playlist": {
public: isPublic,
additional_metadata: { "youtube_description": description }
}
}
}
};
return this.apiRequest(`playlist/edit/${mbid}`, { method: 'POST', token, body: jspf });
},
};
/**
* Scans the DOM for relevant elements and extracts information.
*/
const DOMScanner = {
/**
* Checks if the current page is a YouTube video watch page.
* @returns {string|null} The video ID if it's a video page, otherwise null.
*/
getVideoId: function () {
const videoIdMatch = location.href.match(/[?&]v=([A-Za-z0-9_-]{11})/);
return videoIdMatch ? videoIdMatch[1] : null;
},
/**
* Finds the DOM element where the import button should be appended.
* @returns {Promise<HTMLElement|null>} A promise that resolves with the dock element, or null if not found.
*/
getButtonAnchorElement: async function () {
try {
const dock = await Utils.waitForElement(Config.SELECTORS.BUTTON_DOCK);
console.log(`[${GM.info.script.name}] Found button dock:`, dock);
return dock;
} catch (e) {
console.error(`[${GM.info.script.name}] Could not find button dock element:`, e);
return null;
}
},
};
/**
* Manages the creation, display, and state of the MusicBrainz import button.
*/
const RecordingButtonManager = {
_form: null,
_submitButton: null,
_textElement: null,
_containerDiv: null,
/**
* Initializes the button elements and their basic structure.
*/
init: function () {
this._containerDiv = document.createElement("div");
this._containerDiv.setAttribute("class", `holder ${Config.CLASS_NAMES.CONTAINER}`);
this._containerDiv.style.display = 'none';
this._form = document.createElement("form");
this._form.method = "get";
this._form.action = "//musicbrainz.org/recording/create";
this._form.acceptCharset = "UTF-8";
this._form.target = "_blank";
this._submitButton = document.createElement("button");
this._submitButton.type = "submit";
this._submitButton.title = L10n.getString('addRecordingTitle');
this._submitButton.setAttribute("class", Config.CLASS_NAMES.BUTTON);
this._textElement = document.createElement("span");
this._textElement.innerText = L10n.getString('loading');
const buttonContent = document.createElement('div');
buttonContent.style.display = 'flex';
buttonContent.style.alignItems = 'center';
buttonContent.appendChild(this._textElement);
this._submitButton.appendChild(buttonContent);
this._form.appendChild(this._submitButton);
this._containerDiv.appendChild(this._form);
},
/**
* Resets the button state, clearing previous form fields and setting to loading.
*/
resetState: function () {
Array.from(this._form.querySelectorAll('input[type="hidden"]')).forEach(input => this._form.removeChild(input));
while (this._containerDiv.firstChild) {
this._containerDiv.removeChild(this._containerDiv.firstChild);
}
this._containerDiv.appendChild(this._form);
this._textElement.innerText = L10n.getString('loading');
this._submitButton.className = Config.CLASS_NAMES.BUTTON;
this._submitButton.disabled = true;
this._form.style.display = 'flex';
this._containerDiv.style.display = 'flex';
},
/**
* Appends a hidden input field to the form.
* @param {string} name - The name attribute of the input field.
* @param {string} value - The value attribute of the input field.
*/
_addField: function (name, value) {
if (!this._form) return;
const field = document.createElement("input");
field.type = "hidden";
field.name = name;
field.value = value;
this._form.insertBefore(field, this._submitButton);
},
/**
* Appends the button container to the specified dock element.
* If dock is null, it appends to body as a fallback.
* @param {HTMLElement|null} dockElement - The element to append the button to.
*/
appendToDock: function (dockElement) {
if (document.body.contains(this._containerDiv)) {
return;
}
if (dockElement) {
dockElement.appendChild(this._containerDiv);
console.log(`[${GM.info.script.name}] Button UI appended to dock.`);
} else {
console.warn(`[${GM.info.script.name}] Could not find a suitable dock element. Appending to body as last resort.`);
document.body.appendChild(this._containerDiv);
this._containerDiv.style.position = 'fixed';
this._containerDiv.style.top = '10px';
this._containerDiv.style.right = '10px';
this._containerDiv.style.zIndex = '9999';
this._containerDiv.style.background = 'rgba(0,0,0,0.7)';
this._containerDiv.style.padding = '5px';
this._containerDiv.style.borderRadius = '5px';
}
},
/**
* Prepares the form with YouTube video data and displays the "Add Recording" button.
* @param {Object} youtubeVideoData - The minimalist YouTube video data.
* @param {string} canonicalYtUrl - The canonical YouTube URL.
* @param {string|null} artistMbid - The MusicBrainz Artist MBID if found.
* @param {string} videoId - The YouTube video ID.
*/
prepareAddButton: function (youtubeVideoData, canonicalYtUrl, artistMbid, videoId) {
const title = youtubeVideoData.snippet.title;
const artist = youtubeVideoData.snippet.channelTitle;
let length = 0;
if (youtubeVideoData.contentDetails && typeof youtubeVideoData.contentDetails.duration === 'string') {
length = Utils.ISO8601toMilliSeconds(youtubeVideoData.contentDetails.duration);
}
this._addField('edit-recording.name', title);
if (artistMbid) {
this._addField('artist', artistMbid);
this._addField('edit-recording.artist_credit.names.0.artist.name', artist);
} else {
this._addField('edit-recording.artist_credit.names.0.name', artist);
}
if (typeof length === 'number' && !isNaN(length) && length > 0) {
this._addField('edit-recording.length', length);
}
this._addField('edit-recording.video', '1');
this._addField('edit-recording.url.0.text', canonicalYtUrl);
this._addField('edit-recording.url.0.link_type_id', Config.MUSICBRAINZ_FREE_STREAMING_LINK_TYPE_ID);
const scriptInfo = GM_info.script;
const editNote = `${document.location.href}\n—\n${scriptInfo.name} (v${scriptInfo.version})`;
this._addField('edit-recording.edit_note', editNote);
this._textElement.innerText = L10n.getString('addRecording');
this._submitButton.className = `${Config.CLASS_NAMES.BUTTON} ${Config.CLASS_NAMES.BUTTON_READY}`;
this._submitButton.disabled = false;
this._form.style.display = 'flex';
this._submitButton.onclick = () => {
console.log(`[${GM.info.script.name}] Import button clicked. Clearing cache for video ID: ${videoId}`);
YouTubeMusicBrainzImporter._mbApi.invalidateCacheForUrl(canonicalYtUrl);
if (youtubeVideoData.snippet.channelId) {
const youtubeChannelUrl = new URL(`https://www.youtube.com/channel/${youtubeVideoData.snippet.channelId}`).toString();
YouTubeMusicBrainzImporter._mbApi.invalidateCacheForUrl(youtubeChannelUrl);
}
};
},
/**
* Displays the "On MB ✓" button, linking to the existing MusicBrainz entity.
* If the recording has no length, it provides a button to update it.
* @param {Array} allRelevantRecordingRelations - An array of recording relations.
* @param {string} urlEntityId - The MusicBrainz URL entity ID.
* @param {Object} youtubeVideoData - The minimalist YouTube video data.
*/
displayExistingButton: function (allRelevantRecordingRelations, urlEntityId, youtubeVideoData) {
this._form.style.display = 'none';
const link = document.createElement('a');
link.style.textDecoration = 'none';
link.target = '_blank';
const button = document.createElement('button');
const span = document.createElement('span');
button.appendChild(span);
link.appendChild(button);
if (allRelevantRecordingRelations.length === 1) {
const existingRecordingRelation = allRelevantRecordingRelations[0];
const recordingMBID = existingRecordingRelation.recording.id;
const recordingTitle = existingRecordingRelation.recording.title || "View Recording";
const hasLength = existingRecordingRelation.recording.length != null;
const ytHasLength = youtubeVideoData && youtubeVideoData.contentDetails && youtubeVideoData.contentDetails.duration;
// Check if the recording is missing the length and we have a length from YouTube
if (!hasLength && ytHasLength) {
const lengthInMs = Utils.ISO8601toMilliSeconds(youtubeVideoData.contentDetails.duration);
const scriptInfo = GM_info.script;
const editNote = `${document.location.href}\n—\n${scriptInfo.name} (v${scriptInfo.version})`;
const encodedEditNote = encodeURIComponent(editNote);
link.href = `//musicbrainz.org/recording/${recordingMBID}/edit?edit-recording.length=${lengthInMs}&edit-recording.edit_note=${encodedEditNote}`;
link.title = L10n.getString('updateLengthTitle', {
length: Math.round(lengthInMs / 1000)
});
span.textContent = L10n.getString('updateLength');
button.className = `${Config.CLASS_NAMES.BUTTON} ${Config.CLASS_NAMES.BUTTON_UPDATE}`;
console.log(`[${GM.info.script.name}] Displaying 'Update Length' button for recording ${recordingMBID}.`);
} else {
// Default behavior: link to the recording page
link.href = `//musicbrainz.org/recording/${recordingMBID}`;
link.title = L10n.getString('linkedToRecordingTitle', {
title: recordingTitle
});
span.textContent = L10n.getString('onMB');
button.className = `${Config.CLASS_NAMES.BUTTON} ${Config.CLASS_NAMES.BUTTON_ADDED}`;
}
} else {
console.log(`[${GM.info.script.name}] Multiple recording relations found. Linking to URL entity page.`);
link.href = `//musicbrainz.org/url/${urlEntityId}`;
link.title = L10n.getString('linkedToMultiTitle');
span.textContent = L10n.getString('onMBMulti');
button.className = `${Config.CLASS_NAMES.BUTTON} ${Config.CLASS_NAMES.BUTTON_ADDED}`;
}
this._containerDiv.appendChild(link);
console.log(`[${GM.info.script.name}] Displaying existing link button.`);
},
/**
* Displays an error button with a given message.
* @param {string} message - The error message to display.
*/
displayError: function (message) {
this.resetState();
this._textElement.innerText = message;
this._submitButton.className = `${Config.CLASS_NAMES.BUTTON} ${Config.CLASS_NAMES.BUTTON_ERROR}`;
this._submitButton.disabled = true;
this._containerDiv.style.display = 'flex';
},
/**
* Displays an informational button with a given message.
* @param {string} message - The info message to display.
*/
displayInfo: function (message) {
this.resetState();
this._textElement.innerText = message;
this._submitButton.className = `${Config.CLASS_NAMES.BUTTON} ${Config.CLASS_NAMES.BUTTON_INFO}`;
this._submitButton.disabled = true;
this._containerDiv.style.display = 'flex';
}
};
/**
* Manages the UI for the ListenBrainz playlist button.
*/
const PlaylistButtonManager = {
_containerDiv: null,
_currentButton: null,
_clearContainer: function() {
const element = this._containerDiv;
while (element && element.firstChild) {
element.removeChild(element.firstChild);
}
},
init: function () {
this._containerDiv = document.createElement("div");
this._containerDiv.setAttribute("class", `holder ${Config.CLASS_NAMES.CONTAINER}`);
this._containerDiv.style.display = 'none';
},
appendToDock: function (dockElement) {
if (document.body.contains(this._containerDiv)) {
return;
}
if (dockElement) {
dockElement.appendChild(this._containerDiv);
}
},
_createButton(text, title, className, onClick) {
const button = document.createElement("button");
button.type = "button";
button.title = title;
button.className = `${Config.CLASS_NAMES.BUTTON} ${Config.CLASS_NAMES.PLAYLIST_BUTTON} ${className || ''}`;
const span = document.createElement('span');
span.innerText = text;
button.appendChild(span);
if (onClick) {
button.addEventListener('click', onClick);
}
return button;
},
_replaceButton(newButton) {
this._clearContainer();
this._currentButton = newButton;
this._containerDiv.appendChild(this._currentButton);
this._containerDiv.style.display = 'flex';
},
hide: function() {
this._containerDiv.style.display = 'none';
},
resetState: function () {
this._clearContainer();
const loadingButton = this._createButton(L10n.getString('loading'), '', '', null);
loadingButton.disabled = true;
this._replaceButton(loadingButton);
},
setStateTokenNeeded: function(onSuccessCallback) {
const button = this._createButton(
L10n.getString('tokenMissing'),
L10n.getString('tokenMissingTitle'),
Config.CLASS_NAMES.BUTTON_ERROR,
async () => {
const token = await TokenManager.getToken(true);
if (token) {
onSuccessCallback();
}
}
);
this._replaceButton(button);
},
setStateCreate: function (onClick) {
const button = this._createButton(L10n.getString('createPlaylist'), L10n.getString('createPlaylistTitle'), '', onClick);
this._replaceButton(button);
},
setStateSync: function (title, mbid, onClick) {
const link = document.createElement('a');
link.href = `//listenbrainz.org/playlist/${mbid}`;
link.title = L10n.getString('linkedToPlaylistTitle', { title });
link.target = '_blank';
link.style.textDecoration = 'none';
const buttonExists = this._createButton(L10n.getString('onLB'), L10n.getString('linkedToPlaylistTitle', { title }), Config.CLASS_NAMES.BUTTON_ADDED, null);
link.appendChild(buttonExists);
const syncButton = this._createButton(L10n.getString('syncPlaylist'), L10n.getString('syncPlaylistTitle'), Config.CLASS_NAMES.PLAYLIST_BUTTON_SYNC, onClick);
this._clearContainer();
this._containerDiv.appendChild(link);
this._containerDiv.appendChild(syncButton);
this._containerDiv.style.display = 'flex';
},
setStateExists: function (title, targetUrl) {
const link = document.createElement('a');
const uuidRegex = /^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i;
if (uuidRegex.test(targetUrl)) {
link.href = `//listenbrainz.org/playlist/${targetUrl}`;
} else {
link.href = targetUrl.startsWith('http') ? targetUrl : `//${targetUrl}`;
}
link.title = L10n.getString('linkedToPlaylistTitle', { title });
link.target = '_blank';
link.style.textDecoration = 'none';
const text = title === 'On LB (Multi)' ? 'On LB (Multi) ✓' : L10n.getString('onLB');
const button = this._createButton(text, link.title, Config.CLASS_NAMES.BUTTON_ADDED, null);
link.appendChild(button);
this._replaceButton(link);
},
setStateReport: function(title, mbid, openReportCallback) {
const link = document.createElement('a');
link.href = `//listenbrainz.org/playlist/${mbid}`;
link.title = L10n.getString('linkedToPlaylistTitle', { title });
link.target = '_blank';
link.style.textDecoration = 'none';
const buttonExists = this._createButton(L10n.getString('onLB'), L10n.getString('linkedToPlaylistTitle', { title }), Config.CLASS_NAMES.BUTTON_ADDED, null);
link.appendChild(buttonExists);
const reportButton = this._createButton(L10n.getString('viewReport'), L10n.getString('viewReportTitle'), 'lb-report-button', openReportCallback);
this._clearContainer();
this._containerDiv.appendChild(link);
this._containerDiv.appendChild(reportButton);
this._containerDiv.style.display = 'flex';
},
setStateInProgress: function(message) {
const button = this._createButton(message, '', '', null);
button.disabled = true;
this._replaceButton(button);
},
displayError: function(message) {
const button = this._createButton(message, '', Config.CLASS_NAMES.BUTTON_ERROR, null);
button.disabled = true;
this._replaceButton(button);
}
};
/**
* High-level logic for creating and syncing ListenBrainz playlists.
*/
const PlaylistLogic = {
_generateReportHTML: function(notFoundTracks, unparsedLines, videoTitle) {
let html = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Playlist Import Report: ${videoTitle}</title>
<style>body{font-family:sans-serif;padding:1em 2em;background-color:#f9f9f9;} h1,h2{border-bottom:1px solid #ccc;padding-bottom:5px;} ul{list-style:none;padding-left:0;} li{margin-bottom:0.8em;padding:0.5em;background-color:white;border:1px solid #ddd;border-radius:4px;} a{text-decoration:none;color:#007bff;font-weight:bold;margin-left:1em;}</style>
</head><body><h1>Playlist Import Report</h1><h2>${videoTitle}</h2>`;
if (notFoundTracks.length > 0) {
html += '<h2>Unmatched Tracks</h2><p>These lines were parsed as tracks but could not be found on MusicBrainz.</p><ul>';
notFoundTracks.forEach(track => {
const mbQuery = `artist:"${track.artist}" AND recording:"${track.title}"`;
const mbSearchUrl = `https://musicbrainz.org/search?query=${encodeURIComponent(mbQuery)}&type=recording&method=advanced`;
const googleQuery = `"${track.artist}" "${track.title}"`;
const googleSearchUrl = `https://www.google.com/search?q=${encodeURIComponent(googleQuery)}&nfpr=1`;
html += `<li>${track.originalLine} <a href="${mbSearchUrl}" target="_blank">[Search MB]</a> <a href="${googleSearchUrl}" target="_blank">[Search Google]</a></li>`;
});
html += '</ul>';
}
if (unparsedLines.length > 0) {
html += '<h2>Unparsed Lines</h2><p>These lines from the description did not match any track format.</p><ul>';
unparsedLines.forEach(line => {
const searchUrl = `https://www.google.com/search?q=${encodeURIComponent(line)}`;
html += `<li>${line} <a href="${searchUrl}" target="_blank">[Search Google]</a></li>`;
});
html += '</ul>';
}
html += '</body></html>';
return html;
},
async _processTracklist(description, progressCallback) {
const { parsedTracks, unparsedLines } = Utils.parseTracklist(description);
if (parsedTracks.length === 0) {
return { foundTracks: [], notFoundTracks: [], unparsedLines };
}
parsedTracks.sort((a, b) => a.timestampSeconds - b.timestampSeconds);
// --- Heuristic ---
const uniqueArtists = new Set(parsedTracks.map(t => t.artist)).size;
const uniqueTitles = new Set(parsedTracks.map(t => t.title)).size;
const parserIsLikelySwapped = (uniqueArtists > 0 && uniqueTitles > 0 && parsedTracks.length > 3)
? (uniqueArtists > uniqueTitles)
: false;
if (parserIsLikelySwapped) {
console.log(`[${GM.info.script.name}] Tracklist heuristic: uniqueArtists=${uniqueArtists}, uniqueTitles=${uniqueTitles}. Parser output likely swapped. Prioritizing swapped lookup.`);
} else {
console.log(`[${GM.info.script.name}] Tracklist heuristic: uniqueArtists=${uniqueArtists}, uniqueTitles=${uniqueTitles}. Parser output seems correct. Prioritizing parser order lookup.`);
}
// --- End of heuristic ---
let foundTracks = [];
let potentiallyNotFound = []; // Tracks not found in the first pass
// --- First Pass: Use the heuristic's best guess ---
let i = 0;
for (const track of parsedTracks) {
if (progressCallback) progressCallback(i, parsedTracks.length, 'Pass 1'); // Indicate pass 1
const artistGuess1 = parserIsLikelySwapped ? track.title.trim() : track.artist.trim();
const titleGuess1 = parserIsLikelySwapped ? track.artist.trim() : track.title.trim();
try {
const result = await ListenBrainzAPI.lookupTrack(artistGuess1, titleGuess1);
if (result) {
foundTracks.push(result);
} else {
// Add original track object with the heuristic's guess applied for potential second pass/reporting
potentiallyNotFound.push({
artist: artistGuess1,
title: titleGuess1,
timestamp: track.timestamp,
timestampSeconds: track.timestampSeconds,
originalLine: track.originalLine,
// Store the alternative guess for the second pass
altArtist: parserIsLikelySwapped ? track.artist.trim() : track.title.trim(),
altTitle: parserIsLikelySwapped ? track.title.trim() : track.artist.trim()
});
}
} catch (error) {
console.error(`[${GM.info.script.name}] Error during Pass 1 lookup for track: "${artistGuess1} - ${titleGuess1}" (Original line: ${track.originalLine})`, error);
// Add to potentiallyNotFound on error too, using heuristic guess for report
potentiallyNotFound.push({
artist: artistGuess1,
title: titleGuess1,
timestamp: track.timestamp,
timestampSeconds: track.timestampSeconds,
originalLine: track.originalLine,
altArtist: parserIsLikelySwapped ? track.artist.trim() : track.title.trim(),
altTitle: parserIsLikelySwapped ? track.title.trim() : track.artist.trim()
});
}
i++;
}
// --- Second Pass (Conditional): Try swapped order only if the first pass found nothing ---
let finalNotFoundTracks = potentiallyNotFound; // Assume all potential misses are final unless found in pass 2
if (foundTracks.length === 0 && potentiallyNotFound.length > 0) {
console.log(`[${GM.info.script.name}] First pass found no tracks. Starting second pass with swapped order.`);
foundTracks = []; // Reset foundTracks for the second pass results
finalNotFoundTracks = []; // Reset finalNotFoundTracks for the second pass results
let j = 0;
for (const trackInfo of potentiallyNotFound) {
if (progressCallback) progressCallback(j, potentiallyNotFound.length, 'Pass 2'); // Indicate pass 2
try {
// Use the alternative guess stored earlier
const result = await ListenBrainzAPI.lookupTrack(trackInfo.altArtist, trackInfo.altTitle);
if (result) {
foundTracks.push(result);
} else {
// Track still not found, add its *heuristic guess* to final report list
console.log(`[${GM.info.script.name}] Track still not found on Pass 2: "${trackInfo.artist} - ${trackInfo.title}" (Original line: ${trackInfo.originalLine})`);
finalNotFoundTracks.push({
artist: trackInfo.artist, // Report using heuristic guess
title: trackInfo.title, // Report using heuristic guess
timestamp: trackInfo.timestamp,
timestampSeconds: trackInfo.timestampSeconds,
originalLine: trackInfo.originalLine
});
}
} catch (error) {
console.error(`[${GM.info.script.name}] Error during Pass 2 lookup for track: "${trackInfo.altArtist} - ${trackInfo.altTitle}" (Original line: ${trackInfo.originalLine})`, error);
// Add heuristic guess to report on error
finalNotFoundTracks.push({
artist: trackInfo.artist,
title: trackInfo.title,
timestamp: trackInfo.timestamp,
timestampSeconds: trackInfo.timestampSeconds,
originalLine: trackInfo.originalLine
});
}
j++;
}
} else if (potentiallyNotFound.length > 0){
// First pass found *some* tracks, so don't run second pass.
// Convert potentiallyNotFound (which includes altArtist/altTitle)
// back to the simple structure needed for the report.
finalNotFoundTracks = potentiallyNotFound.map(trackInfo => ({
artist: trackInfo.artist, // Report using heuristic guess
title: trackInfo.title, // Report using heuristic guess
timestamp: trackInfo.timestamp,
timestampSeconds: trackInfo.timestampSeconds,
originalLine: trackInfo.originalLine
}));
console.log(`[${GM.info.script.name}] First pass found ${foundTracks.length} tracks. Skipping second pass.`);
}
return { foundTracks, notFoundTracks: finalNotFoundTracks, unparsedLines };
},
async createPlaylist(ytData, canonicalYtUrl) {
const token = await TokenManager.getToken();
if (!token) {
PlaylistButtonManager.setStateTokenNeeded(() => this.createPlaylist(ytData, canonicalYtUrl));
return;
}
PlaylistButtonManager.setStateInProgress('Processing...');
try {
const { foundTracks, notFoundTracks, unparsedLines } = await this._processTracklist(ytData.snippet.description, (current, total) => {
PlaylistButtonManager.setStateInProgress(`Looking up: ${current}/${total}`);
});
if (foundTracks.length === 0) {
PlaylistButtonManager.displayError('No tracks found');
return;
}
let playlistTitle = ytData.snippet.title;
if (notFoundTracks.length > 0) {
playlistTitle = `[INCOMPLETE] ${playlistTitle}`;
}
PlaylistButtonManager.setStateInProgress('Creating...');
const createResponse = await ListenBrainzAPI.createPlaylist(token, playlistTitle, canonicalYtUrl, foundTracks, true);
const newMbid = createResponse.playlist_mbid;
PlaylistButtonManager.setStateInProgress('Storing metadata...');
await ListenBrainzAPI.editPlaylistMetadata(token, newMbid, {
title: playlistTitle,
annotation: canonicalYtUrl,
isPublic: true,
description: ytData.snippet.description
});
if (notFoundTracks.length > 0 || unparsedLines.length > 0) {
const reportHtml = this._generateReportHTML(notFoundTracks, unparsedLines, ytData.snippet.title);
const openReport = () => {
const blob = new Blob([reportHtml], { type: 'text/html' });
const url = URL.createObjectURL(blob);
const reportWindow = window.open(url);
if (reportWindow) {
reportWindow.addEventListener('unload', () => {
URL.revokeObjectURL(url);
});
} else {
alert('Popup blocked! Please allow popups for this site to view the report.');
}
};
PlaylistButtonManager.setStateReport(playlistTitle, newMbid, openReport);
} else {
PlaylistButtonManager.setStateExists(playlistTitle, newMbid);
}
} catch (error) {
PlaylistButtonManager.displayError('Creation Failed');
console.error("Error creating playlist:", error);
}
},
async syncPlaylist(ytData, canonicalYtUrl, playlistMbid) {
const token = await TokenManager.getToken();
if (!token) {
PlaylistButtonManager.setStateTokenNeeded(() => this.syncPlaylist(ytData, canonicalYtUrl, playlistMbid));
return;
}
PlaylistButtonManager.setStateInProgress('Syncing...');
try {
// Step 1: Fetch existing playlist and process new tracklist
PlaylistButtonManager.setStateInProgress('Fetching data...');
const existingPlaylist = await ListenBrainzAPI.fetchPlaylist(token, playlistMbid);
const oldTracks = existingPlaylist.track || [];
const oldMbids = oldTracks.map(t => t.identifier[0].split('/').pop());
const { foundTracks: newTracks, notFoundTracks, unparsedLines } = await this._processTracklist(ytData.snippet.description, (current, total) => {
PlaylistButtonManager.setStateInProgress(`Looking up: ${current}/${total}`);
});
const newMbids = newTracks.map(t => t.identifier.split('/').pop());
// Steps 2 & 3: Calculate and perform deletions and additions
PlaylistButtonManager.setStateInProgress('Updating tracks...');
const lcsMbids = Utils.findLCS(oldMbids, newMbids);
const lcsMbidsSet = new Set(lcsMbids);
const indicesToDelete = oldMbids.map((mbid, index) => lcsMbidsSet.has(mbid) ? -1 : index).filter(index => index !== -1);
indicesToDelete.sort((a, b) => b - a);
const deleteGroups = Utils.groupDeletions(indicesToDelete);
for (const group of deleteGroups) {
await ListenBrainzAPI.deletePlaylistItems(token, playlistMbid, group.index, group.count);
}
const currentServerMbids = oldMbids.filter(mbid => lcsMbidsSet.has(mbid));
let serverIndex = 0;
for (let i = 0; i < newMbids.length; i++) {
const newMbid = newMbids[i];
if (serverIndex < currentServerMbids.length && currentServerMbids[serverIndex] === newMbid) {
serverIndex++;
} else {
const chunkToAdd = [];
let lookaheadIndex = i;
while (lookaheadIndex < newMbids.length && (serverIndex >= currentServerMbids.length || currentServerMbids[serverIndex] !== newMbids[lookaheadIndex])) {
const trackToAdd = newTracks.find(t => t.identifier.endsWith(newMbids[lookaheadIndex]));
chunkToAdd.push(trackToAdd);
lookaheadIndex++;
}
if (chunkToAdd.length > 0) {
await ListenBrainzAPI.addPlaylistItemAtOffset(token, playlistMbid, i, chunkToAdd);
i = lookaheadIndex - 1;
}
}
}
// Step 4: Update Playlist Metadata on the server
PlaylistButtonManager.setStateInProgress('Updating title...');
let finalTitle = existingPlaylist.title;
if (notFoundTracks.length === 0) {
finalTitle = existingPlaylist.title.replace(/\[INCOMPLETE\]\s*/, '');
} else if (!existingPlaylist.title.startsWith('[INCOMPLETE]')) {
finalTitle = `[INCOMPLETE] ${existingPlaylist.title}`;
}
const isPublic = existingPlaylist.extension["https://musicbrainz.org/doc/jspf#playlist"].public;
await ListenBrainzAPI.editPlaylistMetadata(token, playlistMbid, {
title: finalTitle,
annotation: existingPlaylist.annotation,
isPublic: isPublic,
description: ytData.snippet.description
});
if (notFoundTracks.length > 0 || unparsedLines.length > 0) {
const reportHtml = this._generateReportHTML(notFoundTracks, unparsedLines, ytData.snippet.title);
const openReport = () => {
const blob = new Blob([reportHtml], { type: 'text/html' });
const url = URL.createObjectURL(blob);
const reportWindow = window.open(url);
if (reportWindow) {
reportWindow.addEventListener('unload', () => {
URL.revokeObjectURL(url);
});
} else {
alert('Popup blocked! Please allow popups for this site to view the report.');
}
};
PlaylistButtonManager.setStateReport(finalTitle, playlistMbid, openReport);
} else {
PlaylistButtonManager.setStateExists(finalTitle, playlistMbid);
}
} catch (error) {
PlaylistButtonManager.displayError('Sync Failed');
console.error("Error syncing playlist:", error);
}
},
};
/**
* Main application logic for the userscript.
*/
const YouTubeMusicBrainzImporter = {
_previousUrl: '',
_processingVideoId: null,
_currentProcessingPromise: null,
_navigationTimeoutId: null,
_prefetchedDataPromise: null,
_prefetchedVideoId: null,
_mbApi: null,
lookupMbUrls: async function (canonicalUrls) {
try {
return await this._mbApi.lookupUrl(canonicalUrls, ['recording-rels', 'artist-rels']);
} catch (error) {
if (error.name === 'PermanentError') {
console.log(`[${GM.info.script.name}] A URL was not found in MusicBrainz (404), which is expected.`);
} else {
console.error(`[${GM.info.script.name}] An unexpected error occurred looking up MusicBrainz URLs:`, error);
}
// On error, return a map with null values for all requested URLs
const resultsMap = new Map();
const urls = Array.isArray(canonicalUrls) ? canonicalUrls : [canonicalUrls];
urls.forEach(url => resultsMap.set(url, null));
return resultsMap;
}
},
_extractArtistMbid: function (channelUrlEntity) {
if (!channelUrlEntity?.relations) return null;
const artistRelation = channelUrlEntity.relations.find(rel => rel['target-type'] === 'artist' && rel.artist);
return artistRelation?.artist.id || null;
},
/**
* Initializes the application: injects CSS and sets up observers.
*/
init: function () {
this._mbApi = new MusicBrainzAPI({ user_agent: USER_AGENT });
this._injectCSS();
TokenManager.init(); // Initialize token manager
RecordingButtonManager.init();
PlaylistButtonManager.init(); // Initialize playlist button manager
this._setupObservers();
this._setupUrlChangeListeners();
this._previousUrl = window.location.href;
this.triggerUpdate(DOMScanner.getVideoId());
},
/**
* Injects custom CSS rules into the document head for button styling.
*/
_injectCSS: function () {
const head = document.head || document.getElementsByTagName('head')[0];
if (head) {
const style = document.createElement('style');
style.setAttribute('type', 'text/css');
style.textContent = `
.${Config.CLASS_NAMES.CONTAINER} {
/* Add any container specific styles here if needed */
}
.dashbox {
padding-bottom: 4px;
}
.button-area {
display: flex;
padding: 5px;
}
.button-favicon {
height: 1.25em;
margin-left: 5px;
}
.holder {
height: 100%;
display: flex;
align-items: center;
}
.${Config.CLASS_NAMES.BUTTON} {
border-radius: 18px;
border: none;
padding: 0px 10px;
font-size: 14px;
height: 36px;
color: white;
cursor: pointer;
display: flex;
align-items: center;
text-decoration: none;
margin: 0px 0 0 8px;
background-color: #f8f8f8;
color: #0f0f0f;
transition: background-color .3s;
}
.${Config.CLASS_NAMES.BUTTON}:hover {
background-color: #e0e0e0;
}
.${Config.CLASS_NAMES.BUTTON}[disabled] {
opacity: 0.7;
cursor: not-allowed;
}
.${Config.CLASS_NAMES.BUTTON}.${Config.CLASS_NAMES.BUTTON_READY} {
background-color: #BA478F;
color: white;
}
.${Config.CLASS_NAMES.BUTTON}.${Config.CLASS_NAMES.BUTTON_READY}:hover {
background-color: #a53f7c;
}
.${Config.CLASS_NAMES.BUTTON}.${Config.CLASS_NAMES.BUTTON_ADDED} {
background-color: #a4a4a4;
color: white;
}
.${Config.CLASS_NAMES.BUTTON}.${Config.CLASS_NAMES.BUTTON_ADDED}:hover {
background-color: #8c8c8c;
}
.${Config.CLASS_NAMES.BUTTON}.${Config.CLASS_NAMES.BUTTON_UPDATE} {
background-color: #3ea6ff; /* A different color to stand out */
color: white;
}
.${Config.CLASS_NAMES.BUTTON}.${Config.CLASS_NAMES.BUTTON_UPDATE}:hover {
background-color: #3593e0;
}
.${Config.CLASS_NAMES.BUTTON}.${Config.CLASS_NAMES.BUTTON_ERROR} {
background-color: #cc0000;
color: white;
}
.${Config.CLASS_NAMES.BUTTON}.${Config.CLASS_NAMES.BUTTON_INFO} {
background-color: #3ea6ff;
color: white;
}
.${Config.CLASS_NAMES.PLAYLIST_BUTTON} {
background-color: #eb743b;
color: white;
}
.${Config.CLASS_NAMES.PLAYLIST_BUTTON}:hover {
background-color: #d16631;
}
.${Config.CLASS_NAMES.PLAYLIST_BUTTON}.${Config.CLASS_NAMES.PLAYLIST_BUTTON_SYNC} {
background-color: #007bff;
}
.${Config.CLASS_NAMES.PLAYLIST_BUTTON}.${Config.CLASS_NAMES.PLAYLIST_BUTTON_SYNC}:hover {
background-color: #0069d9;
}
.${Config.CLASS_NAMES.PLAYLIST_BUTTON}.${Config.CLASS_NAMES.PLAYLIST_BUTTON_SYNC}:hover {
background-color: #0069d9;
}
.lb-report-button {
background-color: #ffc107 !important;
color: black !important;
}
.lb-report-button:hover {
background-color: #e0a800 !important;
}
`;
head.appendChild(style);
}
},
/**
* Sets up observers for YouTube's SPA navigation.
*/
_setupObservers: function () {
document.addEventListener('yt-navigate-finish', (event) => {
console.log(`[${GM.info.script.name}] 'yt-navigate-finish' event detected.`);
if (this._navigationTimeoutId) {
clearTimeout(this._navigationTimeoutId);
this._navigationTimeoutId = null;
console.log(`[${GM.info.script.name}] Cleared previous navigation timeout.`);
}
this._navigationTimeoutId = setTimeout(() => {
const currentVideoId = DOMScanner.getVideoId();
this.triggerUpdate(currentVideoId);
}, 500);
});
},
/**
* Sets up event listeners for URL changes to initiate pre-fetching of data.
*/
_setupUrlChangeListeners: function () {
document.addEventListener('yt-navigate-start', () => {
const currentVideoId = DOMScanner.getVideoId();
if (currentVideoId && currentVideoId !== this._prefetchedVideoId) {
console.log(`[${GM.info.script.name}] 'yt-navigate-start' detected for video ID: ${currentVideoId}. Initiating pre-fetch.`);
this._prefetchedVideoId = currentVideoId;
this._prefetchedDataPromise = this._startPrefetching(currentVideoId);
} else if (!currentVideoId && this._prefetchedVideoId) {
console.log(`[${GM.info.script.name}] Navigated away from video page. Clearing pre-fetch state.`);
this._prefetchedVideoId = null;
this._prefetchedDataPromise = null;
}
});
window.addEventListener('popstate', () => {
const currentVideoId = DOMScanner.getVideoId();
if (currentVideoId && currentVideoId !== this._prefetchedVideoId) {
console.log(`[${GM.info.script.name}] 'popstate' detected for video ID: ${currentVideoId}. Initiating pre-fetch.`);
this._prefetchedVideoId = currentVideoId;
this._prefetchedDataPromise = this._startPrefetching(currentVideoId);
} else if (!currentVideoId && this._prefetchedVideoId) {
console.log(`[${GM.info.script.name}] Navigated away from video page. Clearing pre-fetch state.`);
this._prefetchedVideoId = null;
this._prefetchedDataPromise = null;
}
});
console.log(`[${GM.info.script.name}] URL change listeners (yt-navigate-start, popstate) set up.`);
},
/**
* Initiates the pre-fetching of YouTube and MusicBrainz data for a given video ID.
* @param {string} videoId - The YouTube video ID.
* @returns {Promise<[Object|null, Map<string, Object|null>]>} A promise that resolves with an array
* containing [youtubeVideoData, musicBrainzUrlResultsMap] or [null, null] on error.
*/
_startPrefetching: async function (videoId) {
try {
const ytDataPromise = YouTubeAPI.fetchVideoData(videoId);
const canonicalYtUrl = new URL(`https://www.youtube.com/watch?v=${videoId}`).toString();
const ytData = await ytDataPromise;
if (!ytData) {
console.warn(`[${GM.info.script.name}] YT data not available for pre-fetching ${videoId}. Skipping MB pre-fetch.`);
return [null, null];
}
const youtubeChannelUrl = ytData.snippet.channelId ? new URL(`https://www.youtube.com/channel/${ytData.snippet.channelId}`).toString() : null;
const urlsToQuery = [canonicalYtUrl];
if (youtubeChannelUrl) {
urlsToQuery.push(youtubeChannelUrl);
}
const mbResultsPromise = this.lookupMbUrls(urlsToQuery);
const [finalYtData, finalMbResults] = await Promise.all([Promise.resolve(ytData), mbResultsPromise]);
console.log(`[${GM.info.script.name}] Pre-fetching completed for video ID: ${videoId}.`);
return [finalYtData, finalMbResults];
} catch (error) {
console.error(`[${GM.info.script.name}] Error during pre-fetching for video ID: ${videoId}:`, error);
return [null, null];
}
},
/**
* Triggers the update process for a given video ID.
* This function acts as a gatekeeper to ensure only one update runs at a time.
* @param {string|null} videoId - The YouTube video ID to process.
*/
triggerUpdate: function (videoId) {
if (this._processingVideoId === videoId && this._currentProcessingPromise) {
console.log(`[${GM.info.script.name}] Already processing video ID: ${videoId}. Skipping trigger.`);
return;
}
RecordingButtonManager.resetState();
if (!videoId) {
RecordingButtonManager._containerDiv.style.display = 'none';
this._processingVideoId = null;
this._currentProcessingPromise = null;
console.log(`[${GM.info.script.name}] Not a YouTube video page. Hiding button.`);
return;
}
this._processingVideoId = videoId;
console.log(`[${GM.info.script.name}] Triggering update for video ID: ${videoId}`);
if (videoId === this._prefetchedVideoId && this._prefetchedDataPromise) {
console.log(`[${GM.info.script.name}] Using pre-fetched data for video ID: ${videoId}.`);
this._currentProcessingPromise = this._prefetchedDataPromise
.then(([ytData, mbResults]) => this._performUpdate(videoId, ytData, mbResults))
.finally(() => {
if (this._processingVideoId === videoId) {
this._processingVideoId = null;
this._currentProcessingPromise = null;
this._prefetchedDataPromise = null;
this._prefetchedVideoId = null;
}
});
} else {
console.log(`[${GM.info.script.name}] No pre-fetched data or different video. Performing full update for video ID: ${videoId}.`);
this._currentProcessingPromise = this._performUpdate(videoId)
.finally(() => {
if (this._processingVideoId === videoId) {
this._processingVideoId = null;
this._currentProcessingPromise = null;
}
});
}
},
/**
* The actual function that performs the API calls and UI updates.
* @param {string} videoId - The YouTube video ID to process.
* @param {Object|null} [prefetchedYtData=null] - Optional pre-fetched YouTube video data.
* @param {Map<string, Object|null>|null} [prefetchedMbResults=null] - Optional pre-fetched MusicBrainz URL lookup results.
* @returns {Promise<void>} A promise that resolves when the update is complete.
*/
_performUpdate: async function (videoId, prefetchedYtData = null, prefetchedMbResults = null) {
const dockElement = await DOMScanner.getButtonAnchorElement();
RecordingButtonManager.appendToDock(dockElement);
PlaylistButtonManager.appendToDock(dockElement);
let ytData = prefetchedYtData;
if (!ytData) {
try {
ytData = await YouTubeAPI.fetchVideoData(videoId);
} catch (error) {
const apiName = error.apiName || 'API';
const errorMessage = error.status === 503 ?
L10n.getString('errorApiRateLimit', { apiName }) :
L10n.getString('errorApiNetwork', { apiName });
RecordingButtonManager.displayError(errorMessage);
PlaylistButtonManager.displayError(errorMessage);
return;
}
}
if (!ytData) {
RecordingButtonManager.displayInfo(L10n.getString('errorVideoNotFound'));
PlaylistButtonManager.hide();
return;
}
const canonicalYtUrl = new URL(`https://www.youtube.com/watch?v=${videoId}`).toString();
const youtubeChannelUrl = ytData.snippet.channelId ? new URL(`https://www.youtube.com/channel/${ytData.snippet.channelId}`).toString() : null;
// ===== Run Recording Importer Logic and Playlist Logic in Parallel =====
const recordingPromise = this._handleRecordingImport(ytData, canonicalYtUrl, youtubeChannelUrl, prefetchedMbResults);
const playlistPromise = this._handlePlaylistLogic(ytData, canonicalYtUrl);
await Promise.all([recordingPromise, playlistPromise]);
},
_handleRecordingImport: async function (ytData, canonicalYtUrl, youtubeChannelUrl, prefetchedMbResults) {
RecordingButtonManager.resetState();
let mbResults = prefetchedMbResults;
try {
const urlsToQuery = [canonicalYtUrl];
if (youtubeChannelUrl) urlsToQuery.push(youtubeChannelUrl);
if (!mbResults) {
mbResults = await this.lookupMbUrls(urlsToQuery);
}
const mbVideoUrlEntity = mbResults.get(canonicalYtUrl);
const artistMbid = youtubeChannelUrl ? this._extractArtistMbid(mbResults.get(youtubeChannelUrl)) : null;
if (mbVideoUrlEntity) {
const allRelevantRecordingRelations = (mbVideoUrlEntity.relations || []).filter(
rel => rel['type-id'] === Config.MUSICBRAINZ_FREE_STREAMING_RELATION_TYPE_ID &&
rel['target-type'] === "recording" &&
rel.recording && rel.recording.id
);
if (allRelevantRecordingRelations.length > 0) {
RecordingButtonManager.displayExistingButton(allRelevantRecordingRelations, mbVideoUrlEntity.id, ytData);
} else {
RecordingButtonManager.prepareAddButton(ytData, canonicalYtUrl, artistMbid, ytData.id);
}
} else {
RecordingButtonManager.prepareAddButton(ytData, canonicalYtUrl, artistMbid, ytData.id);
}
} catch (error) {
console.error(`[${GM.info.script.name}] Error in recording import logic:`, error);
const apiName = error.apiName || 'API';
const errorMessage = error.status === 503 ? L10n.getString('errorApiRateLimit', { apiName }) : L10n.getString('errorProcessing');
RecordingButtonManager.displayError(errorMessage);
}
},
_handlePlaylistLogic: async function (ytData, canonicalYtUrl) {
PlaylistButtonManager.resetState();
const { parsedTracks } = Utils.parseTracklist(ytData.snippet.description);
if (parsedTracks.length === 0) {
PlaylistButtonManager.hide();
return;
}
if (!TokenManager.getTokenValue()) {
// Pass a function that re-runs this logic after token is set
PlaylistButtonManager.setStateTokenNeeded(() => this._handlePlaylistLogic(ytData, canonicalYtUrl));
return;
}
try {
const searchResults = await ListenBrainzAPI.searchPlaylists(canonicalYtUrl);
const perfectMatches = (searchResults.playlists || []).filter(p => p.playlist.annotation && p.playlist.annotation.includes(canonicalYtUrl));
if (perfectMatches.length === 1) {
const playlist = perfectMatches[0].playlist;
const playlistMbid = playlist.identifier.split('/').pop();
const isINCOMPLETE = playlist.title.startsWith('[INCOMPLETE]');
if (isINCOMPLETE) {
PlaylistButtonManager.setStateSync(playlist.title, playlistMbid, () => {
PlaylistLogic.syncPlaylist(ytData, canonicalYtUrl, playlistMbid);
});
} else {
PlaylistButtonManager.setStateExists(playlist.title, playlistMbid);
}
} else if (perfectMatches.length > 1) {
// Handle multiple matches case if necessary, for now link to search
const searchUrl = `https://listenbrainz.org/search/?search_type=playlist&search_term=${encodeURIComponent(canonicalYtUrl)}`;
PlaylistButtonManager.setStateExists('On LB (Multi)', searchUrl);
} else {
PlaylistButtonManager.setStateCreate(() => {
PlaylistLogic.createPlaylist(ytData, canonicalYtUrl);
});
}
} catch (error) {
console.error(`[${GM.info.script.name}] Error in playlist logic:`, error);
const apiName = error.apiName || 'API';
const errorMessage = error.status === 503 ? L10n.getString('errorApiRateLimit', { apiName }) : L10n.getString('errorProcessing');
PlaylistButtonManager.displayError(errorMessage);
}
},
};
/**
* Helper function to set the checked state of a checkbox by simulating a click.
* @param {HTMLInputElement} checkbox - The checkbox element.
* @param {boolean} isChecked - The desired checked state.
*/
function setCheckboxState(checkbox, isChecked) {
if (!checkbox || checkbox.disabled) {
return;
}
if (checkbox.checked !== isChecked) {
checkbox.click();
}
}
/**
* Handles logic specific to the MusicBrainz recording creation page.
*/
const MusicBrainzRecordingCreatePage = {
_mainVideoCheckbox: null,
_externalLinksEditor: null,
_mutationObserver: null,
_isInternalSync: false,
init: async function () {
try {
this._externalLinksEditor = await Utils.waitForElement(Config.SELECTORS.MUSICBRAINZ_EXTERNAL_LINKS_EDITOR, 10000);
this._mainVideoCheckbox = await Utils.waitForElement(Config.SELECTORS.MUSICBRAINZ_MAIN_VIDEO_CHECKBOX, 10000);
console.log(`[${GM.info.script.name}] Initializing for MusicBrainz recording create page.`);
this._setupListeners();
this._setupMutationObserver();
this._initialSync();
} catch (error) {
console.log(`[${GM.info.script.name}] Not on MusicBrainz recording create page or elements not found:`, error.message);
}
},
/**
* A wrapper function to prevent event loops during checkbox synchronization.
* It ensures the sync flag is always reset, even if an error occurs.
* @param {Function} action - The function to execute while the guard is active.
*/
_withSyncGuard: function (action) {
if (this._isInternalSync) return;
this._isInternalSync = true;
try {
action();
} finally {
this._isInternalSync = false;
}
},
/**
* Gets all 'video' checkboxes associated with external links.
* @returns {NodeListOf<HTMLInputElement>} A NodeList of the checkbox elements.
*/
_getIndividualVideoCheckboxes: function () {
return this._externalLinksEditor.querySelectorAll(Config.SELECTORS.MUSICBRAINZ_INDIVIDUAL_VIDEO_CHECKBOX);
},
/**
* Synchronizes the state of the main video checkbox to all individual video checkboxes.
* @param {boolean} isChecked - The desired checked state.
*/
_syncMainToIndividual: function (isChecked) {
this._getIndividualVideoCheckboxes().forEach(checkbox => {
setCheckboxState(checkbox, isChecked);
});
},
/**
* Synchronizes the state of individual video checkboxes to the main video checkbox.
*/
_syncIndividualToMain: function () {
const anyIndividualChecked = Array.from(this._getIndividualVideoCheckboxes()).some(checkbox => checkbox.checked);
setCheckboxState(this._mainVideoCheckbox, anyIndividualChecked);
},
/**
* Sets up event listeners for the main and existing individual video checkboxes.
*/
_setupListeners: function () {
this._mainVideoCheckbox.addEventListener('change', () => {
this._withSyncGuard(() => {
this._syncMainToIndividual(this._mainVideoCheckbox.checked);
console.log(`[${GM.info.script.name}] Main video checkbox toggled by user. Synced to individual checkboxes.`);
});
});
this._getIndividualVideoCheckboxes().forEach(checkbox => {
checkbox.addEventListener('change', () => {
this._withSyncGuard(() => {
this._syncIndividualToMain();
console.log(`[${GM.info.script.name}] Individual video checkbox toggled by user. Synced to main checkbox.`);
});
});
});
console.log(`[${GM.info.script.name}] Initial listeners set up.`);
},
/**
* Sets up a MutationObserver to detect dynamically added external link rows and attach listeners.
*/
_setupMutationObserver: function () {
this._mutationObserver = new MutationObserver(mutations => {
mutations.forEach(mutation => {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
mutation.addedNodes.forEach(node => {
if (node.nodeType === 1) {
const relationshipItems = node.matches('.relationship-item') ? [node] : node.querySelectorAll('.relationship-item');
relationshipItems.forEach(item => {
const checkbox = item.querySelector(Config.SELECTORS.MUSICBRAINZ_INDIVIDUAL_VIDEO_CHECKBOX);
if (checkbox && !checkbox.dataset.mbSyncListenerAdded) {
checkbox.addEventListener('change', () => {
this._withSyncGuard(() => {
this._syncIndividualToMain();
console.log(`[${GM.info.script.name}] New individual video checkbox toggled. Synced to main checkbox.`);
});
});
checkbox.dataset.mbSyncListenerAdded = 'true';
console.log(`[${GM.info.script.name}] Listener attached to new individual video checkbox.`);
if (this._mainVideoCheckbox && this._mainVideoCheckbox.checked) {
setCheckboxState(checkbox, true);
}
}
});
}
});
}
});
});
this._mutationObserver.observe(this._externalLinksEditor, {
childList: true,
subtree: true
});
console.log(`[${GM.info.script.name}] MutationObserver set up for external links editor.`);
},
/**
* Performs an initial synchronization of checkbox states when the script loads.
*/
_initialSync: function () {
this._withSyncGuard(() => {
if (this._mainVideoCheckbox.checked) {
this._syncMainToIndividual(true);
console.log(`[${GM.info.script.name}] Main video checkbox was pre-checked by URL. Synced all individual checkboxes to true.`);
} else {
this._syncIndividualToMain();
console.log(`[${GM.info.script.name}] Main video checkbox not pre-checked by URL. Synced main checkbox based on individual links.`);
}
});
console.log(`[${GM.info.script.name}] Initial sync completed.`);
}
};
if (window.location.href.includes('musicbrainz.org/recording/create')) {
MusicBrainzRecordingCreatePage.init();
} else if (window.location.hostname.includes('youtube.com')) {
YouTubeMusicBrainzImporter.init();
}
})();