// ==UserScript==
// @name Bandcamp: Show more dates
// @namespace https://musicbrainz.org/user/chaban
// @version 2.0
// @description Shows Bandcamp releases' real "publish date" below the listed release date
// @tag ai-created
// @author w_biggs (~joks), chaban
// @license MIT
// @match https://*.bandcamp.com/track/*
// @match https://*.bandcamp.com/album/*
// @include /^https?://web\.archive\.org/web/\d+/https?://[^/]+/(?:album|track)/[^/]+\/?$/
// @grant none
// ==/UserScript==
(function() {
'use strict';
/**
* Formats a date string into an ISO 8601 date string (YYYY-MM-DD).
* Includes validation to ensure the date is valid.
* @param {string} dateString - The date string to format.
* @returns {string|null} The formatted ISO 8601 date string, or null if input is invalid or date is unparseable.
*/
function formatDate(dateString) {
if (!dateString) {
return null;
}
const date = new Date(dateString);
if (isNaN(date.getTime())) {
console.warn(`Invalid date string provided to formatDate: "${dateString}"`);
return null;
}
return date.toISOString().slice(0, 10);
}
/**
* Parses date information from a script element based on its type.
* @param {HTMLScriptElement} scriptElement - The script element to parse.
* @returns {Object} An object containing extracted raw date strings (or null if not found).
*/
function parseScriptData(scriptElement) {
const rawDates = {};
if (scriptElement.type === 'application/ld+json') {
try {
const jsonld = JSON.parse(scriptElement.innerText);
rawDates.ldPublished = jsonld?.datePublished;
rawDates.ldModified = jsonld?.dateModified;
} catch (e) {
console.error('Error parsing JSON-LD from script element:', scriptElement, e);
}
} else if (scriptElement.hasAttribute('data-tralbum')) {
try {
const tralbumContent = scriptElement.getAttribute('data-tralbum');
const jsonalbum = JSON.parse(tralbumContent);
rawDates.tralbumPublish = jsonalbum.current?.publish_date;
rawDates.tralbumModified = jsonalbum.current?.mod_date;
rawDates.tralbumNew = jsonalbum.current?.new_date;
rawDates.tralbumRelease = jsonalbum.current?.release_date;
const embedContent = scriptElement.getAttribute('data-embed');
const jsonembed = embedContent ? JSON.parse(embedContent) : null;
if (typeof jsonembed?.embed_info?.public_embeddable === 'string' && !isNaN(new Date(jsonembed.embed_info.public_embeddable))) {
rawDates.embeddable = jsonembed.embed_info.public_embeddable;
}
} catch (e) {
console.error('Error parsing data-tralbum or data-embed attributes from script element:', scriptElement, e);
}
}
return rawDates;
}
/**
* Main function to collect and display all unique date information.
*/
function displayAllDates() {
const creditsElement = document.querySelector('div.tralbum-credits');
if (!creditsElement) {
console.debug('Target div.tralbum-credits not found.');
return;
}
const allRawDates = {};
const allRelevantScripts = document.querySelectorAll('script[type="application/ld+json"], script[data-tralbum]');
allRelevantScripts.forEach(scriptElement => {
const currentScriptDates = parseScriptData(scriptElement);
Object.assign(allRawDates, currentScriptDates);
});
// Use a Map to store unique formatted dates along with their associated labels, sources, and explanations.
// The key will be the formatted date string, and the value will be an Array of objects {text: string, title: string}.
const consolidatedDates = new Map(); // Map<formattedDateString, Array<{text: string, title: string}>>
const datePriorities = [
{ key: 'tralbumRelease', label: 'released', source: 'release_date', explanation: 'The official release date set by the artist.' },
{ key: 'tralbumPublish', label: 'published', source: 'publish_date', explanation: 'The actual date the release became public on Bandcamp.' },
{ key: 'ldPublished', label: 'published', source: 'datePublished', explanation: 'The actual date the release became public on Bandcamp (Schema.org).' },
{ key: 'tralbumNew', label: 'created', source: 'new_date', explanation: 'The date the album/track entry was first saved as a draft.' },
{ key: 'tralbumModified', label: 'modified', source: 'mod_date', explanation: 'The last date any changes were saved to the release page.' },
{ key: 'ldModified', label: 'modified', source: 'dateModified', explanation: 'The last date any changes were saved to the release page (Schema.org).' },
{ key: 'embeddable', label: 'embeddable', source: 'public_embeddable', explanation: 'The date the release became publicly embeddable.' }
];
datePriorities.forEach(({ key, label, source, explanation }) => {
const rawDate = allRawDates[key];
if (rawDate) {
const formattedDate = formatDate(rawDate);
if (formattedDate) {
if (!consolidatedDates.has(formattedDate)) {
consolidatedDates.set(formattedDate, []);
}
consolidatedDates.get(formattedDate).push({
text: `${label} (${source})`,
title: explanation
});
}
}
});
const finalDateLines = [];
const scriptAddedParagraphs = creditsElement.querySelectorAll('p[data-userscript-added]');
scriptAddedParagraphs.forEach(p => p.remove());
const sortedFormattedDates = Array.from(consolidatedDates.keys()).sort((a, b) => {
const dateA = new Date(a);
const dateB = new Date(b);
return dateA.getTime() - dateB.getTime();
});
const outputParagraph = document.createElement('p');
outputParagraph.setAttribute('data-userscript-added', 'true');
sortedFormattedDates.forEach((formattedDate, dateIndex) => {
let descriptions = consolidatedDates.get(formattedDate);
if (descriptions.length > 0) {
outputParagraph.appendChild(document.createTextNode(`${formattedDate}: `));
descriptions.forEach((desc, descIndex) => {
const descSpan = document.createElement('span');
descSpan.textContent = desc.text;
descSpan.title = desc.title;
outputParagraph.appendChild(descSpan);
if (descIndex < descriptions.length - 1) {
outputParagraph.appendChild(document.createTextNode('; '));
}
});
if (dateIndex < sortedFormattedDates.length - 1) {
outputParagraph.appendChild(document.createElement('br'));
}
}
});
if (outputParagraph.hasChildNodes()) {
creditsElement.appendChild(outputParagraph);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', displayAllDates);
} else {
displayAllDates();
}
})();