// ==UserScript==
// @name YouTube: MusicBrainz Importer
// @namespace https://musicbrainz.org/user/chaban
// @version 2.1
// @description Imports YouTube videos to MusicBrainz as a new standalone recording
// @tag ai-created
// @author nikki, RustyNova, chaban
// @license MIT
// @match *://www.youtube.com/*
// @icon https://www.google.com/s2/favicons?sz=256&domain=youtube.com
// @grant GM.xmlHttpRequest
// @grant GM.info
// @run-at document-end
// ==/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';
/**
* 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/',
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',
},
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',
},
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} )`;
/**
* 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 = 7000) {
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,
...(details.headers || {})
};
return new Promise((resolve, reject) => {
GM.xmlHttpRequest({
method: details.method || 'GET',
url: details.url,
headers: headers,
data: details.data || '',
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(`${Config.SHORT_APP_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(`${Config.SHORT_APP_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(`${Config.SHORT_APP_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(`${Config.SHORT_APP_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 (limited to hours/minutes/seconds) to milliseconds.
* Format looks like PT1H45M5.789S (note: floats can be used)
* 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) {
var regex = /^PT(?:(\d*\.?\d*)H)?(?:(\d*\.?\d*)M)?(?:(\d*\.?\d*)S)?$/,
m = str.replace(',', '.').match(regex);
if (!m) return NaN;
return (3600 * parseFloat(m[1] || 0) + 60 * parseFloat(m[2] || 0) + parseFloat(m[3] || 0)) * 1000;
}
};
/**
* Handles all interactions with the YouTube Data API.
*/
const YouTubeAPI = {
_videoDataCache: new Map(),
/**
* Fetches minimalist 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(`${Config.SHORT_APP_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(`${Config.SHORT_APP_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];
const minimalStructuredData = {
id: videoData.id,
snippet: {
title: videoData.snippet.title,
channelTitle: videoData.snippet.channelTitle,
channelId: videoData.snippet.channelId,
},
contentDetails: {
duration: videoData.contentDetails.duration
}
};
this._videoDataCache.set(videoId, minimalStructuredData);
return minimalStructuredData;
} else {
console.log(`${Config.SHORT_APP_NAME}: YouTube API returned no items for video ID: ${videoId}.`);
this._videoDataCache.set(videoId, false);
return null;
}
} catch (error) {
console.error(`${Config.SHORT_APP_NAME}: Error fetching YouTube video data for ${videoId}:`, error);
this._videoDataCache.set(videoId, false);
throw error;
}
},
};
/**
* Handles all interactions with the MusicBrainz API.
*/
const MusicBrainzAPI = {
_urlCache: new Map(),
_requestQueue: [],
_isProcessingQueue: false,
_lastRequestTime: 0,
/**
* Throttles GM.xmlHttpRequest calls to respect MusicBrainz API rate limits.
* @param {Object} options - Request options.
* @returns {Promise<Object>} A promise that resolves with the response object.
*/
_throttledGmXmlHttpRequest: function(options) {
return new Promise((resolve, reject) => {
const request = { options, resolve, reject };
this._requestQueue.push(request);
this._processQueue();
});
},
/**
* Processes the request queue, respecting the rate limit.
*/
_processQueue: function() {
if (this._isProcessingQueue || this._requestQueue.length === 0) {
return;
}
this._isProcessingQueue = true;
const now = Date.now();
const timeSinceLastRequest = now - this._lastRequestTime;
const delay = Math.max(0, Config.INITIAL_RETRY_DELAY_MS - timeSinceLastRequest);
setTimeout(async () => {
const request = this._requestQueue.shift();
if (request) {
try {
const response = await Utils.gmXmlHttpRequest(request.options, 'MusicBrainz API');
request.resolve(response);
} catch (error) {
request.reject(error);
} finally {
this._lastRequestTime = Date.now();
this._isProcessingQueue = false;
this._processQueue();
}
} else {
this._isProcessingQueue = false;
}
}, delay);
},
/**
* Looks up multiple URLs on MusicBrainz to find existing relations.
* @param {string[]} canonicalUrls - An array of canonical URLs to look up.
* @returns {Promise<Map<string, Object|null>>} A promise that resolves with a Map where keys are URLs and values are MusicBrainz URL entity data (including relations), or null if not found/error.
*/
lookupUrls: async function(canonicalUrls) {
const resultsMap = new Map();
const urlsToFetch = [];
for (const url of canonicalUrls) {
if (this._urlCache.has(url)) {
const cachedData = this._urlCache.get(url);
if (cachedData !== false && cachedData !== null) {
console.log(`${Config.SHORT_APP_NAME}: MusicBrainz URL entity found in cache for ${url}.`);
} else {
console.log(`${Config.SHORT_APP_NAME}: MusicBrainz URL not found in cache for ${url}.`);
}
resultsMap.set(url, cachedData !== false ? cachedData : null);
} else {
urlsToFetch.push(url);
}
}
if (urlsToFetch.length === 0) {
return resultsMap;
}
const url = new URL('url', Config.MUSICBRAINZ_API_ROOT);
urlsToFetch.forEach(resUrl => url.searchParams.append('resource', resUrl));
url.searchParams.append('inc', 'recording-rels+artist-rels');
url.searchParams.append('fmt', 'json');
console.log(`${Config.SHORT_APP_NAME}: Checking MB for existing URL entities:`, url.toString());
try {
const response = await this._throttledGmXmlHttpRequest({
method: 'GET',
url: url.toString(),
headers: {
"User-Agent": USER_AGENT,
},
anonymous: true,
});
const data = JSON.parse(response.responseText);
if (urlsToFetch.length === 1) {
if (data && data.resource === urlsToFetch[0]) {
this._urlCache.set(urlsToFetch[0], data);
resultsMap.set(urlsToFetch[0], data);
} else {
this._urlCache.set(urlsToFetch[0], false);
resultsMap.set(urlsToFetch[0], null);
}
} else {
if (data.urls && data.urls.length > 0) {
for (const urlEntity of data.urls) {
const originalUrl = urlsToFetch.find(u => u === urlEntity.resource);
if (originalUrl) {
this._urlCache.set(originalUrl, urlEntity);
resultsMap.set(originalUrl, urlEntity);
}
}
}
}
for (const url of urlsToFetch) {
if (!resultsMap.has(url)) {
this._urlCache.set(url, false);
resultsMap.set(url, null);
}
}
return resultsMap;
} catch (error) {
if (error.status === 404 && urlsToFetch.length === 1) {
console.info(`${Config.SHORT_APP_NAME}: MusicBrainz URL not found (404) for single URL: ${urlsToFetch[0]}. This is expected and handled.`);
this._urlCache.set(urlsToFetch[0], false);
resultsMap.set(urlsToFetch[0], null);
return resultsMap;
} else {
console.error(`${Config.SHORT_APP_NAME}: Error looking up MusicBrainz URLs:`, error);
throw error;
}
}
},
/**
* Extracts the Artist MBID from a MusicBrainz URL entity if it contains artist relations.
* @param {Object|null} channelUrlEntity - The MusicBrainz URL entity object for a channel.
* @returns {string|null} The Artist MBID if found, otherwise null.
*/
_extractArtistMbid: function(channelUrlEntity) {
if (!channelUrlEntity || !channelUrlEntity.relations) return null;
for (const relation of channelUrlEntity.relations) {
if (relation['target-type'] === 'artist' && relation.artist && relation.artist.id) {
return relation.artist.id;
}
}
return null;
}
};
/**
* 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, 5000);
console.log(`${Config.SHORT_APP_NAME}: Found button dock:`, dock);
return dock;
} catch (e) {
console.error(`${Config.SHORT_APP_NAME}: Could not find button dock element:`, e);
return null;
}
},
};
/**
* Manages the creation, display, and state of the MusicBrainz import button.
*/
const ButtonManager = {
_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 = "Add to MusicBrainz as recording";
this._submitButton.setAttribute("class", Config.CLASS_NAMES.BUTTON);
this._textElement = document.createElement("span");
this._textElement.innerText = "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 = "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(`${Config.SHORT_APP_NAME}: Button UI appended to dock.`);
} else {
console.warn(`${Config.SHORT_APP_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;
const 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);
}
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 = "Add Recording";
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(`${Config.SHORT_APP_NAME}: Import button clicked. Clearing cache for video ID: ${videoId}`);
MusicBrainzAPI._urlCache.delete(canonicalYtUrl);
if (youtubeVideoData.snippet.channelId) {
const youtubeChannelUrl = new URL(`https://www.youtube.com/channel/${youtubeVideoData.snippet.channelId}`).toString();
MusicBrainzAPI._urlCache.delete(youtubeChannelUrl);
}
};
},
/**
* Displays the "On MB ✓" button, linking to the existing MusicBrainz entity.
* @param {Array} allRelevantRecordingRelations - An array of recording relations.
* @param {string} urlEntityId - The MusicBrainz URL entity ID.
*/
displayExistingButton: function(allRelevantRecordingRelations, urlEntityId) {
this._form.style.display = 'none';
const link = document.createElement('a');
link.style.textDecoration = 'none';
link.target = '_blank';
const button = document.createElement('button');
button.className = `${Config.CLASS_NAMES.BUTTON} ${Config.CLASS_NAMES.BUTTON_ADDED}`;
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";
link.href = `//musicbrainz.org/recording/${recordingMBID}`;
link.title = `This YouTube video is linked to MusicBrainz recording: ${recordingTitle}`;
span.textContent = 'On MB ✓';
} else {
console.log(`${Config.SHORT_APP_NAME}: Multiple recording relations found. Linking to URL entity page.`);
link.href = `//musicbrainz.org/url/${urlEntityId}`;
link.title = `This YouTube video is linked to multiple recordings on MusicBrainz.
Click to view URL entity page.`;
span.textContent = 'On MB (Multi) ✓';
}
this._containerDiv.appendChild(link);
console.log(`${Config.SHORT_APP_NAME}: Displaying 'On MB ✓' 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';
}
};
/**
* Main application logic for the userscript.
*/
const YouTubeMusicBrainzImporter = {
_previousUrl: '',
_processingVideoId: null,
/**
* Initializes the application: injects CSS and sets up observers.
*/
init: function() {
this._injectCSS();
ButtonManager.init();
this._setupObservers();
this._previousUrl = window.location.href;
this.runUpdate(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_ERROR} {
background-color: #cc0000;
color: white;
}
.${Config.CLASS_NAMES.BUTTON}.${Config.CLASS_NAMES.BUTTON_INFO} {
background-color: #3ea6ff;
color: white;
}
`;
head.appendChild(style);
}
},
/**
* Sets up observers for YouTube's SPA navigation.
*/
_setupObservers: function() {
const self = this;
document.addEventListener('yt-navigate-finish', (event) => {
console.log(`${Config.SHORT_APP_NAME}: 'yt-navigate-finish' event detected.`);
setTimeout(() => {
const currentVideoId = DOMScanner.getVideoId();
if (currentVideoId && currentVideoId !== self._processingVideoId) {
self.runUpdate(currentVideoId);
} else if (!currentVideoId) {
ButtonManager.resetState();
ButtonManager._containerDiv.style.display = 'none';
self._processingVideoId = null;
}
}, 500);
});
},
/**
* Main function to execute the process of fetching data and updating UI.
* @param {string|null} videoId - The YouTube video ID to process.
*/
runUpdate: async function(videoId) {
let ytData = null;
if (this._processingVideoId === videoId) {
console.log(`${Config.SHORT_APP_NAME}: Already processing video ID: ${videoId}. Skipping.`);
return;
}
this._processingVideoId = videoId;
if (!videoId) {
console.log(`${Config.SHORT_APP_NAME}: Not a YouTube video page. Hiding button.`);
ButtonManager._containerDiv.style.display = 'none';
this._processingVideoId = null;
return;
}
console.log(`${Config.SHORT_APP_NAME}: Starting update for video ID: ${videoId}`);
ButtonManager.resetState();
const dockElement = await DOMScanner.getButtonAnchorElement();
ButtonManager.appendToDock(dockElement);
try {
ytData = await YouTubeAPI.fetchVideoData(videoId);
if (!ytData) {
ButtonManager.displayInfo("Video Not Found / YT API Error");
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;
const urlsToQuery = [canonicalYtUrl];
if (youtubeChannelUrl) {
urlsToQuery.push(youtubeChannelUrl);
}
const mbResults = await MusicBrainzAPI.lookupUrls(urlsToQuery);
const mbVideoUrlEntity = mbResults.get(canonicalYtUrl);
const artistMbid = youtubeChannelUrl ? MusicBrainzAPI._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) {
console.log(`${Config.SHORT_APP_NAME}: Video already linked on MusicBrainz.`);
ButtonManager.displayExistingButton(allRelevantRecordingRelations, mbVideoUrlEntity.id);
} else {
console.log(`${Config.SHORT_APP_NAME}: URL entity found, but no relevant recording relations. Proceeding to add button.`);
ButtonManager.prepareAddButton(ytData, canonicalYtUrl, artistMbid, videoId);
}
} else {
console.log(`${Config.SHORT_APP_NAME}: YouTube URL not found as a URL entity on MusicBrainz. Preparing add button.`);
ButtonManager.prepareAddButton(ytData, canonicalYtUrl, artistMbid, videoId);
}
} catch (error) {
console.error(`${Config.SHORT_APP_NAME}: Unhandled error during update for video ID: ${videoId}:`, error);
const apiName = error.apiName || 'API';
if (error.status === 503) {
ButtonManager.displayError(`${apiName} Rate Limit / Server Error`);
} else if (error.status === 0) {
ButtonManager.displayError(`${apiName} Network Error`);
}
else {
ButtonManager.displayError("Processing Error");
}
} finally {
this._processingVideoId = null;
}
},
};
YouTubeMusicBrainzImporter.init();
})();