// ==UserScript==
// @name MusicBrainz Magic Tagger Button
// @description Automatically enable the green tagger button on MusicBrainz.org depending on whether Picard is running.
// @version 0.7.10
// @author Philipp Wolfer
// @namespace https://uploadedlobster.com
// @license MIT
// @icon https://raw.githubusercontent.com/phw/musicbrainz-magic-tagger-button/main/resources/mblookup-tagger-default.png
// @match https://*.musicbrainz.org/cdtoc/*
// @match https://*.musicbrainz.org/collection/*
// @match https://*.musicbrainz.org/recording/*
// @match https://*.musicbrainz.org/release-group/*
// @match https://*.musicbrainz.org/release/*
// @match https://*.musicbrainz.org/series/*
// @match https://*.musicbrainz.org/taglookup
// @match https://*.musicbrainz.org/search
// @match https://*.musicbrainz.org/artist/*/releases
// @match https://*.musicbrainz.eu/cdtoc/*
// @match https://*.musicbrainz.eu/collection/*
// @match https://*.musicbrainz.eu/recording/*
// @match https://*.musicbrainz.eu/release-group/*
// @match https://*.musicbrainz.eu/release/*
// @match https://*.musicbrainz.eu/series/*
// @match https://*.musicbrainz.eu/taglookup
// @match https://*.musicbrainz.eu/search
// @match https://*.musicbrainz.eu/artist/*/releases
// @exclude /^https://(.*\.)?musicbrainz\.(org|eu)/release/add/
// @exclude /^https://(.*\.)?musicbrainz\.(org|eu)/.*/create/
// @exclude /^https://(.*\.)?musicbrainz\.(org|eu)/.*/.*/edit/
// @exclude /^https://(.*\.)?musicbrainz\.(org|eu)/cdtoc/.*/.*/
// @exclude /^https://(.*\.)?musicbrainz\.(org|eu)/collection/.*/.*/
// @exclude /^https://(.*\.)?musicbrainz\.(org|eu)/release-group/.*/.*/
// @exclude /^https://(.*\.)?musicbrainz\.(org|eu)/series/.*/.*/
// @grant GM.xmlHttpRequest
// @connect 127.0.0.1
// @inject-into content
// @noframes
// @homepageURL https://github.com/phw/musicbrainz-magic-tagger-button
// ==/UserScript==
// You can change the default port below if you have running Picard on a
// different port then the default 8000.
const TAGGER_DEFAULT_PORT = 8000
// As Picard can end up running on a higher port if the configured port is in
// use, this script probes a range of ports up to TAGGER_MAX_PORT. You can
// lower this value or even set it to TAGGER_DEFAULT_PORT to reduce the amount
// of probing requests.
const TAGGER_MAX_PORT = 8010
// Usually Picard will listen on your local device only. You might want to
// set this to a different IP address in your local network if Picard is
// running on a different computer then your browser.
const TAGGER_HOST = '127.0.0.1'
// MusicBrainz Magic Tagger Button - Copyright (c) 2021-2022 Philipp Wolfer
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
const TAGGER_ERROR_MESSAGE = 'Loading this release or recording into MusicBrainz Picard failed.\nPlease make sure Picard is running and the browser integration is activated.'
const TAGGER_ICON_SUCCESS = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACkAAAANCAMAAAADg7fkAAAAUVBMVEUAAAAzMzM/OzxDQ0NMSUpVVlhaV1hmZmZ2c3R7fHWEhX2Nj4WXmIyZmZmfoaShopOqq5isq6uvmv+zs566ubnDw6fMy6zMzMzW1dXj4+P///9p+Ql3AAAAhElEQVQoz42RUQ+CMAyEe0NBx+aGYNX9/x9qRyFkL9h7uDTdl+aWo1I1Ofov4T6XLrB/nquSEZ7ZQt4ws4lcMId+XEnSzWa0G20rlxMe13En62trOggJ9rHEoSXXUzodpEsZEcFwU3JmHzSnJjsOtznLhGz7uxSE+8tGlm8PW0eit6H3H3qWGo9r6lL+AAAAAElFTkSuQmCC'
const TAGGER_ICON_ERROR = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACkAAAANCAMAAAADg7fkAAAAUVBMVEUAAAAzMzM/OzxDQ0NMSUpVVlhaV1hmZmZ2c3R7fHWEhX2Nj4WXmIyZmZmfoaShopOqq5isq6uzs566ubnDw6fMy6zMzMzW1dXj4+P/Zmb///8r7DMeAAAAh0lEQVQoz42RyQ6DMAxEM0BpCUkJi2mT///QJpgIq4fWI3k5PI3GsklFc2P+K3OvrnVk428V0sMSacgHFlKRKxbXjyeZ0xzFLV5bmU2Y8LyNJ8llvttBgqxP/l49o/Bk8vKcAjyc9BRehreaM1gncwqu4nz7jKC7PT8Iw6Yj07uH7kdZu+LvH+KRIrjjFXuTAAAAAElFTkSuQmCC'
function logger (level, ...args) {
if (!console) {
return
}
let func = console.log
if (level && typeof console[level] === 'function') {
func = console[level]
}
func('[Magic Tagger Button]', ...args)
}
const debug = (...args) => logger('debug', ...args)
const log = (...args) => logger('log', ...args)
const warn = (...args) => logger('warn', ...args)
const error = (...args) => logger('error', ...args)
let xmlHttpRequest
if (typeof (GM) !== 'undefined' && GM.xmlHttpRequest) {
debug('Using GM.xmlHttpRequest')
xmlHttpRequest = GM.xmlHttpRequest
} else {
debug('Using XMLHttpRequest')
xmlHttpRequest = function xmlHttpRequest (details) {
const xhr = new XMLHttpRequest()
xhr.timeout = details.timeout || 0
xhr.open(details.method, details.url)
xhr.onload = details.onload.bind(null, xhr)
xhr.onerror = details.onerror.bind(null, xhr)
xhr.send()
}
}
function makeRequest (method, url) {
return new Promise((resolve, reject) => {
const successHandler = (response) => {
resolve({
method: method,
url: url,
status: response.status,
statusText: response.statusText,
responseText: response.responseText,
})
}
const errorHandler = (response) => {
const msg = `Request failed ${method} ${url}: ${response.status} ${response.statusText}`
reject(new Error(msg))
}
xmlHttpRequest({
method: method,
url: url,
timeout: 200,
onload: successHandler,
onerror: errorHandler,
ontimeout: errorHandler,
})
})
}
async function probeTagger (port) {
try {
const response = await makeRequest('GET', `http://${TAGGER_HOST}:${port}`)
debug(response)
const text = response.responseText || ''
return text.match(/MusicBrainz-Picard/) || text.match(/Nothing to see here/)
} catch (reason) {
debug(reason)
return false
}
}
async function detectTaggerPort () {
for (let port = TAGGER_DEFAULT_PORT; port <= TAGGER_MAX_PORT; port++) {
debug(`Probing port ${port}`)
if (await probeTagger(port)) {
return port
}
}
}
function allTaggerButtons () {
return document.querySelectorAll('a.tagger-icon')
}
function findTaggerButton () {
const button = document.querySelector('a.tagger-icon')
if (button && button.href) {
const url = new URL(button.href)
return {
protocol: url.protocol,
host: url.host,
port: parseInt(url.port, 10)
}
}
}
function setTaggerButtonStatus (button, icon, title) {
button.setAttribute('title', title)
const img = button.getElementsByTagName('img')[0]
img.setAttribute('src', icon)
}
function improveTaggerButtons () {
const taggerButtons = allTaggerButtons()
for (const button of taggerButtons) {
const newButton = button.cloneNode(true)
button.parentNode.replaceChild(newButton, button)
newButton.addEventListener('click', async (event) => {
event.preventDefault()
try {
const url = new URL(newButton.href)
url.host = TAGGER_HOST
debug('Tagger button clicked', url)
const response = await makeRequest('GET', url)
if (response.status >= 200 && response.status < 400) {
debug('Tagger request successful', response.responseText)
setTaggerButtonStatus(newButton, TAGGER_ICON_SUCCESS, response.responseText)
} else {
error('Tagger request was answered with an error', response)
setTaggerButtonStatus(newButton, TAGGER_ICON_ERROR, TAGGER_ERROR_MESSAGE)
}
} catch (reason) {
error('Tagger request error', reason)
setTaggerButtonStatus(newButton, TAGGER_ICON_ERROR, TAGGER_ERROR_MESSAGE)
}
})
}
}
function hideAllTaggerButtons () {
const taggerButtons = allTaggerButtons()
for (const button of taggerButtons) {
button.style.display = 'none'
}
}
function findCurrentlyUsedTaggerPort () {
const url = new URL(document.location.href)
const tport = parseInt(url.searchParams.get('tport'), 10)
if (tport) {
return tport
}
const taggerInfo = findTaggerButton()
if (taggerInfo) {
return taggerInfo.port
}
}
function getCurrentUrlWithPort (port) {
const url = new URL(document.location.href)
url.searchParams.set('tport', port)
return url
}
function reloadWithTaggerPort (port) {
document.location.href = getCurrentUrlWithPort(port)
}
async function disableTaggerButtons () {
hideAllTaggerButtons()
// Perform a request in the background to clear the tport from session
const url = getCurrentUrlWithPort(0)
url.pathname = '/'
try {
await makeRequest('GET', url)
} catch (reason) {
warn(reason)
}
}
function checkCurrentPageExcluded () {
const url = new URL(document.location.href)
// Special handling for search pages
if (url.pathname === '/search' && !['release', 'recording'].includes(url.searchParams.get('type'))) {
debug(`No tagger buttons on ${url.searchParams.get('type')} search page.`)
return true
}
return false
}
async function run () {
if (checkCurrentPageExcluded()) {
return
}
log('Initializing MusicBrainz Magic Tagger Button!')
const currentPort = findCurrentlyUsedTaggerPort()
if (currentPort) {
improveTaggerButtons()
if (await probeTagger(currentPort)) {
log(`Tagger button configured for port ${currentPort}.`)
return
}
}
const taggerPort = await detectTaggerPort()
if (taggerPort) {
log(`Found Picard listening on port ${taggerPort}.`)
if (currentPort !== taggerPort) {
log(`Reloading to activate tagger button on port ${taggerPort}...`)
reloadWithTaggerPort(taggerPort)
} else {
debug('Tagger button already active')
}
} else {
log('Could not find Picard listening for tagger button')
if (currentPort) {
debug('Disable tagger buttons')
await disableTaggerButtons()
}
}
}
window.addEventListener('load', run, false)