// ==UserScript==
// @name Hernan Cattaneo Resident (by MixesDB)
// @author User:Martin@MixesDB (Subfader@GitHub)
// @version 2026.07.14.1
// @description Add MixesDB creation links to Hernan Cattaneo Resident podcast episodes.
// @homepageURL https://www.mixesdb.com/w/Help:MixesDB_userscripts
// @supportURL https://discord.com/channels/1258107262833262603/1261652394799005858
// @updateURL https://raw.githubusercontent.com/mixesdb/userscripts/refs/heads/main/private/Episodes_Importer/Hernan_Cattaneo_Resident/script.user.js
// @downloadURL https://raw.githubusercontent.com/mixesdb/userscripts/refs/heads/main/private/Episodes_Importer/Hernan_Cattaneo_Resident/script.user.js
// @require https://cdn.rawgit.com/mixesdb/userscripts/refs/heads/main/includes/jquery-3.7.1.min.js
// @require https://cdn.rawgit.com/mixesdb/userscripts/refs/heads/main/includes/waitForKeyElements.js
// @require https://raw.githubusercontent.com/mixesdb/userscripts/refs/heads/main/includes/global.js?v-Hernan_Cattaneo_Resident_22
// @require https://raw.githubusercontent.com/mixesdb/userscripts/refs/heads/main/private/Episodes_Importer/funcs.js?v-2026.07.14.1
// @include https://podcast.hernancattaneo.com*
// @include https://www.mixesdb.com/w/index.php*
// @icon https://www.google.com/s2/favicons?sz=64&domain=hernancattaneo.com
// @noframes
// @grant unsafeWindow
// @run-at document-end
// ==/UserScript==
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* Load @ressource files with variables
* global.js URL needs to be changed manually
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * */
var cacheVersion = 22,
scriptName = "Hernan_Cattaneo_Resident";
loadRawCss( githubPath_raw + "includes/global.css?v-" + scriptName + "_" + cacheVersion );
/* global fixTLbox */
(function () {
'use strict';
const importer = window.MixesDBEpisodesImporter;
if (!importer) {
console.error('MixesDBEpisodesImporter dependency is not available.');
return;
}
const sourceHost = 'podcast.hernancattaneo.com';
const mixesdbHost = 'www.mixesdb.com';
const config = {
categoryTitle: 'Category:Resident_(Show)',
titleEpisodePattern: /Resident\s+(\d{3})\b/i,
artist: 'Hernan Cattaneo',
showCategory: 'Resident (Show)',
genres: ['Progressive House'],
storageKey: 'mdbResidentRemoveExistingEpisodes',
visitedLinksStorageKey: 'mdbResidentVisitedEpisodeLinks',
tleApiType: 'commaArtists',
selectors: {
listContainer: '.container.list-container',
episodeWrapper: '.container.list-container .list',
episodeHeading: 'h2.card-title.e-title',
description: 'p.e-description, .episode-description > p',
player: 'a[href*=".mp3"], audio[src*=".mp3"], audio source[src*=".mp3"]',
},
classNames: {
link: 'mdb-resident-link',
closeButton: 'mdb-resident-close-button',
toggle: 'mdb-resident-remove-existing-toggle',
hidden: 'mdb-resident-existing-episode-hidden',
apiFeedback: 'mdb-resident-tle-feedback',
apiTracklist: 'mdb-resident-tle-tracklist',
copyWaiter: 'mdb-resident-copy-waiter',
copiedWrapper: 'mdb-resident-copied-wrapper',
autoLoadMoreWaiter: 'mdb-resident-auto-load-more-waiter',
},
loadMoreSelector: '.load-more a[href]',
autoLoadMoreDelay: 700,
manualExistingEpisodes: [
714, 715, 716,
659, 660, 661, 662, 663, 664, 610, 609, 608, 607, 600,
400, 401, 402, 403, 451, 452, 453, 454, 455,
346, 388, 389, 398, 399,
],
manualExistingEpisodeTitles: [
'Resident / Episode 502 / SunsetStream Eclipse Edition / Dec 19 2020',
'Resident #stayhome #quedateencasa special - Sunsetstrip Home Edition 4/4/2020',
'Resident #stayhome #quedateencasa special - Sunsetstrip 2020',
'Resident / Episode 346 - XMas Special / Dec 23 2017',
'Resident / Episode 004 / May 28th 2011',
'Resident / Episode 003 / May 21st 2011',
'Resident / Episode 002.2 / May 14th 2011',
'Resident / Episode 002.1 / May 14th 2011',
'Resident / Episode 001.2 / May 7th 2011',
'Resident / Episode 001.1 / May 7th 2011',
],
};
let existingEpisodes = new Set(config.manualExistingEpisodes);
const manualExistingEpisodeTitles = new Set(config.manualExistingEpisodeTitles.map(normalizeEpisodeTitle));
let visitedEpisodeLinks = new Set();
let removeExistingEpisodes = false;
let autoLoadMoreTimer = null;
let autoLoadMoreInProgress = false;
let lastAutoLoadVisibleMissingCount = 0;
function loadVisitedEpisodeLinks() {
try {
visitedEpisodeLinks = new Set(JSON.parse(localStorage.getItem(config.visitedLinksStorageKey) || '[]'));
} catch (error) {
visitedEpisodeLinks = new Set();
}
}
function saveVisitedEpisodeLinks() {
localStorage.setItem(config.visitedLinksStorageKey, JSON.stringify(Array.from(visitedEpisodeLinks)));
}
function setLinkVisitedState(link) {
link.classList.toggle('is-visited', visitedEpisodeLinks.has(link.dataset.episodeNumber));
}
function markLinkVisited(link) {
visitedEpisodeLinks.add(link.dataset.episodeNumber);
saveVisitedEpisodeLinks();
setLinkVisitedState(link);
}
function stringifyLogDetails(details) {
if (details === undefined || details === null || details === '') return details || '';
if (typeof details !== 'object') return details;
try {
return JSON.stringify(details);
} catch (error) {
return String(details);
}
}
function logStep(step, details = '') {
importer.logValue(`Resident importer: ${step}`, stringifyLogDetails(details));
}
function getResidentMonthNumber(monthName) {
const month = importer.getMonthNumber(monthName);
if (month) return month;
const residentMonthAliases = {
ene: '01', enero: '01',
abr: '04', abril: '04',
ago: '08', agosto: '08',
dic: '12', diciembre: '12',
};
return residentMonthAliases[String(monthName || '').toLowerCase()] || '';
}
function getWrapperLabel(wrapper) {
return wrapper.querySelector(config.selectors.episodeHeading)?.textContent.trim()
|| wrapper.querySelector("a[href*='/e/']")?.textContent.trim()
|| wrapper.textContent.trim().replace(/\s+/g, ' ').slice(0, 120);
}
function normalizeEpisodeTitle(title) {
return String(title || '').replace(/\s+/g, ' ').trim().toLowerCase();
}
function isManualExistingEpisodeTitle(title) {
return manualExistingEpisodeTitles.has(normalizeEpisodeTitle(title));
}
function isExistingEpisode(episode, title = '') {
return existingEpisodes.has(episode.episodeNumber) || isManualExistingEpisodeTitle(title);
}
function parseEpisodeTitle(title) {
logStep('parseEpisodeTitle input', title);
const slashTitleMatch = title.match(/Resident\s*\/\s*Episode\s*(\d+)\s*\/\s*([A-Za-z]+)\s+(\d{1,2})(?:\s*\/)?\s+(\d{4})/i);
if (slashTitleMatch) {
const episodeNumber = Number(slashTitleMatch[1]);
const monthName = slashTitleMatch[2];
const month = getResidentMonthNumber(monthName);
logStep('slash title match', { episodeNumber, monthName, month, day: slashTitleMatch[3], year: slashTitleMatch[4] });
if (!month || !episodeNumber) {
logStep('slash title rejected', { title, episodeNumber, monthName, month });
return null;
}
return {
episodeNumber,
date: `${slashTitleMatch[4]}-${month}-${String(slashTitleMatch[3]).padStart(2, '0')}`,
year: slashTitleMatch[4],
};
}
const podcastTitleMatch = title.match(/^(\d+)\s+Hernan\s+Cattaneo\s+podcast\s+-\s+(\d{4})-(\d{2})-(\d{2})\b/i);
if (podcastTitleMatch) {
const episodeNumber = Number(podcastTitleMatch[1]);
logStep('podcast title match', { episodeNumber, year: podcastTitleMatch[2], month: podcastTitleMatch[3], day: podcastTitleMatch[4] });
if (!episodeNumber) {
logStep('podcast title rejected', { title, episodeNumber });
return null;
}
return {
episodeNumber,
date: `${podcastTitleMatch[2]}-${podcastTitleMatch[3]}-${podcastTitleMatch[4]}`,
year: podcastTitleMatch[2],
};
}
logStep('parseEpisodeTitle no match', title);
return null;
}
function buildMixesdbTitle(episode) {
return `${episode.date} - ${config.artist} - Resident ${importer.padNumber(episode.episodeNumber)}`;
}
function getFirstDescriptionParagraph(description) {
const firstChildParagraph = Array.from(description.children)
.find(child => child.matches('p'));
if (firstChildParagraph) return firstChildParagraph;
return description.matches('p.e-description')
&& !importer.getNodeTextWithLinebreaks(description).trim()
&& description.nextElementSibling
&& description.nextElementSibling.matches('p')
? description.nextElementSibling
: null;
}
function getTracklistSourceNode(wrapper) {
const description = wrapper.querySelector(config.selectors.description);
if (!description) {
logStep('tracklist source missing description', getWrapperLabel(wrapper));
return null;
}
return getFirstDescriptionParagraph(description) || description;
}
function fixRawTracklistLine(line) {
return line.replace(/^(.*?\S\s+-\s+)ID$/i, '$1?');
}
function lowercaseTracklistLineArtist(line) {
const artistTitleSeparator = /\s+-\s+/;
const separatorMatch = line.match(artistTitleSeparator);
if (!separatorMatch || separatorMatch.index === undefined) return line;
const artist = line.slice(0, separatorMatch.index);
const title = line.slice(separatorMatch.index + separatorMatch[0].length);
if (!artist.trim() || !title.trim()) return line;
return `${artist.toLowerCase()}${separatorMatch[0]}${title}`;
}
function renderTracklistApiFeedback(wrapper, tracklistResult, options = {}) {
const source = getTracklistSourceNode(wrapper);
if (!source) return;
let editor = wrapper.querySelector(`.${config.classNames.apiFeedback}`);
const tlApi = tracklistResult && tracklistResult.text;
const feedback = tracklistResult && tracklistResult.feedback;
if (!tlApi || !feedback) {
editor?.remove();
return;
}
if (!editor) {
editor = document.createElement('div');
editor.className = `tlEditor ${config.classNames.apiFeedback}`;
editor.innerHTML = ``;
source.insertAdjacentElement('afterend', editor);
}
editor.className = `tlEditor ${config.classNames.apiFeedback}`;
editor.querySelectorAll('#tlEditor-feedback, #tlEditor-feedback-topInfo, #tlEditor-feedback-topInfo-noList').forEach(node => node.remove());
editor.querySelector('textarea').value = tlApi;
if (typeof fixTLbox === 'function') {
const feedbackForDisplay = options.hideNoChangesFeedback
? getFeedbackWithoutNoChangesMessage(feedback)
: feedback;
fixTLbox(feedbackForDisplay, editor);
}
}
function getFeedbackWithoutNoChangesMessage(feedback) {
if (!feedback || !feedback.text || !feedback.text.includes('No changes were made.')) return feedback;
return {
...feedback,
text: feedback.text.replace(/<[^>]*>[^<]*No changes were made\.[\s\S]*?<\/[^>]+>/i, '').replace(/No changes were made\./g, '').trim(),
};
}
function extractRawTracklist(wrapper) {
const description = wrapper.querySelector(config.selectors.description);
if (!description) {
logStep('extractRawTracklist no description', getWrapperLabel(wrapper));
return '';
}
const source = getTracklistSourceNode(wrapper);
logStep('extractRawTracklist source', { episode: getWrapperLabel(wrapper), sourceTag: source?.tagName || '', sourceClass: source?.className || '' });
const rawTracklist = importer.getNodeTextWithLinebreaks(source)
.split('\n')
.map(importer.normalizeTracklistLine)
.filter(Boolean)
.map(fixRawTracklistLine)
.map(lowercaseTracklistLineArtist)
.join('\n');
logStep('extractRawTracklist result', { episode: getWrapperLabel(wrapper), lines: rawTracklist.split('\n').filter(Boolean).length, rawTracklist });
return rawTracklist;
}
function isValidPlayerUrl(playerUrl) {
if (!playerUrl) return false;
try {
const url = new URL(playerUrl, location.href);
const host = url.hostname.toLowerCase();
return ['http:', 'https:'].includes(url.protocol)
&& url.pathname.toLowerCase().endsWith('.mp3')
&& host !== 'url-del-episodio.mp3';
} catch (error) {
return false;
}
}
function getPlayerCandidateUrl(player) {
return player.href
|| player.src
|| player.getAttribute('href')
|| player.getAttribute('src')
|| '';
}
function extractPlayerUrl(wrapper) {
const players = Array.from(wrapper.querySelectorAll(config.selectors.player));
logStep('extractPlayerUrl candidates', { episode: getWrapperLabel(wrapper), count: players.length, urls: players.map(getPlayerCandidateUrl) });
const validPlayerUrl = players
.map(getPlayerCandidateUrl)
.find(isValidPlayerUrl);
const resolvedPlayerUrl = validPlayerUrl ? new URL(validPlayerUrl, location.href).toString() : '';
logStep('extractPlayerUrl result', { episode: getWrapperLabel(wrapper), resolvedPlayerUrl });
return resolvedPlayerUrl;
}
function buildEpisodePageText(episode, tracklistResult, playerUrl = '') {
return importer.buildMixPageText({
year: episode.year,
artist: config.artist,
showCategory: config.showCategory,
genres: config.genres,
tracklistResult,
playerUrl,
});
}
function getEditorTracklist(wrapper, fallbackTracklist) {
return wrapper.querySelector(`.${config.classNames.apiTracklist}`)?.value || fallbackTracklist;
}
function showCreateLinkWaiter(link) {
const waiter = document.createElement('waiter');
waiter.className = config.classNames.copyWaiter;
waiter.textContent = '…';
link.hidden = true;
link.setAttribute('aria-busy', 'true');
link.insertAdjacentElement('afterend', waiter);
return waiter;
}
function hideCreateLinkWaiter(link, waiter) {
waiter?.remove();
link.hidden = false;
link.removeAttribute('aria-busy');
}
function waitBeforeOpeningCreateLink() {
return new Promise(resolve => setTimeout(resolve, 1200));
}
async function getTracklistForCreate(wrapper, fallbackTracklist, fallbackStatus) {
const editorTracklist = getEditorTracklist(wrapper, fallbackTracklist);
if (!editorTracklist || editorTracklist === fallbackTracklist) {
return {
tracklist: {
text: fallbackTracklist,
status: fallbackStatus,
},
refreshedFeedback: false,
};
}
try {
const updatedTracklist = await importer.formatTracklist(editorTracklist, config.tleApiType);
renderTracklistApiFeedback(wrapper, updatedTracklist, { hideNoChangesFeedback: true });
return {
tracklist: updatedTracklist,
refreshedFeedback: Boolean(updatedTracklist.feedback),
};
} catch (error) {
importer.logValue('Failed to refresh Resident tracklist feedback for MixesDB', error.message || error);
return {
tracklist: {
text: editorTracklist,
status: fallbackStatus,
},
refreshedFeedback: false,
};
}
}
function setCreateLinkHref(link, title, episodeUrl, insertText) {
link.href = importer.buildMixesdbUrl(title, episodeUrl, insertText);
}
function updateCreateLinkOnClick(link, title, episodeUrl, episode, wrapper, fallbackTracklist, fallbackStatus) {
if (link.dataset.mdbResidentCreateLinkTextUpdater === '1') return;
link.dataset.mdbResidentCreateLinkTextUpdater = '1';
link.addEventListener('click', async event => {
event.preventDefault();
markLinkVisited(link);
wrapper.classList.add(config.classNames.copiedWrapper);
const waiter = showCreateLinkWaiter(link);
try {
const { tracklist, refreshedFeedback } = await getTracklistForCreate(wrapper, fallbackTracklist, fallbackStatus);
const insertText = buildEpisodePageText(episode, tracklist, extractPlayerUrl(wrapper));
setCreateLinkHref(link, title, episodeUrl, insertText);
if (refreshedFeedback) {
await waitBeforeOpeningCreateLink();
}
window.open(link.href, link.target || '_blank', 'noopener,noreferrer');
} finally {
hideCreateLinkWaiter(link, waiter);
}
});
}
function setLinkPending(link, episode) {
link.className = `${config.classNames.link} is-pending`;
link.removeAttribute('href');
link.textContent = `Checking episode ${importer.padNumber(episode.episodeNumber)}`;
}
function getDownloadLink(wrapper) {
return Array.from(wrapper.querySelectorAll('a[href*=".mp3"]'))
.find(link => isValidPlayerUrl(link.href || link.getAttribute('href')));
}
function getDedicatedCopyLinkParagraph(link) {
const currentParagraph = link.closest('p');
if (currentParagraph && currentParagraph.children.length === 1 && currentParagraph.textContent.trim() === link.textContent.trim()) {
return currentParagraph;
}
const paragraph = document.createElement('p');
paragraph.append(link);
return paragraph;
}
function placeCopyLink(link, wrapper) {
const apiFeedback = wrapper.querySelector(`.${config.classNames.apiFeedback}`);
const downloadLink = getDownloadLink(wrapper);
const paragraph = getDedicatedCopyLinkParagraph(link);
if (apiFeedback) {
apiFeedback.insertAdjacentElement('afterend', paragraph);
return;
}
if (downloadLink) {
const downloadParagraph = downloadLink.closest('p');
(downloadParagraph || downloadLink).insertAdjacentElement('beforebegin', paragraph);
}
}
async function updateMixesdbLink(link, episode, wrapper) {
logStep('updateMixesdbLink start', { episode, wrapper: getWrapperLabel(wrapper) });
const sourceTitle = wrapper.querySelector(config.selectors.episodeHeading)?.textContent.trim() || '';
const isExisting = isExistingEpisode(episode, sourceTitle);
const title = buildMixesdbTitle(episode);
const episodeUrl = link.dataset.episodeUrl || location.href;
link.className = `${config.classNames.link} ${isExisting ? 'is-existing' : 'is-missing'}`;
link.title = title;
setLinkVisitedState(link);
logStep('episode existing status', { episodeNumber: episode.episodeNumber, isExisting, title, episodeUrl });
if (isExisting) {
link.href = importer.buildMixesdbUrl(title, episodeUrl);
link.textContent = 'See on MixesDB';
return;
}
setLinkPending(link, episode);
const rawTracklist = extractRawTracklist(wrapper);
try {
logStep('formatTracklist start', { episodeNumber: episode.episodeNumber, rawTracklistLines: rawTracklist.split('\n').filter(Boolean).length });
const tracklist = await importer.formatTracklist(rawTracklist, config.tleApiType);
logStep('formatTracklist success', { episodeNumber: episode.episodeNumber, status: tracklist.status, textLength: tracklist.text?.length || 0, hasFeedback: Boolean(tracklist.feedback) });
renderTracklistApiFeedback(wrapper, tracklist);
setCreateLinkHref(link, title, episodeUrl, buildEpisodePageText(episode, tracklist, extractPlayerUrl(wrapper)));
updateCreateLinkOnClick(link, title, episodeUrl, episode, wrapper, tracklist.text, tracklist.status);
} catch (error) {
logStep('formatTracklist failed', { episodeNumber: episode.episodeNumber, error: error.message || error });
importer.logValue('Failed to format Resident tracklist for MixesDB', error.message || error);
const fallbackTracklist = importer.hasTracklistForApi(rawTracklist)
? rawTracklist.split('\n').filter(Boolean).map(line => `# ${line}`).join('\n')
: '\n\n
';
const fallbackStatus = importer.hasTracklistForApi(rawTracklist) ? 'incomplete' : 'none';
renderTracklistApiFeedback(wrapper, { feedback: null });
setCreateLinkHref(link, title, episodeUrl, buildEpisodePageText(episode, { text: fallbackTracklist, status: fallbackStatus }, extractPlayerUrl(wrapper)));
updateCreateLinkOnClick(link, title, episodeUrl, episode, wrapper, fallbackTracklist, fallbackStatus);
}
logStep('updateMixesdbLink complete', { episodeNumber: episode.episodeNumber, linkText: 'Copy to MixesDB' });
link.className = `${config.classNames.link} is-missing`;
link.textContent = 'Copy to MixesDB';
placeCopyLink(link, wrapper);
setLinkVisitedState(link);
}
function setEpisodeVisibility(wrapper, episode) {
const sourceTitle = wrapper.querySelector(config.selectors.episodeHeading)?.textContent.trim() || '';
wrapper.classList.toggle(config.classNames.hidden, removeExistingEpisodes && isExistingEpisode(episode, sourceTitle));
}
function setManualExistingTitleVisibility(wrapper, title) {
wrapper.classList.toggle(config.classNames.hidden, removeExistingEpisodes && isManualExistingEpisodeTitle(title));
}
function updateEpisodeVisibility() {
document.querySelectorAll(`${config.selectors.episodeWrapper}[data-mdb-episode-number]`).forEach(wrapper => {
setEpisodeVisibility(wrapper, { episodeNumber: Number(wrapper.dataset.mdbEpisodeNumber) });
});
document.querySelectorAll(`${config.selectors.episodeWrapper}[data-mdb-manual-existing-title="true"]`).forEach(wrapper => {
setManualExistingTitleVisibility(wrapper, wrapper.querySelector(config.selectors.episodeHeading)?.textContent.trim() || '');
});
scheduleAutoLoadMoreWhenNoNewEpisodes();
}
function getVisibleMissingEpisodeCount() {
return Array.from(document.querySelectorAll(`${config.selectors.episodeWrapper}[data-mdb-episode-number]`))
.filter(wrapper => !wrapper.classList.contains(config.classNames.hidden))
.filter(wrapper => !existingEpisodes.has(Number(wrapper.dataset.mdbEpisodeNumber)))
.length;
}
function getLoadMoreLink() {
return document.querySelector(config.loadMoreSelector);
}
function setAutoLoadMoreFeedback(isLoading) {
const loadMore = getLoadMoreLink();
if (!loadMore) return;
let waiter = loadMore.parentElement?.querySelector(`.${config.classNames.autoLoadMoreWaiter}`);
if (!isLoading) {
waiter?.remove();
return;
}
if (!waiter) {
waiter = document.createElement('span');
waiter.className = config.classNames.autoLoadMoreWaiter;
loadMore.insertAdjacentElement('afterend', waiter);
}
waiter.textContent = 'Loading hidden episodes…';
}
function scheduleAutoLoadMoreWhenNoNewEpisodes() {
clearTimeout(autoLoadMoreTimer);
if (!removeExistingEpisodes || autoLoadMoreInProgress) return;
autoLoadMoreTimer = setTimeout(autoLoadMoreWhenNoNewEpisodes, config.autoLoadMoreDelay);
}
function autoLoadMoreWhenNoNewEpisodes() {
if (!removeExistingEpisodes || autoLoadMoreInProgress) return;
const loadMore = getLoadMoreLink();
if (!loadMore) return;
const visibleMissingCount = getVisibleMissingEpisodeCount();
if (visibleMissingCount > 0 || visibleMissingCount > lastAutoLoadVisibleMissingCount) {
lastAutoLoadVisibleMissingCount = visibleMissingCount;
return;
}
autoLoadMoreInProgress = true;
setAutoLoadMoreFeedback(true);
logStep('auto load more: no new visible missing episodes', { visibleMissingCount, href: loadMore.href });
loadMore.click();
setTimeout(() => {
autoLoadMoreInProgress = false;
setAutoLoadMoreFeedback(false);
scheduleAutoLoadMoreWhenNoNewEpisodes();
}, config.autoLoadMoreDelay);
}
function createRemoveExistingToggle() {
const toggleWrapper = document.createElement('label');
toggleWrapper.className = config.classNames.toggle;
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = removeExistingEpisodes;
checkbox.addEventListener('change', () => {
removeExistingEpisodes = checkbox.checked;
localStorage.setItem(config.storageKey, removeExistingEpisodes ? 'true' : 'false');
lastAutoLoadVisibleMissingCount = getVisibleMissingEpisodeCount();
updateEpisodeVisibility();
});
toggleWrapper.append(checkbox, document.createTextNode('Hide episodes that exist on MixesDB'));
return toggleWrapper;
}
function addRemoveExistingToggle() {
if (document.querySelector(`.${config.classNames.toggle}`)) return;
const listContainer = document.querySelector(config.selectors.listContainer) || document.body;
listContainer.insertAdjacentElement('afterbegin', createRemoveExistingToggle());
}
function createMixesdbLink(heading, episode, wrapper) {
const episodeLink = heading.querySelector('a') || heading.closest('a');
const link = document.createElement('a');
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.dataset.episodeNumber = String(episode.episodeNumber);
link.dataset.episodeUrl = episodeLink ? episodeLink.href : location.href;
link.addEventListener('click', () => markLinkVisited(link));
updateMixesdbLink(link, episode, wrapper);
return link;
}
function createEpisodeCloseButton() {
const button = document.createElement('button');
button.type = 'button';
button.className = config.classNames.closeButton;
button.setAttribute('aria-label', 'Hide this episode');
button.title = 'Hide this episode';
button.textContent = '×';
button.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
button.closest('.list')?.remove();
});
return button;
}
function addEpisodeCloseButton(wrapper) {
if (wrapper.querySelector(`:scope > .${config.classNames.closeButton}`)) return;
wrapper.append(createEpisodeCloseButton());
}
function restoreCopiedWrapperOnClick(wrapper) {
if (wrapper.dataset.mdbResidentCopiedOpacityRestorer === '1') return;
wrapper.dataset.mdbResidentCopiedOpacityRestorer = '1';
wrapper.addEventListener('click', event => {
if (event.target.closest(`.${config.classNames.link}.is-missing`)) return;
wrapper.classList.remove(config.classNames.copiedWrapper);
});
}
function addStyles() {
const style = document.createElement('style');
style.textContent = `
${config.selectors.episodeWrapper}[data-mdb-episode-number] {
position: relative;
}
${config.selectors.episodeWrapper}.${config.classNames.copiedWrapper} {
opacity: 0.5;
}
.${config.classNames.closeButton} {
align-items: center;
background: #00000080;
border: 1px solid #ffffff80;
border-radius: 50%;
color: #fff;
cursor: pointer;
display: flex;
font-size: 1.25rem;
font-weight: 700;
height: 1.7rem;
justify-content: center;
line-height: 1;
padding: 0;
position: absolute;
right: 0.6rem;
top: 0.6rem;
width: 1.7rem;
z-index: 2;
}
.${config.classNames.closeButton}:hover,
.${config.classNames.closeButton}:focus {
background: #ff6600;
border-color: #ff6600;
outline: none;
}
.${config.classNames.link} {
display: inline-block;
margin: 0.25em 0 0.75em;
padding: 0.25em 0.6em;
border-radius: 4px;
font-size: 0.85rem;
font-weight: 700;
line-height: 1.4;
text-decoration: none;
}
.${config.classNames.link}.is-visited {
opacity: .62 !important;
}
.${config.classNames.link}.is-existing {
background: #2ea70060;
color: #fff !important;
border: 1px solid #4d9f4d;
}
.mdb-resident-tle-feedback + .${config.classNames.link} {
margin-top: -0.75em;
}
.${config.classNames.toggle} {
display: block;
margin: 0 0 1rem;
padding: 0.6em 0.8em;
border: 1px solid #4d9f4d;
border-radius: 4px;
background: #2ea70030;
color: #fff;
font-size: 0.95rem;
font-weight: 700;
line-height: 1.4;
}
.${config.classNames.toggle} input {
margin-right: 0.35em;
vertical-align: middle;
}
.${config.classNames.hidden} {
display: none !important;
}
.${config.classNames.link}.is-missing,
.${config.classNames.link}.is-pending {
background: #ff660050;
border: 1px solid #f60;
color: #ffffff !important;
}
.${config.classNames.apiFeedback} {
margin: 0.6rem 0 1rem;
}
.${config.classNames.apiFeedback} .${config.classNames.apiTracklist} {
box-sizing: border-box;
min-height: 8rem;
width: 100%;
}
.${config.classNames.copyWaiter} {
display: inline-block;
margin: 0.25em 0 0.75em 0.5em;
padding: 0.25em 0.6em;
color: #fff;
font-size: 0.85rem;
font-weight: 700;
line-height: 1.4;
}
.${config.classNames.autoLoadMoreWaiter} {
display: inline-block;
margin-left: 0.5rem;
color: #fff;
font-size: 0.85rem;
font-weight: 700;
}
/* Hide donation banner block */
.e-description table {
display: none;
}
/* tracklist textarea */
.tlEditor textarea {
font-family: monospace;
font-size: 12px;
}
`;
document.head.appendChild(style);
}
function removeDonationContent(wrapper) {
const description = wrapper.querySelector('.episode-description');
if (!description) return;
const walker = document.createTreeWalker(description, NodeFilter.SHOW_TEXT);
let markerNode = null;
while ((markerNode = walker.nextNode())) {
if (/Help me support/i.test(markerNode.textContent)) break;
}
if (!markerNode) return;
const firstDonationNode = markerNode.parentElement?.closest('p, a') || markerNode.parentElement;
if (!firstDonationNode) return;
let node = firstDonationNode;
while (node) {
const nextNode = node.nextSibling;
node.remove();
node = nextNode;
}
}
function removeEmptyEpisodeParagraphs(wrapper) {
wrapper.querySelectorAll('p').forEach(paragraph => {
const text = paragraph.textContent.replace(/\u00a0/g, ' ').trim();
if (!text && !paragraph.querySelector('img, audio, video, iframe, embed, object, input, textarea, select, button, a[href]')) {
paragraph.remove();
}
});
}
function addEpisodeLinks() {
const wrappers = document.querySelectorAll(config.selectors.episodeWrapper);
logStep('addEpisodeLinks scan', { wrapperCount: wrappers.length });
wrappers.forEach(wrapper => {
logStep('episode wrapper found', getWrapperLabel(wrapper));
removeDonationContent(wrapper);
removeEmptyEpisodeParagraphs(wrapper);
const heading = wrapper.querySelector(config.selectors.episodeHeading);
if (!heading) {
logStep('episode skipped: missing heading', getWrapperLabel(wrapper));
return;
}
if (heading.dataset.mdbImporterProcessed === 'true') {
logStep('episode skipped: already processed', heading.textContent.trim());
return;
}
const headingTitle = heading.textContent.trim();
const isManualExistingTitle = isManualExistingEpisodeTitle(headingTitle);
const episode = parseEpisodeTitle(headingTitle);
if (!episode && isManualExistingTitle) {
wrapper.dataset.mdbManualExistingTitle = 'true';
addEpisodeCloseButton(wrapper);
heading.dataset.mdbImporterProcessed = 'true';
setManualExistingTitleVisibility(wrapper, headingTitle);
logStep('episode treated as existing by title', { title: headingTitle, hidden: wrapper.classList.contains(config.classNames.hidden) });
return;
}
if (!episode) {
logStep('episode skipped: title parse failed', headingTitle);
return;
}
logStep('episode parsed', episode);
wrapper.dataset.mdbEpisodeNumber = String(episode.episodeNumber);
restoreCopiedWrapperOnClick(wrapper);
addEpisodeCloseButton(wrapper);
heading.dataset.mdbImporterProcessed = 'true';
heading.insertAdjacentElement('afterend', createMixesdbLink(heading, episode, wrapper));
setEpisodeVisibility(wrapper, episode);
logStep('episode link inserted', { episodeNumber: episode.episodeNumber, hidden: wrapper.classList.contains(config.classNames.hidden) });
});
scheduleAutoLoadMoreWhenNoNewEpisodes();
}
function startEpisodeObserver() {
const observer = new MutationObserver(addEpisodeLinks);
observer.observe(document.body, { childList: true, subtree: true });
}
function logExistingEpisodes() {
importer.logValue(`Existing ${config.artist} ${config.showCategory} MixesDB episodes`, Array.from(existingEpisodes).sort((a, b) => a - b).join(', '));
}
if (location.hostname === mixesdbHost) {
importer.insertMixesdbTextFromUrl();
return;
}
if (location.hostname !== sourceHost) return;
removeExistingEpisodes = localStorage.getItem(config.storageKey) === 'true';
loadVisitedEpisodeLinks();
addStyles();
addRemoveExistingToggle();
importer.fetchExistingEpisodes(config)
.then(episodeNumbers => {
existingEpisodes = new Set([...episodeNumbers, ...config.manualExistingEpisodes]);
logExistingEpisodes();
addRemoveExistingToggle();
addEpisodeLinks();
startEpisodeObserver();
})
.catch(error => {
importer.logValue('Failed to load existing Resident episodes from MixesDB', error.message || error);
logExistingEpisodes();
addEpisodeLinks();
startEpisodeObserver();
});
}());