release:txt [Bandcamp 2025 Standalone]

Standalone script for extracting release info and tracklist from Bandcamp (2024+ compatible). Outputs formatted text in a textbox at the top.

// ==UserScript==
// @name         release:txt [Bandcamp 2025 Standalone]
// @namespace    http://userscripts-mirror.org/scripts/show/156420
// @version      2024.06.29.07
// @description  Standalone script for extracting release info and tracklist from Bandcamp (2024+ compatible). Outputs formatted text in a textbox at the top.
// @author       nj4442 + original author DMBoxer
// @match        http*://*.bandcamp.com/*
// @license      CC-BY-4.0
// @grant        none
// @run-at       document-end
// ==/UserScript==

(function() {
    'use strict';

    // ==== CONFIGURATION ====
    // Export header format: ARTIST - TITLE (YEAR) [LABEL]
    var exportHeaderFormat = '%artist% - %title% (%year%) [%label%]';
    var sectionLineSeparator = '_';
    var textWidth = 90;

    // ==== UTILITIES ====
    function tidyline(s) { return s ? s.replace(/[\s\xA0\u200e]+/g, ' ').trim() : ''; }
    function headerline(title, toLength, sepChar) {
        toLength = toLength || textWidth;
        sepChar = sepChar || sectionLineSeparator;
        var filler = new Array(Math.max(0, toLength - (title ? title.length + 1 : 0))).join(sepChar);
        return (title ? title + ' ' : '') + filler;
    }
    function filesystemsafe(s) {
        return s.replace(/[\/\\|]/g, ', ')
                .replace(/:/g, ';')
                .replace(/\?/g, '¿')
                .replace(/"/g, "'")
                .replace(/[\*<>]/g, '_');
    }

    // ==== MODELS ====
    function Track() {
        this.number = '';
        this.artist = '';
        this.title = '';
        this.time = '';
        this.bpm = '';
        this.credits = '';
        this.release = '';
        this.label = '';
    }
    function Section(title, content) {
        this.title = title || '';
        this.content = content || '';
    }
    function Release() {
        this.artist = '';
        this.title = '';
        this.by = '';
        this.label = '';
        this.catalog = '';
        this.released = '';
        this.format = '';
        this.tracks = '';
        this.country = '';
        this.genre = '';
        this.style = '';
        this.duration = '';
        this.tags = '';
        this.bandcamp = '';
        this.tracklist = [];
        this.description = [];
        Object.defineProperty(this, 'year', { enumerable: true, get: function () {
            var rlsYear = (this.released || '').match(/[\d]{4}/);
            return (rlsYear === null) ? '' : rlsYear[0];
        }});
    }

    // ==== PROTOTYPE FORMATTERS ====
    Track.prototype.TXT = function(fieldsSize, skipartist) {
        skipartist = skipartist || false;
        var spaceToTrack = ((this.time === '') ? 0 : fieldsSize.time + 3) + ((this.number === '') ? 0 : fieldsSize.number + 2);
        return ((this.time === '') ? '' : '[' + this.time + '] ')
            + ((this.number === '') ? '' : this.number + '. ')
            + ((skipartist || this.artist === '') ? '' : this.artist + ' - ')
            + ((this.title === '') ? 'unknown' : this.title);
    };

    Release.prototype.TXT_tracklist = function() {
        var trklistTXT = '', trklist = this.tracklist, t, trk = new Track(), trksfieldsize = {time:0, number:0, artist:0, title:0}, k;
        for (t = 0; t < trklist.length; t++) {
            trk = trklist[t];
            trksfieldsize.time = Math.max(trksfieldsize.time, (trk.time||'').length);
            trksfieldsize.number = Math.max(trksfieldsize.number, (trk.number||'').length);
            trksfieldsize.artist = Math.max(trksfieldsize.artist, (trk.artist||'').length);
            trksfieldsize.title = Math.max(trksfieldsize.title, (trk.title||'').length);
        }
        var rlsartist = (this.artist||'').toLowerCase();
        var isSingleArtist = !this.tracklist.some(function(trk) { return (trk.artist||'').toLowerCase() !== rlsartist; });
        for (t = 0; t < trklist.length; t++) {
            trklistTXT += trklist[t].TXT(trksfieldsize, isSingleArtist) + '\n';
        }
        return trklistTXT.trim();
    };

    // Custom top header formatter
    Release.prototype.TXT_header = function() {
        var header = exportHeaderFormat;
        var keys = {
            artist: this.artist || this.by || '',
            title: this.title || '',
            year: this.year || '',
            label: this.label || ''
        };
        Object.keys(keys).forEach(function(key) {
            header = header.replace(new RegExp('%' + key + '%', 'ig'), keys[key].replace(/\s+/g, ' ').trim());
        });
        // Collapse extra spaces
        return filesystemsafe(header).replace(/\s{2,}/g, ' ').trim();
    };

    Release.prototype.TXT_oneliner = function() {
        // No longer needed for top, but keep for reference/legacy
        var line = '%artist% - %title% (by %by%) [%label%] (%year%)', attrib = '', keys = Object.keys(this);
        for (var k = 0; k < keys.length; k++) {
            if (typeof this[keys[k]] === 'string' && this[keys[k]]) {
                attrib = this[keys[k]].replace(/\s+/g, ' ').trim();
                line = line.replace(new RegExp('%' + keys[k] + '%', 'ig'), attrib);
            } else {
                line = line.replace(new RegExp('%' + keys[k] + '%', 'ig'), '');
            }
        }
        line = line.replace(/\(by \)/g, '').replace(/ +\]/g, ']').replace(/\[ +/g, '[')
            .replace(/ +\)/g, ')').replace(/\( +/g, '(')
            .replace(/\[ *\]/g, '').replace(/\( *\)/g, '')
            .replace(/(^ *\- *|\- *\-| *\- *$)/g, '').replace(/ +/g, ' ');
        return filesystemsafe(line);
    };

    Release.prototype.TXT_profile = function() {
        let profile = '';
        const keys = Object.keys(this);
        let keysmaxlenght = 0;
        for (let k = 0; k < keys.length; k++) {
            if (typeof this[keys[k]] === 'string' && keys[k].length > keysmaxlenght) {
                keysmaxlenght = keys[k].length;
            }
        }
        for (let k = 0; k < keys.length; k++) {
            // Only include if value is non-empty and not 'year'
            if (typeof this[keys[k]] === 'string' && keys[k] !== 'year' && this[keys[k]]) {
                // Remove leading/trailing spaces, and collapse multiple spaces
                let value = this[keys[k]].replace(/\s+/g, ' ').trim();
                profile += keys[k].replace(/_/g, ' ').padEnd(keysmaxlenght + 1, ' ') + ': ' + value + '\n';
            }
        }
        return profile.trim();
    };

    Release.prototype.TXT = function() {
        var rlsTXT = '', d = 0;
        rlsTXT = this.TXT_header() + '\n';
        rlsTXT += headerline('') + '\n\n';
        rlsTXT += this.TXT_profile() + '\n';
        if (this.tracklist.length > 0) {
            rlsTXT += '\n' + headerline('Tracklist') + '\n\n';
            rlsTXT += this.TXT_tracklist() + '\n';
        }
        for (d = 0; d < this.description.length; d++) {
            let descContent = this.description[d].content;
            // Clean up extra blank lines
            descContent = descContent.replace(/\n{2,}/g, '\n\n').trim();
            rlsTXT += '\n' + headerline(this.description[d].title) + '\n\n';
            rlsTXT += descContent + '\n';
        }
        rlsTXT += '\n' + headerline('__ generated by release:txt') + '\n';
        return rlsTXT.trim();
    };

    // ==== BANDCAMP EXTRACTION ====
    function get_bandcamp_release(htmldoc) {
        var rls = new Release();

        // Title and Artist
        rls.title = htmldoc.querySelector('#name-section .trackTitle')?.textContent.trim() || '';
        rls.by = htmldoc.querySelector('#name-section h3 a')?.textContent.trim() || '';

        // Label/Publisher
        rls.label = htmldoc.querySelector('#bio-container #band-name-location .title')?.textContent.trim() || '';

        // Release Date (extract only the date, not credits/description)
        var creditsElem = htmldoc.querySelector('#trackInfoInner .tralbum-credits');
        if (creditsElem) {
            let creditsText = creditsElem.textContent.trim();
            // Look for "released Month DD, YYYY"
            let releasedMatch = creditsText.match(/released\s+([A-Za-z]+\s+\d{1,2},\s+\d{4})/i);
            if (releasedMatch) {
                rls.released = releasedMatch[1];
            } else {
                // fallback: just look for Month DD, YYYY anywhere
                let fallbackMatch = creditsText.match(/([A-Za-z]+\s+\d{1,2},\s+\d{4})/);
                rls.released = fallbackMatch ? fallbackMatch[1] : '';
            }
        } else {
            rls.released = '';
        }

        // Format
        var formatElem = htmldoc.querySelector('#trackInfoInner .buyItemPackageTitle');
        rls.format = formatElem ? formatElem.textContent.trim() : '';

        // Tags
        var tagsElem = htmldoc.querySelector('.tralbum-tags');
        if (tagsElem) {
            rls.tags = Array.from(tagsElem.querySelectorAll('a.tag')).map(a => a.textContent.trim()).join(', ');
        }

        // Description/About
        var aboutElem = htmldoc.querySelector('#trackInfoInner .tralbum-about');
        var rlsDescriptionSection = new Section();
        rlsDescriptionSection.title = 'Description';
        rlsDescriptionSection.content = '';
        if (aboutElem) rlsDescriptionSection.content += aboutElem.textContent.trim();
        // Add other credits except the release date
        if (creditsElem && creditsElem.textContent.trim()) {
            let creditsText = creditsElem.textContent.trim();
            // Remove the "released Month DD, YYYY" from the start, if present
            let extraCredits = creditsText.replace(/^released\s+[A-Za-z]+\s+\d{1,2},\s+\d{4}[.,;:! ]*/i, '').trim();
            if (extraCredits) {
                rlsDescriptionSection.content += (rlsDescriptionSection.content ? '\n\n' : '') + extraCredits;
            }
        }

        // Label bio and links
        var bandBioElem = htmldoc.querySelector('#bio-container .signed-out-artists-bio-text');
        if (bandBioElem && bandBioElem.textContent.trim()) {
            rlsDescriptionSection.content += '\n\n' + bandBioElem.textContent.trim();
        }
        var bandLinks = htmldoc.querySelectorAll('#bio-container #band-links li a');
        if (bandLinks.length) {
            rlsDescriptionSection.content += '\n\nLinks:\n' + Array.from(bandLinks).map(a => a.href).join('\n');
        }
        // Clean up extra blank lines in description content
        if (rlsDescriptionSection.content)
            rlsDescriptionSection.content = rlsDescriptionSection.content.replace(/\n{2,}/g, '\n\n').trim();
        if (rlsDescriptionSection.content) rls.description.push(rlsDescriptionSection);

        // Tracklist
        var trackRows = htmldoc.querySelectorAll('#track_table tr.track_row_view');
        trackRows.forEach(function(row) {
            var trk = new Track();
            var numElem = row.querySelector('.track_number');
            trk.number = numElem ? numElem.textContent.trim().replace(/\.$/, '') : '';
            var titleElem = row.querySelector('.track-title');
            trk.title = titleElem ? titleElem.textContent.trim() : '';
            var timeElem = row.querySelector('.time.secondaryText');
            trk.time = timeElem ? timeElem.textContent.trim() : '';
            rls.tracklist.push(trk);
        });

        // Bandcamp URL
        rls.bandcamp = htmldoc.URL;

        return rls;
    }

    // ==== UI ====
    function buildUI() {
        var htmldoc = window.top.document;
        // Remove old UI if present
        var old = htmldoc.getElementById('releaseTXT_header');
        if (old) old.remove();

        var UIcontainer = htmldoc.createElement('div');
        UIcontainer.id = 'releaseTXT_header';
        UIcontainer.style.cssText = 'position: fixed; z-index: 9999; top: 0; left: 0; margin-top: 0; height: 28px; width: 100%; background: #222; color: #fff; box-shadow:0 2px 4px #0007;';
        htmldoc.body.insertBefore(UIcontainer, htmldoc.body.firstChild);

        var div = htmldoc.createElement('div');
        div.style.cssText = 'margin:0 auto; height:24px; width:990px;';
        UIcontainer.appendChild(div);

        var gettxt = htmldoc.createElement('input');
        gettxt.type = 'button';
        gettxt.id = 'releaseTXT_button';
        gettxt.value = 'release:txt';
        gettxt.style.cssText = 'margin:2px 1px 2px 10px;padding:0 5px;height:20px;font-family:verdana;font-size:10px;background:#333;color:#fff;border:1px solid #888;border-radius:6px;';
        div.appendChild(gettxt);

        var txtbox = htmldoc.createElement('textarea');
        txtbox.id = 'releaseTXT_txtbox';
        txtbox.value = 'click to get the text version of this release...';
        txtbox.spellcheck = false;
        txtbox.style.cssText = 'margin:2px 1px;padding:2px 1px 1px 5px;min-height:15px;height:15px;width:750px;font-family:monospace;font-size:12px;line-height:15px;vertical-align:top;resize:both;overflow:auto;border:solid 1px #d7d7d7;border-radius:6px;box-shadow:inset 1px 1px 3px 0px #333;color:#000;background:#fff;';
        div.appendChild(txtbox);

        gettxt.addEventListener('click', function () { main(); }, false);
    }

    // ==== MAIN FUNCTION ====
    function main() {
        var htmldoc = window.top.document;
        var txtbox = htmldoc.getElementById('releaseTXT_txtbox');
        txtbox.value = 'page loading...';
        txtbox.style.background='#FFD700';

        try {
            var rls = get_bandcamp_release(htmldoc);
            var outtxt = rls.TXT();
            txtbox.value = outtxt;
            txtbox.style.background='#fff';
        } catch(e) {
            txtbox.value = 'Could not collect the data for this release. Error: ' + e;
            txtbox.style.background='#fdd';
        }
    }

    // ==== INIT ====
    function isBandcampReleasePage() {
        return !!document.querySelector('#name-section .trackTitle');
    }
    if (isBandcampReleasePage()) {
        buildUI();
        // Optionally auto-trigger extraction:
        // setTimeout(main, 1000);
    }
})();