Greasy Fork is available in English.

release:txt

(DO NOT USE; as of 2022, the script doesn't seem to work anywhere anymore. sorry.) Get a music release info and tracklist from discogs.com and bandcamp.com

// ==UserScript==
// @name release:txt
// @id   release_txt
// @namespace   http://userscripts.org/scripts/show/156420
// @homepageURL http://userscripts.org/scripts/show/156420
// @author  DMBoxer
// @version 2020.1.1
// @description (DO NOT USE; as of 2022, the script doesn't seem to work anywhere anymore. sorry.) Get a music release info and tracklist from discogs.com and bandcamp.com
// @grant   none
// @run-at  document-end
// @include http*://*.bandcamp.com/*
// @include http*://www.beatport.com/*
// @include http*://mixes.beatport.com/*
// @include http*://www.discogs.com/*/release/*
// @include http*://www.discogs.com/release/*
// @include http*://www.junodownload.com/charts/mixcloud/*
// @include http*://www.junodownload.com/charts/dj/*
// @include http*://www.junodownload.com/charts/juno-recommends/*
// @include http*://www.junodownload.com/products/*
// @include http*://www.mixcloud.com/*
// @exclude http*://soundcloud.com/*
// ==/UserScript==

// updated November 2020, beatport.com, mixcloud.com, junodownload.com and soundcloud.com no longer work; all the respective code is kept if someone wants to commit fixes
// updated April 2018 with own discogs/bandcamp code changes + soundcloud/beatport fixes from https://greasyfork.org/forum/discussion/2299/release-txt-reupload-of-script

/*jslint browser: true, passfail: false, sloppy: true, nomen: false, vars: true, white: true, todo: false*/

// BEGIN CONFIGURATION
var releaseLineFormat = '%artist% - %title% - %year%';
var sectionLineSeparator = '_';
var textWidth = 90;
// END CONFIGURATION


// ==================================================================================================================
// Debugging/Text patterns analysis
// ==================================================================================================================

String.prototype.anal = function anal(prefix) {
    // return text showing text linefeeds, carriage returns, tabs, non-breaking spaces
    var text = this.replace(/[\xA0\u200e]/g, '_').replace(/\r/g, '\\r').replace(/\n/g, '\\n').replace(/\t/g, '\\t');
    console.log(((prefix === undefined) ? '' : prefix + ': ') + text);
    return text;
};


// ==================================================================================================================
// JAVASCRIPT OBJECTS PROTOTYPE FUNCTIONS TOOLBOX
// String, data/time... objects extensions.
// ==================================================================================================================



/* FIREFOX COMPATIBILITY - PARTIAL EMULATION with .textContent of Chrome's elegant .innerText property
   based on tags seen in the target sites descriptions: this does NOT aim at being a spec-abiding emulation !
   Native browser or added prototype .innerText are NOT overriden if present. Used only for description texts */
if (!HTMLElement.prototype.hasOwnProperty("innerText")) {
    Object.defineProperty(HTMLElement.prototype, "innerText", {
        get: function () {
            // linebreaks optimization before .textContent with support for just a few very basic HTML tags.
            var thisHTML = this.innerHTML, text;
            thisHTML = thisHTML.replace(/\s+/g, ' '); // discogs.com: in-text multiple space+tabs+linebreaks mess fixed to single-space chars
            thisHTML = thisHTML.replace(/<\/p>/ig, '\n\n</p>'); // 2x linebreaks before element closing
            thisHTML = thisHTML.replace(/<\/(li|ul|ol|table|tr)>/ig, '\n</$1>'); // 1 linebreak before element closing
            thisHTML = thisHTML.replace(/<br>/ig, '\n<br>'); // 1 linebreak
            //thisHTML.anal('innerHTML');
            this.innerHTML = thisHTML;
            text = this.textContent;
            //text.anal('innerText');
            return text;
        }
    });
} else {
    console.log('HTMLElement.prototype.innerText overwrite skipped');
}

if (!HTMLElement.prototype.hasOwnProperty("expandLinks")) {
    HTMLElement.prototype.expandLinks = function expandLinks() {
        // reveal links url in description text - side effect: FIXES THE HTML SOURCE PAGE TOO.
        var l, links = this.getElementsByTagName('a'), linkurl, r1, r2;
        for (l = 0; l < links.length; l += 1) {
            if (links[l].href.substr(0, 4) === 'http') {
                // split link url on '…' & '...' plus '%', ';', '+' as at least mixcloud somehow messes up link label html chars
                r1 = new RegExp('^' + (links[l].textContent.tidyurl(true).split(new RegExp('…|\\.\\.\\.|%|;|\\+'))[0]).escapeRegExp(), 'i');
                r2 = new RegExp((links[l].textContent.tidyurl(true)).escapeRegExp() + '$', 'i');
                linkurl = links[l].href.tidyurl(true); // stripping protocol prefix, ? arguments and # anchors
                if (linkurl.match(r1) !== null || linkurl.match(r2) !== null) {
                    // link label is part of start or end of its href url => substitute link label with href url
                    links[l].textContent = links[l].href.tidyurl(false);
                } else {
                    // link label not derived from its truncated url => append ' [href]' to it
                    links[l].textContent += ' [' + links[l].href.tidyurl(false) + ']';
                }
            }
        }
        return this;
    };
} else {
    console.log('HTMLElement.prototype.expandLinks() overwrite skipped');
}

String.prototype.escapeRegExp = function escapeRegExp() {
    // stackoverflow.com/questions/3561493/is-there-a-regexp-escape-function-in-javascript/3561711#3561711
    // "$&" inserts the matched substring. http://www.tutorialspoint.com/javascript/string_replace.htm
    return this.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&");
};

String.prototype.trim = function trim() {
    // including line feeds, tabs, non-breaking spaces in ASCII & Unicode, ...
    return this.replace(/^[\s\xA0\u200e]+|[\s\xA0\u200e]+$/g, '');
};

String.prototype.tidyline = function tidyline() {
    // to be applied to html node .textContent expected to be one single line of text
    // removes all linebreaks and non-breaking spaces and fuse adjacent spaces into just one
    // trim left+right including line feeds, tabs, non-breaking spaces in ASCII & Unicode, ...
    var text = this.toString();
    text = text.replace(/[\s\xA0\u200e]+/g, ' ').replace(/^\s+|\s+$/g, '');
    return text;
};

String.prototype.tidydate = function tidydate() {
    var date = this.toString(), toDate;
    if (date.match(/[a-z]+/) !== null) {
        // Remove ',' '-' (e.g. in '5 January, 2009', '28-January-2013') + Capitalize month
        date = date.replace(/[,]/g, '').replace(/[\-\.]/g, ' ').toInitials();
    } else {
        // convert to ISO date yyyy-mm-dd - input d/m/y assumed 
        date = date.replace(/[\.\-]/g, '/').split('/').reverse().map(function (n) { return (n.length === 1) ? '0' + n : n; }).join('-');
    }
    return date;
};

String.prototype.tidyurl = function tidyurl(optRemoveArguments) {
    // remove 'http(s)://' protocol from URL
    // optional: 'false' to leave query arguments after '?' and '#' anchor
    var url = this.toString().trim(); // trim() required for .expandLinks() correct operations on link labels.
    if (optRemoveArguments === undefined) { optRemoveArguments = true; }
    url = url.replace(/^http[s]{0,1}\:\/\//i, '');
    if (optRemoveArguments) {
        url = url.split('?')[0]; // keep only the part before the '?' char
        url = url.split('#')[0]; // keep only the part before the '#' char
    }
    if (url.match(/\/$/) !== null) {
        if (url.match(/\//g).length === 1) { url = url.replace(/\/$/, ''); } // domain with trailer '/', no path
    }
    return url;
};

String.prototype.parentDomain = function parentDomain() {
    // input can be any url with or without protocol header
    var url = this.toString().replace(/^http[s]{0,1}\:\/\//i, '').split('/')[0].split('.'); // capture domain members
    return url.slice(url.length - 2).join('.'); // just the last 2 domain members e.g. 'soundcloud' and 'com'
};

String.prototype.toInitials = function toInitials() {
    // convert each word in a string to Proper case
    var text = this.toString();
    text = text.replace(/(\w+)/g, function (word) {
        var exceptions = ['va', 'ep', 'lp', 'dj', 'mc', 'feat', 'ft', 'featuring', 'with', 'and', 'vs'];
        if (exceptions.indexOf(word.toLowerCase()) === -1) {
            word = word.charAt(0).toUpperCase() + word.substring(1, word.length).toLowerCase();
        }
        return word;
    });
    return text;
};

String.prototype.rfill = function rfill(toLength, optFiller) {
    // extend string toLength (required) on the right with optFiller character (optional, default is ' ')
    if (optFiller === undefined) { optFiller = ' '; }
    var text = this, fillerArray = [];
    if (text.length < toLength + 1) {
        fillerArray.length = toLength + 1 - text.length;
        text += fillerArray.join(optFiller);
    }
    return text;
};

String.prototype.lfill = function lfill(toLength, optFiller) {
    // extend string toLength (required) on the left with optFiller character (optional, default is ' ')
    if (optFiller === undefined) { optFiller = ' '; }
    var text = this, fillerArray = [];
    if (text.length < toLength + 1) {
        fillerArray.length = toLength + 1 - text.length;
        text = fillerArray.join(optFiller) + text;
    }
    return text;
};

String.prototype.timecodefill = function timecodefill(toLength) {
// left-fills timecode string using '00:00:00' mask up to toLength argument
// if toLength argument is ommitted, timecode string returns unchanged
    var timecode = this, tcmask = '00:00:00';
    if (toLength === undefined) { toLength = timecode.length; }
    if (timecode.length < toLength) {
        timecode = tcmask.substr(9 - toLength - 1, toLength - timecode.length) + timecode;
    }
    return timecode;
};

String.prototype.headerline = function headerline(toLength, optFiller) {
    // return section title header with line filled with repeated seperator
    if (toLength === undefined) { toLength = textWidth; }
    if (optFiller === undefined) { optFiller = sectionLineSeparator; }
    var fillerArray = [];
    fillerArray.length = toLength - this.length + ((this.toString() === '') ? 1 : 0);
    return ((this.toString() === '') ? '' : this + ' ') + fillerArray.join(optFiller);
};

String.prototype.filesystemsafe = function filesystemsafe() {
    // convert known (windows) forbidden characters: / \ : * ? " < > | to their best possible equivalent
    var name = this.toString();
    name = name.replace(/(\d+)[\/\\\|]([\w\d]+)[\/\\\|](\d+)/g, '$1.$2.$3'); // convert '/' date separator to '.'
    name = name.replace(/[ ]?[\/\\\|][ ]?/g, ', '); // convert '/' '\' '|' (with any surrounding single-space chat) to ', ' 
    name = name.replace(/[\:]/g, ';'); // convert : to ;
    name = name.replace(/[\?]/g, String.fromCharCode(191)); // convert ? to ¿ (upside-down question mark)
    name = name.replace(/[\"]/g, "'"); // convert " to '
    name = name.replace(/[\*<>]/g, '_'); // convert * < > to _ (underscore)
    return name;
};

String.prototype.timeToMillisec = function timeToMillisec() {
    // Input format supported string: hh:mm:ss, mm:ss, ss. +/- sign is stripped out if present.
    // Returns an absolute number in milliseconds for maximum Date/Time js functions compatibility
    var times = this.replace(/^[\-+]/, '').split(':').reverse().map(Number);
    return (times[0] + ((times.length < 2) ? 0 : times[1]) * 60 + ((times.length < 3) ? 0 : times[2]) * 60 * 60) * 1000;
};

Number.prototype.millisecToString = function millisecToString() {
    // Input a number in milliseconds. Returns a string formatted hh:mm:ss
    // !!! for some reason js .toTimeString() gives 1 hour too much and .toGMTString() seems correct,
    // at least with mixcloud.com duration timecodes.
    // not sure this works correctly for all Locales/Timezones and for more than mixcloud   !!!
    return new Date(this).toGMTString().replace(/.*(\d{2}:\d{2}:\d{2}).*/, "$1");
};

String.prototype.ageToDate = function ageToDate() {
    // transforms age into a date string. e.g. "18 days ago" => "25 December 2012"
    // supports unit singular, plural and shorthand (3 first letters min.)
    // ignores any additional word after 'n [unit]'
    // n minutes, hours, days  =>  day month year
    // n weeks, months         =>  month year
    // n years                 =>  year
    // output: day 1 or 2 digits, month by name, year 4 digits
    var age = this.trim().toLowerCase().split(' '),
        toDate = '',
        monthNames = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
    if (age.length > 1) {
        switch (age[1].substr(0, 3)) {
        case 'yea':
            toDate = new Date().getFullYear() - age[0];
            break;
        case 'mon':
            toDate = new Date(new Date().getFullYear() * 12 + new Date().getMonth() - age[0]);
            toDate = monthNames[toDate % 12] + ' ' + Math.floor(toDate / 12);
            break;
        case 'wee':
            toDate = new Date(new Date() - age[0] * 7 * 24 * 60 * 60 * 1000);
            toDate = monthNames[toDate.getMonth()] + ' ' + toDate.getFullYear();
            break;
        case 'day':
            toDate = new Date(new Date() - age[0] * 24 * 60 * 60 * 1000);
            toDate = toDate.getDate() + ' ' + monthNames[toDate.getMonth()] + ' ' + toDate.getFullYear();
            break;
        case 'hou':
            toDate = new Date(new Date() - age[0] * 60 * 60 * 1000);
            toDate = toDate.getDate() + ' ' + monthNames[toDate.getMonth()] + ' ' + toDate.getFullYear();
            break;
        case 'min':
            toDate = new Date(new Date() - age[0] * 60 * 1000);
            toDate = toDate.getDate() + ' ' + monthNames[toDate.getMonth()] + ' ' + toDate.getFullYear();
            break;
        case 'sec':
            toDate = new Date(new Date() - age[0] * 1000);
            toDate = toDate.getDate() + ' ' + monthNames[toDate.getMonth()] + ' ' + toDate.getFullYear();
            break;
        default:
            toDate = this; // unsupported, return input unchanged
        }
    } else {
        toDate = this; // unsupported, return input unchanged
    }
    return toDate.toString();
};


   
// ==================================================================================================================
// DATA COLLECTION Release OBJECT MODEL & PROTOTYPE FUNCTIONS
// this should never be edited with site source-specific code. 
// Any source-specific processing must happen in the getRelease_[source] Release data collectors
// ==================================================================================================================

// RELEASE OBJECT MODEL ===================================================================================
// tracklist is a regular js array of Track() objects
// description is a regular js array of Section() objects
// more properties can be added to the 'Release' main object, just as with any js object.
// user-added properties of type 'string' will show at the end of the release profile section
// in the order they were added to the object

function Track(number, artist, title, time, bpm, credits, release, label) {
    // Release.tracklist() is a regular js array of Track() objects.
    // default all properties to empty string '': we don't want 'undefined' testing in the code
    this.number = number; this.number = '';
    this.artist = artist; this.artist = '';
    this.title = title; this.title = '';
    this.time = time; this.time = '';
    this.bpm = bpm; this.bpm = '';
    this.credits = credits; this.credits = '';
    this.release = release; this.release = '';
    this.label = label; this.label = '';
}

function Section(title, content) {
    // Release.description is a regular js array of additional description Section() objects
    this.title = title; this.title = '';
    this.content = content; this.content = '';
}

function Release(artist, title, by, label, catalog, format, tracks, country, released, genre, style, duration, tracklist, description) {
    // profile properties naming is mostly aligned to discogs.com release profile naming conventions.
    // Release  properties can be added on the fly by code, as js permits with any object: Release.myproperty = 'myvalue'
    // string properties, both pre-defined and user-code added, are all read for release profile information building.

    // string properties
    this.artist = artist; this.artist = '';
    this.title = title; this.title = '';
    this.by = by; this.by = ''; // mix & compilation artist(s)
    this.label = label; this.label = '';
    this.catalog = catalog; this.catalog = '';
    this.released = released; this.released = '';
    this.format = format; this.format = '';
    this.tracks = tracks; this.tracks = '';
    this.country = country; this.country = '';
    this.genre = genre; this.genre = '';
    this.style = style; this.style = '';
    this.duration = duration; this.duration = '';
    // array properties
    this.tracklist = tracklist; this.tracklist = [];
    this.description = description; this.description = [];
    // read-only computed properties 
    Object.defineProperty(this, 'year', { enumerable: true, get: function () {
        var rlsYear = this.released.toString().match(/[\d]{4}/);
        return (rlsYear === null) ? '' : rlsYear[0];
    }});
    Object.defineProperty(this, 'isMix', { enumerable: false, get: function () {
        // returns true if all track.time are set and each track's timecode is > to the previous 
        var areTimecodesIncremental = true, t, previousTimecode = 0;
        if (this.tracklist.length === 0) { areTimecodesIncremental = false; } // empty tracklist
        for (t = 0; t < this.tracklist.length; t += 1) {
            if (this.tracklist[t].time.timeToMillisec() < previousTimecode || this.tracklist[t].time === '') { areTimecodesIncremental = false; break; }
            previousTimecode = this.tracklist[t].time.timeToMillisec();
        }
        return areTimecodesIncremental;
    }});
    Object.defineProperty(this, 'isCompilation', { enumerable: false, get: function () {
        // returns true if for all tracks .artist is set there are different artist names, 
        // that don't contain the release's .artist/.by (case of 'artist ft. xx' album artist tracklists)
        // - strip 'DJ', 'MC' and more to test rls.artist
        // - Remix album => not a VA => add test on track.title for .artist name in Remix etc...
        // - change rule to if >=75% artist names are same as .artist => not a VA (case of artist album + remixes)
        var areTracksOfDifferentArtists = false, t, previousArtist = '', differentArtistCount = 0,
            artistPrefixRexp = new RegExp(((this.artist === '') ? this.by : this.artist).replace(/dj |mc /ig, '').escapeRegExp(), 'i');
        if (this.tracklist.length === 0) { areTracksOfDifferentArtists = false; } // empty tracklist
        for (t = 0; t < this.tracklist.length; t += 1) {
            if (t > 0 &&
                    this.tracklist[t].artist !== '' &&
                    this.tracklist[t].artist.toLowerCase() !== previousArtist &&
                    this.tracklist[t].artist.match(artistPrefixRexp) === null && this.tracklist[t].title.match(artistPrefixRexp) === null) {
                differentArtistCount += 1;
            }
            previousArtist = this.tracklist[t].artist.toLowerCase();
        }
        // more than 25% is from different artists ?
        return (differentArtistCount > t * 0.25) ? true : false;
    }});
    // TEST: nested tracklist2 + tracks object
    
    
    
}


// DEDICATED Release OBJECTS PROTOTYPE METHODS ===============================================================


Release.prototype.normalizeTimecodes = function normalizeTimecodes() {
    // Align all timecodes in tracklist to shortest necessary timecode length
    // if all tracks timecodes start with '00' we strip '00:' out of all time strings
    if (!this.tracklist.some(function (trk) { return (trk.time.substr(0, 2) !== '00'); })) {
        this.tracklist = this.tracklist.map(function (trk) {
            trk.time = trk.time.replace(/^00\:/g, '');
            return trk;
        });
    }
    // if all tracks timecodes start with '0' we strip it out of all time strings
    if (!this.tracklist.some(function (trk) { return (trk.time.substr(0, 1) !== '0'); })) {
        this.tracklist = this.tracklist.map(function (trk) {
            trk.time = trk.time.replace(/^0[:]?/, '');
            return trk;
        });
    }
    // case of tracks broken down in sections with only the main having a duration
    // if at least one track has a duration set, set .time = '-' if empty to tracks with a .number
    if (this.tracklist.some(function (trk) { return (trk.time !== ''); })) {
        this.tracklist = this.tracklist.map(function (trk) {
            if (trk.number !== '' && trk.time === '') { trk.time = '-'; }
            return trk;
        });
    }
};

Release.prototype.normalizeProfile = function normalizeProfile() {
    // HEURISTICS on title, artist, (uploaded) by, label, catalog# based on a set of guesswork rules:
    // - detect if uploader (.by) name is the .artist or .label
    // - remove artist, .label, .catalog redundant info from .title
    // - clean-up .title string from layout remainders such as empty leading/trailing separators, brackets, parenthesis
    // Method best applied after tracklist has been populated (Release.isCompilation property is checked)
    // Most useful for user-contributed content platforms using 'By (username)' syntax such Mixcloud, Souncloud, Bandcamp...
    // EXECUTION ORDER BELOW MATTERS !
    // TODO (mixcloud): add support to heuristics on syntax "Artist At...", "Artist @ ", "Artist Live At "...
    // TODO (mixcloud): add support to heuristics when removespace(lower(rls.Title") includes lower(rls.By) ex. Acidpauli

    var rgxp = '', tmpstr = '';

    // uploader username is at beginning of title => strip it out & set to artist
    // .by plus '-' or '|' with/without surrounding spaces AND .by != .title
    rgxp = new RegExp('^' + this.by.escapeRegExp() + '[ \\-|]*', 'i');
    if (this.by !== '' && this.artist === '' && this.title.toLowerCase() !== this.by.toLowerCase() && this.title.match(rgxp) !== null) {
        this.title = this.title.replace(rgxp, '');
        this.artist = this.by;
        this.by = '';
    }
    // detect if 'artist - title...', 'artist | title...'
    // TODO: add case of artist "title" ..., artist 'title'...
    rgxp = new RegExp(' \\(|@| vol\\.', 'i'); // only part before '(' '@' 'vol.' empirically considered relevant
    tmpstr = this.title.split(rgxp)[0];
    rgxp = new RegExp(' [\\-|] ');
    if (this.artist === '' && tmpstr.split(rgxp).length > 1) {
        this.artist = tmpstr.split(rgxp)[0];
        this.title = this.title.replace(new RegExp('^' + this.artist.escapeRegExp() + ' [\\-|] ', 'i'), '');
    }
    // uploader username same as label or artist => clear redundant .by
    if (this.label.toLowerCase() === this.by.toLowerCase()) { this.by = ''; } // label upload
    if (this.artist.toLowerCase() === this.by.toLowerCase()) { this.by = ''; } // artist upload
    // if this is a compilation and not a mix, set artist to 'VA' and title to 'title (by '.by')'
    rgxp = new RegExp(this.by.escapeRegExp(), 'i');
    // DEACTIVATED - Oneliner was changed to include (by %by%)
    //if (this.artist === '' && this.isCompilation && !this.isMix && this.title.match(rgxp) === null) {
    //    this.title = this.title + ((this.by === '') ? '' : ' (by ' + this.by + ')');
    //    this.artist = 'VA';
    //}
    // artist empty => set to 'by' uploader username by default
    if (this.artist === '' && this.by !== '') {
        this.artist = this.by;
        this.by = '';
    }

    // .tracks empty => set from tracklist length
    if (this.tracks === '' && this.tracklist.length > 0) { this.tracks = this.tracklist.length.toString(); }

    // clean-up: strip duplicate info from .title if already captured in .catalog property
    rgxp = new RegExp('([\\[\\(])' + this.catalog.escapeRegExp() + '|' + this.catalog.escapeRegExp() + '([\\]\\)])', 'i');
    if (this.catalog !== '' && this.title.match(rgxp) !== null) { this.title = this.title.replace(rgxp, '$1'); }
    // clean-up: strip duplicate info from .title if already captured in .label property
    rgxp = new RegExp('([\\[\\(])' + this.label + '|' + this.label + '([\\]\\)])', 'i');
    if (this.label !== '' && this.title.match(rgxp) !== null) { this.title = this.title.replace(rgxp, '$1'); }
    // clean-up: rls.title string
    this.title = this.title.replace(/^[ \-|]*|[ \-|]*$/g, '');             // trim title off of empty leading & trailing space/dash/pipe separator 
    this.title = this.title.replace(/\| *\||\- *\-|\[ *\]|\( *[\)]/g, ''); // empty '[ ]' brackets (\x5B \x5D), '( )' parentheses (\x28 \x29), '- -' (\x2D) and '| |' sections
    this.title = this.title.replace(/([\[\(]) +| +([\)\]])/g, '$1$2');     // trim space before ']', ')' or after '[', '('
    this.title = this.title.replace(/ +/g, ' ');                           // fix multiple contiguous space-chars to one

    // normalize caps for artist, title, by & label
    if (this.artist.toUpperCase() === this.artist || this.artist.toLowerCase() === this.artist) { this.artist = this.artist.toInitials(); }
    if (this.title.toUpperCase() === this.title || this.title.toLowerCase() === this.title) { this.title = this.title.toInitials(); }
    if (this.by.toUpperCase() === this.by || this.by.toLowerCase() === this.by) { this.by = this.by.toInitials(); }
    if (this.label.toUpperCase() === this.label) { this.label = this.label.toInitials(); } // no change on all-lower case: domain name as label allowed & must stay unchanged

};


// DEDICATED Release OBJECTS TEXT FORMAT PROTOTYPE METHODS ====================================================


Track.prototype.TXT = function TXT(fieldsSize, skipartist) {
    // return formatted text line for the Track. we expect each Track to have at least a title.
    // required fieldsSize argument with a Track object providing string size for each property
    // optional skipartist argument to handle the case of single-artist releases
    if (skipartist === undefined) { skipartist = false; }
    var spaceToTrack = ((this.time.toString() === '') ? 0 : fieldsSize.time + 3) + ((this.number.toString() === '') ? 0 : fieldsSize.number + 2);
    return ((this.time.toString() === '') ? '' : ((this.time.toString() === '-') ? ''.lfill(fieldsSize.time + 3) : '[' + this.time.timecodefill(fieldsSize.time) + '] ')) +
               ((this.number.toString() === '') ? '' : this.number.lfill(fieldsSize.number) + '. ') +
               ((skipartist || this.artist.toString() === '') ? '' : this.artist + ' - ') +
               ((this.title === '') ? 'unknown' : this.title) +
               ((this.release + this.label === '') ? '' : ' [' + this.release + ((this.release === '' || this.label === '') ? '' : ', ') + this.label + ']') +
               ((this.bpm.toString() === '') ? '' : ' (' + this.bpm.toString() + ' bpm)') +
               ((this.credits.toString() === '') ? '' : '\n' + ''.headerline(spaceToTrack + 2, ' ') + this.credits.replace(/\n/g, '\n' + ''.headerline(spaceToTrack + 2, ' ')));
};

Release.prototype.TXT_tracklist = function TXT_tracklist() {
    // build tracklist text block
    var trklistTXT = '',
        trklist = this.tracklist, t, trk = new Track(),
        trksfieldsize = new Track(), k, keys = Object.keys(trk);
    // calculate max nb. characters for each Track property in tracklist into a Track object, for text alignment purposes
    for (t = 0; t < this.tracklist.length; t += 1) {
        trk = trklist[t];
        for (k = 0; k < keys.length; k += 1) {
            if (trk[keys[k]].length > trksfieldsize[keys[k]]) {
                trksfieldsize[keys[k]] = trk[keys[k]].length;
            }
        }
    }
    // are all tracks from the same artist as the release artist ?
    var rlsartist = this.artist.toLowerCase(),
        isSingleArtist = !this.tracklist.some(function (trk) { return (trk.artist.toLowerCase() !== rlsartist); });
    // build and return text block
    for (t = 0; t < trklist.length; t += 1) {
        trklistTXT += trklist[t].TXT(trksfieldsize, isSingleArtist) + '\n';
    }
    return trklistTXT;
};

Release.prototype.TXT_oneliner = function TXT_oneliner() {
    // one line release description string, based on mask and Release object properties of type 'string'
    var line = releaseLineFormat, attrib = '', k = 0, keys = Object.keys(this);
    for (k = 0; k < keys.length; k += 1) {
        if (typeof this[keys[k]] === 'string' && this[keys[k]] !== '') {
            // attribute content fixed to single line if needed
            attrib = this[keys[k]].replace(/ \s+\S/g, ', ').trim();
            // substitute %label% with Content into the releasLineFormat pre-formatted mask
            line = line.replace(new RegExp('%' + keys[k] + '%', 'ig'), attrib);
        } else {
            // empty Content => remove %label% section from one-liner if present
            line = line.replace(new RegExp('%' + keys[k] + '%', 'ig'), '');
        }
        // remove empty sections from the result, if any
        line = line.replace(/\(by \)/g, ''); // remove empty '(by )'
        line = line.replace(/ +\]/g, ']'); // space before ]
        line = line.replace(/\[ +/g, '['); // space after [
        line = line.replace(/ +\)/g, ')'); // space before )
        line = line.replace(/\( +/g, '('); // space after (
        line = line.replace(/\[ *\]/g, ''); // empty [ ] brackets. '['=\x5B, ']'=\x5D
        line = line.replace(/\( *\)/g, ''); // empty ( ) parentheses. '('=\x28 ')'=\x29
        line = line.replace(/(^ *\- *|\- *\-| *\- *$)/g, ''); // empty '- -' sections. '-'=\x2D
        line = line.replace(/ +/g, ' '); // fix multiple to single-space
    }
    // convert known characters forbidden in a filename, if any
    line = line.filesystemsafe();
    return line;
};

Release.prototype.TXT_profile = function TXT_profile() {
    // release profile text, based on the non-empty Release object 'string' properties (no arrays, objects...)
    // computed properties such as .year are ignored
    // user added 'string' properties appear in the same order they were added to the Release object. 
    var k, profile = '', keysmaxlenght = 0, keys = Object.keys(this);
    // max profile label string length for text formatting purposes
    for (k = 0; k < keys.length; k += 1) {
        if (typeof this[keys[k]] === 'string' && keys[k].length > keysmaxlenght) {
            keysmaxlenght = keys[k].length;
        }
    }
    // build profile text block using enumerable properties
    for (k = 0; k < keys.length; k += 1) {
        if (typeof this[keys[k]] === 'string' && keys[k] !== 'year' && this[keys[k]] !== '') {
            // Release property content, fixed to single line if needed
            profile += keys[k].replace(/_/g, ' ').toInitials().lfill(keysmaxlenght + 1, ' ') + ': ' + this[keys[k]].replace(/ \s+\S/g, ', ').trim() + '\n';
        }
    }
    return profile;
};

Release.prototype.TXT = function TXT() {
    // full release info returned as formatted text. builds on the other 'TXT_...' prototype methods
    var rlsTXT = '', d = 0;
    // release oneliner
    rlsTXT = this.TXT_oneliner() + '\n';
    // release profile section
    rlsTXT += ''.headerline() + '\n\n';
    rlsTXT += this.TXT_profile() + '\n';
    // tracklist section, with or without artist name, track duration and additional credits
    if (this.tracklist.length > 0) {
        rlsTXT += 'Tracklist'.headerline() + '\n\n';
        rlsTXT += this.TXT_tracklist() + '\n';
    }
    // additional description sections, if any
    for (d = 0; d < this.description.length; d += 1) {
        rlsTXT += this.description[d].title.headerline() + '\n\n';
        rlsTXT += this.description[d].content + '\n\n';
    }
    // final divider line
    rlsTXT += '__ generated by release:txt'.headerline() + '\n';
    // exit
    return rlsTXT;
};


// ==================================================================================================================
// USER INTERFACE WITH BUTTONS & TXTAREA 
// ==================================================================================================================


function releaseTXT_plusminus() {
    // button to expand/collapse the text box vertically
    var htmldoc = window.top.document,
        txtbox = htmldoc.getElementById('releaseTXT_txtbox'),
        plusminus = htmldoc.getElementById('plusminusTXT_button');
    if (plusminus.value === '+') {
        plusminus.value = '-';
        // expand to 80% of the browser's document window height
        // TODO: how to allow user-resize height down (dragging bottom to upwards) in Chrome ?
        // it's possible only if the user has dragged & resized it BEFORE clicking the '+' button ...
        txtbox.style.height = window.innerHeight * 0.8 + 'px';
    } else {
        plusminus.value = '+';
        // collapse text back to its original min-height
        txtbox.style.height = txtbox.style.minHeight;
    }
}

function releaseTXT_buildUI(additionalContainerCSS, insertContainerBeforeNode) {
    // build & insert script's user interface into the web page
    // optional additionalContainerCSS string: UI container styling. 
    // optional insertContainerBeforeNode html node: before which the UI should be inserted
    // style properties passed via this argument take precedence

    var htmldoc = window.top.document,
        UIcontainer = htmldoc.createElement('div'),
        div = htmldoc.createElement('div'),
        gettxt = htmldoc.createElement('input'),
        plusminus = htmldoc.createElement('input'),
        txtbox = htmldoc.createElement('textarea');

    // make way for the UI insert, same height as UI container
    htmldoc.body.style.paddingTop = '24px';

    // insert and style main UI container - default: insert UI before first <div> in <body>
    if (insertContainerBeforeNode === undefined) {
        insertContainerBeforeNode = htmldoc.getElementsByTagName('body')[0].getElementsByTagName('div')[0];
    }
    UIcontainer = insertContainerBeforeNode.parentNode.insertBefore(UIcontainer, insertContainerBeforeNode);
    UIcontainer.id = 'releaseTXT_header';
    UIcontainer.style.cssText = 'position: fixed; z-index: 9999; margin-top: -24px; height: 24px; width: 100%; background-color: #000000; ';
    if (additionalContainerCSS !== undefined) {
        UIcontainer.style.cssText += additionalContainerCSS;

    }

    // build nested div for buttons & text box
    div.id = 'releaseTXT_innerDiv';
    div.style.cssText = 'margin: 0 auto; height: 24px; width: 990px; resize: both; ';

    // build button to trigger main 'releaseTXT_main()' function
    gettxt.type = 'button';
    gettxt.id = 'releaseTXT_button';
    gettxt.value = 'release:txt';
    gettxt.addEventListener('click', function (e) { releaseTXT_main('txt_button'); }, false);
    gettxt.style.cssText = 'margin: 2px 1px 2px 10px; padding: 0px 5px 1px 5px; height: 20px; vertical-align: top; font-family: verdana; font-size: 10px; ';
    //soundcloud COUNTER INHERITED BUTTON STYLE: "color: #333; background: #fff; border: 1px solid #ccc; " border-width: 0px; 
    gettxt.style.cssText += 'color: #000; background-color: buttonface; border: solid 1px #333; border-radius: 6px; ';

    // build plus/minus button
    plusminus.type = 'button';
    plusminus.id = 'plusminusTXT_button';
    plusminus.value = '+';
    plusminus.addEventListener('click', releaseTXT_plusminus, false);
    plusminus.style.cssText = 'margin: 2px 1px; padding: 0px 0px 1px 0px; height: 20px; width: 18px; vertical-align: top; font-family: verdana; font-size: 10px;';
    // soundcloud COUNTER INHERITED BUTTON STYLE: "color: #333; background: #fff; border: 1px solid #ccc; "
    plusminus.style.cssText += 'color: #000; background-color: buttonface; border: solid 1px #333; border-radius: 6px; ';

    // build editable text box to collect release TXT output. ' + UIcontainer.style.backgroundColor + '
    txtbox.id = 'releaseTXT_txtbox';
    txtbox.value = 'click to get the text version of this release...';
    txtbox.spellcheck = false;
    txtbox.style.cssText = 'margin: 2px 1px 2px 1px; padding: 2px 1px 1px 5px; min-height: 15px; height: 15px; width: 750px; vertical-align: top; ' +
                           'resize: both; overflow: none; ' +
                           'font-family: monospace; font-size: 12px; line-height: 15px; ' +
                           'border: solid; border-width: 1px; border-color: #d7d7d7; border-radius: 6px; ' +
                           'box-shadow: inset 1px 1px 3px 0px #333; ';
    // soundcloud COUNTER INHERITED TXTAREA STYLE: "color: #333; -webkit-box-sizing: border-box; -moz-box-sizing: border-box; box-sizing: border-box;"
    txtbox.style.cssText += 'color: #000; -webkit-box-sizing: content-box; -moz-box-sizing: content-box; box-sizing: content-box; ';

    // add the elements to the main UI container
    div = UIcontainer.appendChild(div);
    gettxt = div.appendChild(gettxt);
    plusminus = div.appendChild(plusminus);
    txtbox = div.appendChild(txtbox);

    // return the container object to caller
    return UIcontainer;
}



// ==================================================================================================================
// SITE-SPECIFIC RELEASE DATA COLLECTION FUNCTIONS
// add getRelease_[source]() function to add support of other discographic release pages
// raw data only -all formatting stripped- to be fed into the 'Release' object
// ==================================================================================================================


// ====================================
// bandcamp.com release data collection
// ====================================
// CH/Tampermonkey users can add a 'User include' for the private domain bandcamp pages
// FF/Greasemonkey users can add it manually to this script's meta @includes, but will be overwritten by updates...

Release.prototype.get_bandcamp = function getRelease() {

    // page to parse and new Release object to collect data in
    var htmldoc = window.top.document,
        rls = new Release(), rlsDescriptionSection, trackrows, t, trk,
        creditsInfo = '', productInfo = '', isCompilation = true, regxp;

    // PROFILE information
    // note: isCompilation is currently guesswork true if ALL track names formatted as 'artist - title'
    //       can't be sure the 'By' on bandcamp is always a label in that case...
    //       no straightforward way to get an actual label name in case of an artist release either...
    // rls.by = htmldoc.getElementById('name-section').querySelector('[itemprop=byArtist]').textContent.tidyline();
    rls.by = htmldoc.getElementById('name-section').getElementsByTagName("h3")[0].getElementsByTagName("a")[0].textContent.tidyline();
    rls.title = htmldoc.getElementById('name-section').getElementsByClassName('trackTitle')[0].textContent.tidyline();
    rls.label = htmldoc.domain.tidyurl();
    rls.catalog = '';
    rls.released = htmldoc.getElementById('trackInfoInner').getElementsByClassName('tralbum-credits')[0].firstChild.textContent.tidyline().replace(/released /i, '');
    creditsInfo = htmldoc.getElementById('trackInfoInner').getElementsByClassName('tralbum-credits')[0].innerText.replace(/NOIDEAWHATIMDOINGHERE/i, '').trim();
    // creditsInfo = htmldoc.getElementById('trackInfoInner').getElementsByClassName('tralbum-credits')[0].innerText.replace(/released [ \w\d]+/i, '').trim();
    // creditsInfo = htmldoc.getElementById('trackInfoInner').getElementsByClassName('tralbum-credits')[0].innerText;
    // TODO? support for more than 1 sales item for the release. Digital Download  is managed correctly and seems to always come first so far.
    if (htmldoc.getElementById('trackInfoInner').getElementsByClassName('buyItemPackageTitle')[0] !== undefined) {
        rls.format = htmldoc.getElementById('trackInfoInner').getElementsByClassName('buyItemPackageTitle')[0].textContent.tidyline();
        rls.format += (htmldoc.getElementById('trackInfoInner').getElementsByClassName('compound-button')[0].textContent.tidyline().match(/Free download/i) === null) ?
                      '' : ', ' + htmldoc.getElementById('trackInfoInner').getElementsByClassName('compound-button')[0].textContent.tidyline();
        // product info not the standard 'Immediate download of n-track album in your choice of MP3 320, FLAC,...' => collect for Description
        regxp = new RegExp('Immediate download of \\d+\\-track album in your choice of MP3 320, FLAC, or just about any other format you could possibly desire\\.', 'i');
        productInfo = htmldoc.getElementById('trackInfoInner').getElementsByClassName('bd')[0].innerText.replace(regxp, '').trim();
    }
    // source specific release profile properties
    rls.bandcamp = htmldoc.URL.tidyurl();
    // rls.tags = htmldoc.getElementById('trackInfoInner').getElementsByClassName('tralbum-tags')[0].textContent.replace(/^\s+tags\:\s+|\s+$/ig, '').replace(/\n\s+/g, ', ').tidyline();

    // DESCRIPTION Section (optional)
    // note: doing before profile info, as there may be more info to be added parsed for rls.format
    rlsDescriptionSection = new Section();
    rlsDescriptionSection.title = 'Description';
    // special product info captured in the profile format collection (optional)
    if (productInfo !== '') {
        rlsDescriptionSection.content = productInfo;
    }
    // release description (optional)
    if (htmldoc.getElementById('trackInfoInner').getElementsByClassName('tralbum-about')[0] !== undefined) {
        rlsDescriptionSection.content += (rlsDescriptionSection.content === '') ? '' : '\n\n';
        rlsDescriptionSection.content += htmldoc.getElementById('trackInfoInner').getElementsByClassName('tralbum-about')[0].innerText.trim();
    }
    // description text embedded in credits section (optional)
    if (creditsInfo !== '') {
        rlsDescriptionSection.content += (rlsDescriptionSection.content === '') ? '' : '\n\n';
        rlsDescriptionSection.content += creditsInfo;
    }
    // bio band/label - upper right corner of the page (optional)
    if (htmldoc.getElementById('bio-container').querySelector('[itemprop=description]') !== null || htmldoc.getElementById('band-links') !== null) {
        rlsDescriptionSection.content += ((rlsDescriptionSection.content === '') ? '' : '\n\n') + 'ABOUT:\n';
        // bio band/label (optional)
        if (htmldoc.getElementById('bio-container').querySelector('[itemprop=description]') !== null) {
            rlsDescriptionSection.content += '\n' + htmldoc.getElementById('bio-container').querySelector('[itemprop=description]').content;
        }
        // links band/label (optional)
        if (htmldoc.getElementById('band-links') !== null) {
            var l, links = htmldoc.getElementById('band-links').getElementsByTagName('a');
            for (l = 0; l < links.length; l += 1) {
                rlsDescriptionSection.content += '\n' + links[l].href.tidyurl();
            }
        }
    }
    // store description to Release object if any content was collected
    if (rlsDescriptionSection.content !== '') { rls.description.push(rlsDescriptionSection); }

    // TRACKLIST information from <div> list items in div id='track_row_view'
    // note: we begin with traklist as it's the way to detect if it's an artit release or a compilation
    trackrows = htmldoc.getElementById('track_table').getElementsByClassName('track_row_view');
    for (t = 0; t < trackrows.length; t += 1) {
        trk = new Track();
        trk.number = trackrows[t].getElementsByClassName('track_number secondaryText')[0].textContent.tidyline().replace(/\.$/, '');
        // trk.title = trackrows[t].querySelector('[itemprop=name]').textContent.tidyline();
        trk.title = trackrows[t].getElementsByClassName('track-title')[0].textContent.tidyline();
        if (trk.title.split(' - ').length > 1) {
            // compilation: .title 'artist - title' => .artist & .title
            trk.artist = trk.title.split(' - ')[0];
            trk.title = trk.title.split(' - ')[1];
        }

				// fix for pre-releases w/ missing track times
				if (trackrows[t].getElementsByClassName('time secondaryText')[0]) {
				    trk.time = trackrows[t].getElementsByClassName('time secondaryText')[0].textContent.tidyline();
				} else {
				    trk.time = "";
				}
				
        // append to tracklist array
        rls.tracklist.push(trk);
    }

    // return Release object with collected information
    rls.normalizeProfile(); // HEURISTICS on title, artist, by, label, catalog#
    rls.normalizeTimecodes();
    return rls;
};




// ====================================
// beatport.com release data collection
// ====================================
// last updated 30 January 2014

Release.prototype.get_beatport = function getRelease() {

    // capture document & create new Release object instance
    var htmldoc = window.top.document, rls = new Release(), contentType;
    
    // get type of content from main conent player button: release, chart, ...
    contentType = htmldoc.querySelector('span.play-queue-large>a.btn-play').attributes['data-item-type'].value

    // TRACKLIST - collect first, as we need the list of main track artists to determine list of release profile main artists

    var trackrows = htmldoc.querySelectorAll('table[data-module-type=track_grid]>tbody>tr[data-index]'),
        t, trk, a, artists, genres;
    for (t = 0; t < trackrows.length; t += 1) {
        trk = new Track();
        artists = []; // reset for next track
        genres = [];  // reset for next track
        trk.number = trackrows[t].attributes['data-index'].value;
        if (trackrows[t].querySelector('span[data-json]') === null) {
 
            // Mixes: some tracks can be "MIX ONLY" with no data-json to read info from
            // ex.: http://mixes.beatport.com/mix/saga-chapter-one/126414
            trk.time = trackrows[t].querySelector('td.start-time').textContent; // mix timecode
            trk.title = trackrows[t].querySelector('div.mix-track-name').textContent.toInitials();
            trk.artist = trackrows[t].querySelectorAll('td')[5].textContent.toInitials();
            trk.genre = trackrows[t].querySelectorAll('td')[7].textContent;
            if (trackrows[t].querySelector('td.buy>span') !== null) {
                trk.title = trk.title + ' (' + trackrows[t].querySelector('td.buy>span').textContent + ')';
            }

        } else {
            // only present for tracks actually SOLD on beatport, i.e. not for "MIX ONLY" tracks within mixes.
            var trackdata = JSON.parse(trackrows[t].querySelector('span[data-json]').attributes['data-json'].value);
            trk.title = trackdata.title;
            for (a = 0; a < trackdata.artists.length; a += 1) {
                // if >1 track artist, screen artists list and remove any already present in the title (credited remix, etc...)
                // fixing Beatport's not so readable format: track artists are alpha-sorted and main artist isn't highlighted...
                // exception: http://www.beatport.com/release/surf-smurf/1216910
                if (trk.title.match(new RegExp(trackdata.artists[a].name.escapeRegExp(), 'i')) === null) {
                    artists.push(trackdata.artists[a].name);
                }
            }
            trk.artist = artists.join(', ');
            if (contentType === "mix") {
                trk.time = trackrows[t].querySelector('td.start-time').textContent; // mix timecode
            } else {
                trk.time = trackdata.length; // track length
            }
            if (trackdata.bpm !== 0) { trk.bpm = trackdata.bpm; } // 0 means unknown
    
            // currently not in TXT output - could be leveraged later
            for (a = 0; a < trackdata.genres.length; a += 1) { genres.push(trackdata.genres[a].name); }
            trk.genre = genres.join(', ');
            trk.released = trackdata.releaseDate;
            trk.published = trackdata.publishDate; //differs from .releaseDate for compilations ?
            trk.exclusive = trackdata.exclusive;   // only on Beatport
    
            // relevant only for Charts (not Releases) - we don't want release & label repeat in the tracklist in normal releases
            if (contentType === 'chart' || contentType === 'mix') {
                trk.credits = '"' + trackdata.release.name + '" [' + trackdata.label.name.toInitials() + ']';
            }
        }
        
        // capture tracklist info
        rls.tracklist.push(trk);
            
    }

    // RELEASE PROFILE
    
    // set .artist & .by depending on the type or release/chart
    var artistsLinks = htmldoc.querySelector('div[data-mod-name$=Detail] div.block,p.by-dj,span.byline').querySelectorAll('a');
	var artistsProfile = [], genresProfile = [];
    for (a = 0; a < artistsLinks.length; a += 1) {
        if (contentType === "chart" || contentType === "mix") {
            // Chart: add all profile artist(s) without checking against tracklist artists
            artistsProfile.push(artistsLinks[a].textContent);
        } else {
            // Release: check if the profile artist matches one release track MAIN artist, add it to the release artist list if not listed already
            // this is to weed out remix, featuring, etc... artists
            for (t = 0; t < rls.tracklist.length; t += 1) {
                if (rls.tracklist[t].artist.split(', ').indexOf(artistsLinks[a].textContent) !== -1 && artistsProfile.indexOf(artistsLinks[a].textContent) === -1) {
                    artistsProfile.push(artistsLinks[a].textContent); 
                }
            }
        }
    }
    if (contentType === "mix") {
            // Mix
            rls.artist = artistsProfile.join(', ');
    } else if (contentType === "chart") {
            // Chart => .artist=VA + .by=artist(s)
            rls.artist = 'VA';
            rls.by = artistsProfile.join(', ');
    } else if (artistsProfile.length===0) {
            // release with no artists <a>'s => ASSUME only in case of a compilation...
            rls.artist = 'VA';
    } else if (artistsProfile.length <= 3) {
            // release no more tha 3 artists => set to .artist
            rls.artist = artists.join(', ');
    } else {
            // >3 release artists => change to VA and set artists to .by (by = list of artists)
            rls.artist = 'VA';
            rls.by = artistsProfile.join(', ');
    }

    // PROFILE rest of the info
    // beatport (ab)uses all-caps titles => get from main player meta info
    // TODO: get release/chart/mix duration. e.g. for mixes from 'div#mix-meta>span[data-json]' or is it elesewhere ?

    rls.title = htmldoc.querySelector('span.play-queue-large>a.btn-play[data-item-name]').attributes['data-item-name'].value;
    rls.title = rls.title.replace(/ - /, ': '); // fix any " - " in the title to ": ", it's reserved
    rls.format = 'Digital';
    rls.tracks = rls.tracklist.length.toString();
    rls.beatport = htmldoc.URL.tidyurl(true); // beatport specific property

    if (contentType === "mix") {
        // see "badge-date" rls.released = htmldoc.querySelector('div[data-mod-name$=Detail] p.by-dj').lastChild.textContent.trim();
        if (rls.title.match(/ mix|mix /i) === null) { rls.title = rls.title + ' Mix'; }
        rls.label = "beatport.com";
        genresProfile = htmldoc.querySelectorAll('p.genre-list>a');
        genres = []; // clear
        for (a = 0; a < genresProfile.length; a += 1) { genres.push(genresProfile[a].textContent); }
        rls.genre = genres.join(', ');

    } else if (contentType === "chart") {
        // Chart specific profile info
        rls.released = htmldoc.querySelector('div[data-mod-name$=Detail] p.by-dj').lastChild.textContent.trim();
        rls.title = rls.title + ' Chart ' + rls.released;
        rls.label = "beatport.com";
        genresProfile = htmldoc.querySelectorAll('p.genre-list>a');
        genres = []; // clear
        for (a = 0; a < genresProfile.length; a += 1) { genres.push(genresProfile[a].textContent); }
        rls.genre = genres.join(', ');
        
    } else {
        // Release: references & release date are in a special meta data block
        // beatport localizes 'Release Date', 'Label', 'Catalog #' => can't test => assume they always come in the right order
        var r, metadatarows = htmldoc.querySelectorAll('table.meta-data>tbody>tr');
        rls.released = metadatarows[0].getElementsByTagName('td')[1].textContent.trim();
        rls.label = metadatarows[1].getElementsByTagName('td')[1].textContent.trim().toInitials();
        rls.catalog = metadatarows[2].getElementsByTagName('td')[1].textContent.trim();
        // if more unexpected fields after that, add info as new propreties (security, not seen so far)
        for (r = 3; r < metadatarows.length; r += 1) {
            rls[metadatarows[r].getElementsByTagName('td')[0].textContent.trim().toLowerCase()] = metadatarows[r].getElementsByTagName('td')[1].textContent.trim();
        }
    }
    
    // release description - beatport renders all description texts without any linefeeds/layout, no way to restore it :-(
    if (htmldoc.querySelector('div.description, p.description') !== null) {
        var rlsSection = new Section();
        rlsSection.title = 'Description';
        rlsSection.content = htmldoc.querySelector('div.description, p.description').textContent.trim(); // no formatting to preserve in Beatport descriptions...
        rls.description.push(rlsSection);
    }
    // return Release object with collected information
    rls.normalizeTimecodes();
    return rls;
};




// ===================================
// discogs.com release data collection
// ===================================
// last updated June 2018

Release.prototype.get_discogs = function getRelease() {

    // capture document, Base release info node in page & new Release object instance
    var htmldoc = window.top.document,
		rlsDiv = htmldoc.getElementById('page_content'), // target block 
        rls = new Release();

    // release profile
    var rlsProfile = rlsDiv.getElementsByClassName('profile')[0];
    // artist - title - removes artist(s) trailing '*' (what for?), ' (n)' and fixes compilations as Artist = 'VA'
    // rls.artist = rlsProfile.querySelector('h1>span[itemprop=byArtist]').textContent.tidyline();
    // rls.artist = rlsProfile.querySelector('h1>span[itemprop=byArtist]').textContent.tidyline();
    
    var element = document.querySelector('meta[property="og:title"]');
		rls.artist = element && element.getAttribute("content");
		var rlstitleregexa = new RegExp(" ?– .*", "gi")
		rls.artist = rls.artist.replace(rlstitleregexa, "");
		rls.artist = rls.artist.tidyline();

    rls.artist = rls.artist.replace(/[*]| \(\d+\)/g, '').replace(/^Various$/i, 'VA');
    if (rlsProfile.querySelectorAll('h1>span[itemprop=byArtist]>span[itemprop=name]').length > 2) {
	    // >2 album artist => .artist = VA + .by = list of artists)
        rls.by = rls.artist;
        rls.artist = 'VA';
    }
    // rls.title = document.getElementsByTagName("h1")[0].innerText;
    //
    // var rlstitleregex = new RegExp(".* ?–  ", "gi")
		//rls.title = rls.title.replace(rlstitleregex, "");
		// rls.title = rls.title.tidyline();
    // rls.title = rlsProfile.querySelector('h1>spanitemprop[name=*]').textContent.tidyline();
    var rlstitleregexb = new RegExp(".* ?– ", "gi")
		rls.title = rls.title.replace(rlstitleregexb, "");
		rls.title = rls.title.tidyline();
    // loop through the nested profile div's to gather the rest: successive pairs or 'head' & 'content'
    var profileProperties = rlsProfile.querySelectorAll('div.head, div.content'),
        d, rlsLabel, lbl, refs;
    for (d = 0; d < profileProperties.length; d += 2) {
        rlsLabel = profileProperties[d].textContent.tidyline().replace(/\:$/, '').toLowerCase();
        if (rlsLabel === 'label') {
            // parse format 'Label - Catalog' (long dash) - it can be multiple 'Label - Catalog' references
            refs = profileProperties[d + 1].getElementsByTagName('a');
            for (lbl = 0; lbl < refs.length; lbl += 1) {
                rls.label += ((rls.label !== '') ? ' / ' : '') + refs[lbl].textContent.tidyline();
                rls.catalog += ((rls.catalog !== '') ? ' / ' : '') + refs[lbl].nextSibling.textContent.tidyline().replace(/^\u2013 /, '').replace(/,$/, '');
            }
        } else {
            // .head = .content default
            rls[rlsLabel] = profileProperties[d + 1].textContent.tidyline().replace(/\u2013/g, '-');
        }
    }
    // discogs specific added Release property: release ID [link]
    rls.discogs = htmldoc.URL.match(/\d+$/g)[0] + ' [www.discogs.com/release/' + htmldoc.URL.match(/\d+$/g)[0] + ']';

    // each track can be with or without artist name, track duration and additional credits
    // tracklist is skipped if tracklist section is hidden/collapsed by user.
    if (rlsDiv.querySelector('#tracklist>div.section_content').style.display !== 'none') {
        var nbtracks = 0, t, trk, c, creditLines, creditType, creditArtist,
            trackrows = htmldoc.querySelectorAll('#tracklist table.playlist>tbody>tr');
        for (t = 0; t < trackrows.length; t += 1) {
            trk = new Track();
            if (trackrows[t].classList.contains('track_heading')) {
                // chapter separator, e.g. with multi-disc and bonus track sections in the tracklist
                trk.title = trackrows[t].querySelector('td.tracklist_track_title').textContent.tidyline();

            } else {
                // collect actual track description row
                // track count, excluding chapter separator
                nbtracks += 1;
                // track index number 
                trk.number = trackrows[t].querySelector('td.track_pos,td.tracklist_track_pos').textContent.tidyline();
                // artist
                if (trackrows[t].querySelector('td.track_artists,td.tracklist_track_artists') !== null) {
                    // artist(s) (optional) - remove leading and trailing '-' (LONG dash \u2013), '*' (what for?), ' (n)' (different artists with the same name)
                    trk.artist = trackrows[t].querySelector('td.track_artists,td.tracklist_track_artists').textContent.tidyline().replace(/^\u2013 | \u2013$|[*]| \(\d+\)/g, '');
                } else {
                    // no artist: assumed single artist album => set to profile artist
                    trk.artist = rls.artist;
                }
                // title - Replace any LONG dash by a regular dash.
                if (trackrows[t].querySelector('td.track>span.track_title,td.track>a>span.tracklist_track_title')) {
									trk.title = trackrows[t].querySelector('td.track>span.track_title,td.track>a>span.tracklist_track_title').textContent.tidyline();
								} else {
									trk.title = trackrows[t].querySelector('td.track>span.track_title,td.track>span.tracklist_track_title').textContent.tidyline();
								} 
                // title credits (optional) - ignored if hidden by user in the page
                if (trackrows[t].querySelector('td.track>blockquote') !== null) {
                    if (trackrows[t].querySelector('td.track>blockquote').style.display !== 'none') {
                        creditLines = trackrows[t].querySelectorAll('td.track>blockquote>span.tracklist_extra_artist_span');
                        for (c = 0; c < creditLines.length; c += 1) {
                            // credit line skipped if it is a single artist credit (e.g. remix) already reflected in the title
                            // more than one artist credited or artist+his credit not already in the track title => add to credit list
                            creditType = creditLines[c].firstChild.textContent.tidyline().split(String.fromCharCode(32, 8211))[0].trim();
                            if (creditLines[c].getElementsByTagName('a').length > 0) {
                                // at least one artist in the credit has a link => capture artist name in the first link
                                creditArtist = creditLines[c].getElementsByTagName('a')[0].textContent.tidyline().replace(/[*]| \(\d+\)/g, '');
                            } else {
                                // no linked artist(s) in the artist(s) list => capture full string after '[credit type] - '
                                creditArtist = creditLines[c].firstChild.textContent.tidyline().split(String.fromCharCode(32, 8211, 32))[1].replace(/[*]| \(\d+\)/g, '').trim();
                            }
                            // TODO: fix/refine condition to skip credit line to be: Credit type + Artist name present is track title, not just either as below
                            if (creditLines[c].getElementsByTagName('a').length > 1 ||
                                    (trk.title.match(new RegExp(creditType.escapeRegExp(), 'i')) === null &&
                                     trk.title.match(new RegExp(creditArtist.escapeRegExp(), 'i')) === null)) {
                                // replace ' -' (LONG dash) by ':', remove '*' (what for?) and ' (n)' (different artists with the same name)
                                trk.credits += ((trk.credits === '') ? '' : '\n') +
                                                   creditLines[c].textContent.tidyline().replace(/ \u2013/g, ':').replace(/[*]|\(\d+\)/g, '').trim();
                            }
                        }
                    }
                }
                // duration (optional)
                trk.time = trackrows[t].querySelector('td.track_duration>span,td.tracklist_track_duration>span').textContent.tidyline();
            }
            // append track to tracklist array
            rls.tracklist.push(trk);
        }
        // collect number of tracks, ignoring sub-section title lines
        rls.tracks = nbtracks.toString();
    }
    
    // add description sections with a 'data-toggle-section-id' attribute
    var sections = rlsDiv.querySelectorAll('div#page_content>div[data-toggle-section-id]'),
        sectionList, s, ln, sectn = new Section(), sectnLines = [];
    for (s = 0; s < sections.length; s += 1) {
        // add we skip user-hidden (collapsed) sections as well as the "recommendations" section
        if (sections[s].querySelector('div.section_content').style.display !== 'none' && sections[s].id.match(/recommendations/) === null) {
            sectn = new Section();
            sectnLines = [];
            // section title text. expand/collapse arrows are ignored
            sectn.title = sections[s].querySelector('h3').firstChild.textContent.tidyline();
            if (sectn.title.substr(0, 14) === 'Other Versions') { // capture and add discogs master release link
                sectn.title = 'Other Versions [www.discogs.com/master/' + sections[s].querySelector('h3>a').href.match(/\d+$/g)[0] + ']';
            }
            // section content, multiline , replacing all LONG dashes with regular dashes & tabs with ' / '
            // FIREFOX special: trim discogs seemingly random white space line-by-line 
            sectionList = sections[s].querySelector('div.section_content').innerText.replace(/\u2013/g, '-').trim().split('\n');
            for (ln = 0; ln < sectionList.length; ln += 1) {
                sectnLines.push(sectionList[ln].trim().replace(/\t/g, ' / '));
            }
            sectn.content = sectnLines.join('\n').trim();
        
            // 'Reviews' section special processing
            if (sections[s].id === "reviews") {
                // remove leading "Add Review" - if no review to begin with, clears out section content
                sectn.content = sectn.content.replace(/^Add Review/i, '').trim();
                
                sectn.content = sectn.content.replace(/Reply\x20+Notify me\x20+Helpful/ig, '');
                // convert 2x linefeeds into just one
                sectn.content = sectn.content.replace(/\n\n/ig, '\n').trim();
            }
        
            // add section to Release.description array, except if section content is empty
            if (sectn.content !== '') {
                rls.description.push(sectn);
            }
        }
    }

    // return Release object with collected information
    rls.normalizeTimecodes();
    return rls;
};




// ========================================
// junodownload.com release data collection
// ========================================
// regular releases: www.junodownload.com/products/*
// mixcloud mixes: www.junodownload.com/charts/mixcloud/*
// DJ charts: www.junodownload.com/charts/dj/*
// TODO? alternate charts: www.beatport.com/chart/*

Release.prototype.get_junodownload = function getRelease() {

    // page to parse and new Release object to collect data in
    var htmldoc = window.top.document,
        rls = new Release(), rlsDescriptionSection, trackrows, t, trk,
        charttype = htmldoc.URL.tidyurl().split('/')[2];

    switch (charttype) {

    case 'dj': case 'juno-recommends': // syntax not strictly adhering to standards

            // release profile information
        rls.artist = htmldoc.getElementById('product_list_dj_banner_dj_name').firstChild.textContent.tidyline().toInitials();
        rls.title = htmldoc.getElementById('product_list_dj_banner_chart_name').textContent.tidyline().toInitials();
        if (charttype === 'juno-recommends' && rls.artist === rls.title.substr(0, rls.artist.length)) {
            rls.artist = 'VA'; // DJ Charts: removing silly prefix repeat between artist & title in favor of 'VA'
        }
        rls.label = 'junodownload DJ Chart';
        rls.released = htmldoc.getElementById('product_list_dj_banner_chart_creation_date').textContent.tidyline().tidydate();
        rls.format = 'Digital';
        // source specific release profile properties
        if (htmldoc.getElementById('product_list_dj_banner_chart_website') !== null) {
            rls.DJ_site = htmldoc.getElementById('product_list_dj_banner_chart_website').firstChild.textContent.tidyline();
        }
        rls.juno = htmldoc.URL.tidyurl(true);

        // description Section (optional)
        if (htmldoc.getElementById('product_list_dj_banner_chart_description').textContent.trim() !== '') {
            rlsDescriptionSection = new Section();
            rlsDescriptionSection.title = 'Description';
            rlsDescriptionSection.content = htmldoc.getElementById('product_list_dj_banner_chart_description').textContent.trim();
            rls.description.push(rlsDescriptionSection);
        }

        // collect tracklist information from <div> list items in div id='product_list_controller_container_top'
        trackrows = htmldoc.getElementById('product_list_controller_container_top').getElementsByClassName('productlist_widget_container');
        for (t = 0; t < trackrows.length; t += 1) {
            trk = new Track();
            // there doesn't seem to be DJ charts with unknown tracks as with mixcloud charts
            trk.number = trackrows[t].getElementsByClassName('productlist_widget_product_sn_tracks')[0].firstChild.textContent.tidyline();
            trk.artist = trackrows[t].getElementsByClassName('productlist_widget_product_artists')[0].textContent.tidyline().toInitials();
            trk.title = trackrows[t].getElementsByClassName('productlist_widget_product_title')[0].getElementsByTagName('a')[0].textContent.tidyline();
            trk.time = trackrows[t].getElementsByClassName('productlist_widget_product_title')[0].getElementsByTagName('a')[0].nextSibling.textContent.tidyline().replace(/^\(|\)$/g, '');
            trk.label = trackrows[t].getElementsByClassName('productlist_widget_product_label')[0].textContent.tidyline();
            trk.label += ' ' + trackrows[t].getElementsByClassName('productlist_widget_product_preview_buy_tracks')[0].firstChild.textContent.tidyline().replace(/^From release\: /i, '');
            trk.release = trackrows[t].getElementsByClassName('productlist_widget_product_from_release')[0].textContent.tidyline().replace(/^From release\: /i, '');
            if (trackrows[t].getElementsByClassName('bpm-value').length > 0) {
                trk.bpm = parseInt(trackrows[t].getElementsByClassName('bpm-value')[0].textContent.tidyline(), 10);
            }
            // Additional track info - currently ignored by TXT rendering
            // TODO? add Release object/TXT methods support to this additional track info
            trk.date = trackrows[t].getElementsByClassName('productlist_widget_product_preview_buy_tracks')[0].getElementsByTagName('span')[0].textContent.tidyline();
            trk.style = trackrows[t].getElementsByClassName('productlist_widget_product_preview_buy_tracks')[0].getElementsByTagName('span')[1].textContent.tidyline();
            // append to tracklist array
            rls.tracklist.push(trk);
        }
        break;

    case 'mixcloud':

        // release profile information
        rls.artist = ''; // TODO? code some guesswork ?
        rls.title = htmldoc.getElementById('mxc_name').textContent.tidyline().toInitials();
        rls.by = htmldoc.getElementById('mxc_author').textContent.tidyline();
        rls.label = 'mixcloud.com';
        rls.format = 'Digital';
        // source specific release profile properties
        rls.mixcloud = htmldoc.getElementById('mxc_play').getElementsByTagName('a')[0].href.tidyurl();
        rls.juno = htmldoc.URL.tidyurl(true);

        // description Section (optional)
        if (htmldoc.getElementById('mxc_descr') !== null) {
            rlsDescriptionSection = new Section();
            rlsDescriptionSection.title = 'Description';
            rlsDescriptionSection.content = htmldoc.getElementById('mxc_descr').textContent.trim();
            rls.description.push(rlsDescriptionSection);
        }

        // collect tracklist information from <div> list items in div id='product_list_controller_container_top'
        trackrows = htmldoc.getElementById('product_list_controller_container_top').getElementsByClassName('productlist_widget_container');
        for (t = 0; t < trackrows.length; t += 1) {
            // clicking 'buy' on a specific track in mixcloud, a duplicate with id='mxc_selected' of the track is added at the tracklist top  
            if (trackrows[t].id !== 'mxc_selected') {
	            trk = new Track();
                if (trackrows[t].id !== '') {
                    // track is identified
                    trk.number = trackrows[t].getElementsByClassName('known_serial')[0].firstChild.textContent.tidyline();
                    trk.time = trackrows[t].getElementsByClassName('known_time')[0].textContent.tidyline();
                    trk.artist = trackrows[t].getElementsByClassName('productlist_widget_product_artists')[0].textContent.tidyline().toInitials();
                    trk.title = trackrows[t].getElementsByClassName('productlist_widget_product_title')[0].textContent.tidyline();
                    trk.release = trackrows[t].getElementsByClassName('productlist_widget_product_from_release')[0].textContent.tidyline().replace(/^From release\: /i, '');
                    trk.label = trackrows[t].getElementsByClassName('productlist_widget_product_label')[0].textContent.tidyline();
                } else {
                    // unidentified tracks have their distinct markup/styles.
                    trk.number = trackrows[t].getElementsByClassName('unknown_serial')[0].firstChild.textContent.tidyline();
                    trk.time = trackrows[t].getElementsByClassName('unknown_time')[0].textContent.tidyline();
                    trk.title = '';
                }
                // append to tracklist array
                rls.tracklist.push(trk);
            }
        }
        break;

    default: // meant for regular release under junodownload.com/products/*

            // release profile information
        rls.artist = htmldoc.getElementById('product_heading_artist').textContent.tidyline().toInitials();
        if (rls.artist.match(/Various$/) !== null) {
            // rls.compiled_by = compilation/mix artist name(s) before '/Various', if any
            if (rls.artist.split('/').length > 1) {
                rls.by = rls.artist.split('/').slice(0, rls.artist.split('/').length - 1).join('/');
            }
            rls.artist = 'VA';
        }
        rls.title = htmldoc.getElementById('product_heading_title').textContent.tidyline();
        rls.label = htmldoc.getElementById('product_heading_label').textContent.tidyline();
        rls.catalog = htmldoc.getElementById('product_info_cat_no').textContent.tidyline();
        rls.released = htmldoc.getElementById('product_info_released_on').textContent.tidyline().tidydate();
        rls.format = 'Digital';
        rls.genre = htmldoc.getElementById('product_info_genre').textContent.tidyline();
        // source specific profile information
        rls.juno = htmldoc.URL.tidyurl(true);

        // description Section (optional)
        if (htmldoc.getElementById('product_release_note') !== null || htmldoc.getElementsByClassName('product_download_dj_links').length > 0) {
            rlsDescriptionSection = new Section();
            rlsDescriptionSection.title = 'Description';
            // review
            if (htmldoc.getElementById('product_release_note') !== null) {
                rlsDescriptionSection.content = htmldoc.getElementById('product_release_note').textContent.trim().replace(/^Review\:\s+/i, 'Review:\n');
            }
            // played by
            if (htmldoc.getElementsByClassName('product_download_dj_links').length > 0) {
                rlsDescriptionSection.content += ((rlsDescriptionSection.content === '') ? '' : '\n\n') +
                                                 'Played by:\n' + htmldoc.getElementsByClassName('product_download_dj_links')[0].getElementsByTagName('i')[0].textContent.tidyline();
            }
            rls.description.push(rlsDescriptionSection);
        }

// collect tracklist information from items in div id='product_tracklist'
 // note: loop stops at .length - 1 as we skip the last non-track 'Entire Release:' shopping line in the tracklist list
 trackrows = htmldoc.getElementById('product_tracklist').getElementsByClassName('product_tracklist_records');
 // trackrows = htmldoc.querySelectorAll('tbody[itemprop=tracks]>tr');
 trackrows = htmldoc.querySelectorAll('tbody[ua_location=tracklist]>tr'); 
 for (t = 0; t < trackrows.length - 1; t += 1) {
 trk = new Track();
 trk.number = trackrows[t].getElementsByClassName('col-title')[0].textContent.tidyline();
 if (trk.number.split('. ').length > 1) {
 trk.title = trk.number.split('. ')[1];
 trk.number = trk.number.split('. ')[0]; 
 }
 if (trk.title.split(' - ').length > 1) {
 // compilation: .title 'artist - title' => .artist & .title
 trk.artist = trk.title.split(' - ')[0];
 trk.title = trk.title.split(' - ')[1];
 }

 trk.bpm = trackrows[t].getElementsByClassName('col-bpm')[0].textContent.tidyline();
 trk.time = trackrows[t].getElementsByClassName('col-length')[0].textContent.tidyline();
 rls.tracklist.push(trk);
 }
    }

    // return Release object with collected information
    rls.tracks = rls.tracklist.length.toString();
    rls.normalizeTimecodes();
    return rls;
};



// ====================================
// mixcloud.com release data collection
// ====================================
// last updated 6 June 2014 - handling case of no mixcloud shorthand URL available
    
Release.prototype.get_mixcloud = function getRelease() {

	// Updated to Mixcloud 2014 new layout. Supports mixes with/without timecodes
    // Tracklist/tracks details are presumably sourced from the junodownload database/music recognition service,
    // but experience shows that the related junodownload chart tracklist may diverge from mixcloud's. 
    // Ex. 1: tracklist differing (present upfront, not dynamically built, no ng-init attribute set):
    // http://www.mixcloud.com/acidpauli/weisse-baren-im-schwarzen-schaf/
    // http://www.junodownload.com/charts/mixcloud/acidpauli/weisse-baren-im-schwarzen-schaf/8265422
    // http://www.mixcloud.com/player/details/?key=%2Facidpauli%2Fweisse-baren-im-schwarzen-schaf%2F (simple tracklist + junochart url + guid)
    // http://www.mixcloud.com/tracklist/?guid=BD412BE6-0E9C-4585-92D0-405394A3A4D6 (very detailled tracklist with Juno buy info)
    // Ex. 2: tracklist same (loaded dynamically, ng-init attribute set)
    // http://www.mixcloud.com/falentinvreigeist/kyodai-at-attitude-club-paristokyo-dec-2012-dj-set/
    // http://www.junodownload.com/charts/mixcloud/falentinvreigeist/kyodai-at-attitude-club-paristokyo-dec-2012-dj-set/30160370
    // http://www.mixcloud.com/tracklist/?guid=D2E08B1A-8309-4137-988D-764B15DD95BC (very detailled tracklist with Juno buy info)
    // Ex. 3: no initial tracks timetable, no ng-init junodownload url, no tracklist/timecodes dynamic loading
    // http://www.mixcloud.com/ibizasonica/jose-padilla-bella-musica-ibiza-sonica-29-june/ (11 tracks)
    // http://www.mixcloud.com/player/details/?key=%2Fibizasonica%2Fjose-padilla-bella-musica-ibiza-sonica-29-june%2F (11 tracks, all "start-time" = null)
	// http://www.mixcloud.com/tracklist/?guid=E84F554C-EA3E-461A-A6C8-7FF1A14D1CE1 has "start" & "end" times (10 tracks)
    
    // capture document & create new Release object instance
    var htmldoc = window.top.document, rls = new Release();

    rls.title = htmldoc.querySelector('h1[itemprop=name]').textContent.tidyline().toInitials();
    rls.by = htmldoc.querySelector('h2[itemprop=byArtist] span[itemprop=name]').textContent.tidyline();
    rls.artist = ''; // guessed at the end via heuristics from title/by
    rls.label = 'mixcloud.com';
    rls.format = 'Digital';
    // uploaded/release date. Format is "2013-08-30T18:09:49+00:00" timestamp
    rls.released = htmldoc.querySelector('time[itemprop="dateCreated"]').attributes['datetime'].value.split('T')[0];
    if (htmldoc.querySelector('meta[property="music:duration"]') !== null) {
	    rls.duration = (htmldoc.querySelector('meta[property="music:duration"]').content * 1000).millisecToString();
    }

    // source specific added Release properties

    // tags/style/genre
    var tag, aTags = [], tags = htmldoc.querySelectorAll('div.cloudcast-item-tag-cloud span.tag-wrap');
    for (tag = 0; tag < tags.length; tag += 1) {
        aTags.push(tags[tag].textContent);
    }
    rls.tags = aTags.join(', ');

    // short URL to the cloudcast e.g. "http://i.mixcloud.com/CDUbGe"
    if (htmldoc.querySelector('span.card-link-url') !== null) {
    	rls.mixcloud = htmldoc.querySelector('span.card-link-url').textContent.tidyurl();
    } else {
    	rls.mixcloud = htmldoc.URL.tidyurl(); // full URL if shorthand not available
    }        

    // Junodownload equivalent page url, only if "ng-init" attribute is found in <div ng-controller="CloudcastHeaderCtrl" ...> tracklist parent tag 
    // Only mixcloud cloudcasts with a dynamically populating tracklist (seem to) have it
    // Ex. ng-init param: <div ng-controller="CloudcastHeaderCtrl" ng-init="juno.replaceTracklist=true;juno.guid='D2E08B1A\u002D8309\u002D4137\u002D988D\u002D764B15DD95BC';
    //       juno.chartUrl='http://www.junodownload.com/charts/mixcloud/falentinvreigeist/kyodai\u002Dat\u002Dattitude\u002Dclub\u002Dparistokyo\u002Ddec\u002D2012\u002Ddj\u002Dset/30160370'" class="ng-scope">
    //TODO: - search for the junodownload url WITH the required cloudcast key in it. It can be in the Player info if the same cloudcast as on screen is being played.
    //		  Ex. http://www.junodownload.com/charts/mixcloud/ibizasonica/jose-padilla-bella-musica-ibiza-sonica-29-june/133183
	//		<a ng-href="http://www.junodownload.com/charts/mixcloud/ibizasonica/jose-padilla-bella-musica-ibiza-sonica-29-june/133183?timein=2142" target="_blank" ng-show="nowPlaying.currentDisplayTrack.buyUrl" class="buy-current-track" href="http://www.junodownload.com/charts/mixcloud/ibizasonica/jose-padilla-bella-musica-ibiza-sonica-29-june/133183?timein=2142" style="">&nbsp; — Buy</a>
	//	 or - load JSON from "www.mixcloud.com/player/details/?key=" url - junodownload (juno.chart_url) and guid (juno.guid) are provided, together with a tracklist optionally w. timecodes
    //		  Ex. http://www.mixcloud.com/player/details/?key=%2Facidpauli%2Fweisse-baren-im-schwarzen-schaf%2F (simple tracklist + junochart url + guid)
    if (htmldoc.querySelector('div[ng-controller=CloudcastHeaderCtrl]').attributes['ng-init'] !== undefined) {

        var junourl;
        // capture ng-init raw string attribute and extract the jundownload url
        junourl = htmldoc.querySelector('div[ng-controller=CloudcastHeaderCtrl]').attributes['ng-init'].value;
        junourl = junourl.substring(junourl.indexOf("juno.chartUrl='")+15);
        junourl = junourl.substring(0, junourl.indexOf("'"));
        // convert all \uHHHH char codes back into unicode char
        //junourl = junourl.replace(/\\u([0-9a-fA-F]{4})/g, function (whole, group1) { return String.fromCharCode(parseInt(group1, 16)); } );
        junourl = JSON.parse('{"url":"' + junourl + '"}').url;		// works on Chrome at least
        // set to rls.juno property, trimming the htpp(s) away
        rls.juno = junourl.tidyurl();
    }

    // description Section (optional) - also available as plain text in a <head> <meta ...>
    var rlsSection = new Section(), descriptionHTML = htmldoc.querySelector('div[itemprop=description]>p');
    if (descriptionHTML !== null) {
        // unfuck links in description html - side effect: FIXES THE HTML SOURCE PAGE TOO.
        rlsSection.content = descriptionHTML.expandLinks().innerText.trim();
        rlsSection.title = 'Description';
        rls.description.push(rlsSection);
    }


    // TRACKLIST ENTRIES & TIMECODES

    // Timecodes for each track - present only if tracklist is NOT sourced from junodownload (TBC)
    // NOTE on mixcloud 'sectionstart' track change timecodes (TBC with mixcloud 2014 revamp):
    // they may differ from the 'Now playing' player tooltip timecodes. The same goes for artist/title info.
    // this is because the mixcloud player sources its tracklist info from the Juno database, which may differ.
    // <div ng-init="tracklistShown=false;audioLength=6873;sectionStartTimes=[0, 368, 575, 1012, 1449, 1833, 2086, 2354, 2584, 2815, 3121, 3428, 3835, 4149, 4349, 4510, 4901, 5185, 5384, 5707, 5960, 6259, 6543]"><div class="tracklist-toggle-container">
    var tracktimecodes = htmldoc.querySelector('div[ng-init*=sectionStartTimes]');
    if (tracktimecodes !== null) {
        // capture sectionStartTimes [] array string within 'ng-init' attribute
        tracktimecodes = tracktimecodes.attributes['ng-init'].value;
        tracktimecodes = tracktimecodes.substring(tracktimecodes.indexOf("sectionStartTimes=[")+19);
        tracktimecodes = tracktimecodes.substring(0, tracktimecodes.indexOf("]"));
        tracktimecodes = tracktimecodes.split(', ');
        console.log(tracktimecodes.length.toString() + ' timecodes found: ' + tracktimecodes);
        // http://www.mixcloud.com/player/details/?key=%2Facidpauli%2Fweisse-baren-im-schwarzen-schaf%2F
        // => http://www.mixcloud.com/tracklist/?guid=BD412BE6-0E9C-4585-92D0-405394A3A4D6 (track details)
        // => http://www.junodownload.com/charts/mixcloud/acidpauli/weisse-baren-im-schwarzen-schaf/8265422 (jd link)

    } else {
        //TODO: go grab tracklist/timecodes from the JSON queries (if really not to be found in the html) or even the junodownload page...
        // Ex.  http://www.mixcloud.com/player/details/?key=%2Ffalentinvreigeist%2Fkyodai-at-attitude-club-paristokyo-dec-2012-dj-set%2F
        //      http://www.mixcloud.com/tracklist/?guid=D2E08B1A-8309-4137-988D-764B15DD95BC

        // set tracktimecodes to an empty array for subsequent code compatibility
        tracktimecodes = [];
        console.log('NO timecodes table found => working from tracklist entries');
    }
 
    // Get tracklist entries nodes within <div class="cloudcast-tracklist" ...>
    var t, trk, trackrows;
    if (htmldoc.querySelector('div[ng-controller=CloudcastHeaderCtrl]').attributes['ng-init'] !== undefined) {
        // - tracklist sourced from jd and tracklist empty/not loaded yet, it's set to a unique track with title same as mix
        //   <div ng-repeat="section in juno.sections" class="track-row cf ng-scope">
        //   ex. http://www.mixcloud.com/falentinvreigeist/kyodai-at-attitude-club-paristokyo-dec-2012-dj-set/
        //   skips first cloudcast tracklist node if present: <div class="track-row cf ng-hide" ng-hide="juno.sections.length">
        //   ex. http://www.mixcloud.com/superbreak/sunday-drift-04-superbreak/  
        trackrows = htmldoc.querySelectorAll('div.cloudcast-tracklist>div.track-row[ng-repeat="section in juno.sections"]');
    } else {
        // - tracklist NOT sourced from jd ex. http://www.mixcloud.com/acidpauli/weisse-baren-im-schwarzen-schaf/
        //   <div class="track-row cf" ng-hide="juno.sections.length">
        //   ex. http://www.mixcloud.com/acidpauli/weisse-baren-im-schwarzen-schaf/
        trackrows = htmldoc.querySelectorAll('div.cloudcast-tracklist>div.track-row');
    }
    console.log(trackrows.length + ' tracks found');
    
    // collect tracklist information into rls.tracklist
    for (t = 0; t < trackrows.length; t += 1) {
        trk = new Track();
        trk.number = trackrows[t].querySelector('span.track-number').textContent.replace(/[.]/, '').trim();
        trk.title = trackrows[t].querySelector('span.chapter-name, span.track-song-name-link, a.track-song-name-link').textContent.tidyline();
        trk.artist = trackrows[t].querySelector('span.artist-name-link, a.artist-name-link').textContent.tidyline();
        if (tracktimecodes.length === trackrows.length) {
            // timecodes to all tracks available (native, not from junodownload case)
            trk.time = (tracktimecodes[t] * 1000).millisecToString(); // hh:mm:ss
        } else if (trackrows[t].querySelector('a[ng-href*="?timein="]') !== null ) {
            // fall back to get if from the track node "?timein=" argument (dynamically generated tracklist use case, for all except for "Unknown" tracks)
            // ex.: http://www.junodownload.com/charts/mixcloud/falentinvreigeist/kyodai-at-attitude-club-paristokyo-dec-2012-dj-set/30160370?timein=1557
            trk.time = trackrows[t].querySelector('a[ng-href]').attributes['ng-href'].value.match(/timein=(\d+)/)[1];
            trk.time = (Number(trk.time) * 1000).millisecToString();
        }

        // append to tracklist array
        rls.tracklist.push(trk);
    }
    rls.tracks = rls.tracklist.length.toString();

    // return Release object with collected information
    rls.normalizeProfile(); // HEURISTICS on title, artist, (uploaded) by, label, catalog#
    rls.normalizeTimecodes();
    return rls;
};




// ======================================
// soundcloud.com release data collection
// ======================================
// last updated 7 december 2013

Release.prototype.get_soundcloud = function getRelease() {

    // page to parse and new Release object to collect data in
    var htmldoc = window.top.document, rlsInfo = htmldoc,
        rls = new Release(), rlsDescription = new Section(),
        i, trackrows, t, trk;

    // RELEASE INFO BLOCK below image (optional) - usually found with label/artist track previews
    rlsInfo = htmldoc.querySelectorAll('dt.listenInfo__releaseTitle, dd.listenInfo__releaseData');
    for (i = 0; i < rlsInfo.length / 2; i += 1) {
        if (rlsInfo[i * 2].textContent.match(/Released by/i) !== null) { rls.label = rlsInfo[i * 2 + 1].textContent.trim().toInitials(); }
        if (rlsInfo[i * 2].textContent.match(/catalog/i) !== null) { rls.catalog = rlsInfo[i * 2 + 1].textContent.trim(); }
        if (rlsInfo[i * 2].textContent.match(/date/i) !== null) { rls.released = rlsInfo[i * 2 + 1].textContent.trim().tidydate(); }
        // no example found with other info fields so far...
    }

    // MAIN CONTENT HEADER
    rlsInfo = htmldoc.getElementById('content');
    // title - LONG dash(es) replaced by regular dash(es) if present
    rls.title = rlsInfo.getElementsByClassName('soundTitle__title')[0].textContent.tidyline().replace(/\u2013/g, '-');
    // uploader username
    rls.by = rlsInfo.querySelector('a.soundTitle__username').textContent.tidyline();

    // duration (track or set)
    //TODO: find an alternative way to get the duration, now fails, soundcloud source changed and generated on the fly somehow it seems...
    if (rlsInfo.querySelector('div.timeIndicator__total') !== null) {
        //rls.duration = rlsInfo.querySelector('div.timeIndicator__total').textContent.trim().replace(/\./, ':');
    } else {
        rls.duration = "0:00";
    }
    
        try {
    // format + download // free download & external download/buy link detection
    if (rlsInfo.getElementsByClassName('listenContent')[0].querySelector('button.sc-button-download') !== null) {
        rls.format = 'Free download [' + htmldoc.URL.tidyurl(true) + '/download]';
    } else if (rlsInfo.querySelector('div.sc-button-group>a.soundActions__purchaseLink') !== null) {
        rls.format = rlsInfo.querySelector('div.sc-button-group>a.soundActions__purchaseLink').title.trim() + ' [' + rlsInfo.querySelector('div.sc-button-group>a.soundActions__purchaseLink').href.tidyurl() + ']';
    }
        } catch(e) {}
    // default label to soundcloud.com if not set
    if (rls.label === '') { rls.label = 'soundcloud.com'; }

    // source-specific release info - order matters for txt layout
    // source url
    rls.soundcloud = htmldoc.URL.tidyurl();
    // date uploaded - set to .released date if empty
    rls.uploaded = rlsInfo.querySelector('time.relativeTime').title.replace(/ \d\d\:\d\d$/, '').replace(/^Posted on /i, '').trim();
    if (rls.released === '') {
        rls.released = rls.uploaded;
        rls.uploaded = '';
    }

    // description (optional)
    var descriptionDiv;
    if (rlsInfo.querySelector('div.listenDetails__description') !== null) {
        rlsDescription.title = 'Description';
        if (rlsInfo.querySelector('a.truncatedUserText__toggleLink') !== null) {
            // expandable text => get the long version - we MUST expand to get the text with format
            if (rlsInfo.querySelector('a.truncatedUserText__toggleLink').textContent === 'Read full description') {
                rlsInfo.querySelector('a.truncatedUserText__toggleLink').click();
            }
            descriptionDiv = rlsInfo.querySelector('div.userText__expanded');
        } else {
            // standard text
            descriptionDiv = rlsInfo.querySelector('div.listenDetails__description');
        }
        // unfuck links in description text - side effect: FIXES THE HTML SOURCE PAGE TOO.
        descriptionDiv.expandLinks();
        rlsDescription.content = descriptionDiv.innerText.trim();
        if (rlsDescription.content !== '') { rls.description.push(rlsDescription); }
    }

    // tags (optional)
    rlsInfo = rlsInfo.querySelectorAll('div.sc-tag-group>a');
    rls.tags = '';
    for (i = 0; i < rlsInfo.length; i += 1) {
        rls.tags += ((rls.tags === '') ? '' : ', ') + rlsInfo[i].textContent.trim();
    }

	
    // SINGLE TRACK MIXES/LIVES TRACKLIST
    // TODO: detect tracklist in description and feed it to tracklist[]

    // SOUNDCLOUD SET => GET TRACKS TOTAL & TRACKLIST
    // note: for more than artist, title and title url, each title info would need to be loaded/queried for duration, comment...
    if (htmldoc.URL.split('?')[0].match(/\/sets\//) !== null) {

        // Set duration
        if (htmldoc.querySelectorAll('h3.trackListTitle>Strong') !== null) {
            rls.duration = htmldoc.querySelectorAll('h3.trackListTitle>Strong')[1].textContent.trim().replace(/\./, ':');
        }

        // tracks details
        trackrows = htmldoc.querySelectorAll('div.soundBadge__content');
        for (t = 0; t < trackrows.length; t += 1) {
            trk = new Track();
            trk.title = trackrows[t].querySelector('a.soundTitle__title').textContent.tidyline().replace(/\u2013/g, '-');
            // extract track number from title, if present
            if (trk.title.match(/^\d+[ \.\-]+/) !== null) {
                trk.number = trk.title.match(/^\d+/)[0];
                trk.title = trk.title.replace(/^\d+[ \.\-]+/, '');
            } else {
                trk.number = (t + 1).toString();
            }
            // normalize title CAPS
            if (trk.title.toUpperCase() === trk.title || trk.title.toLowerCase() === trk.title) { trk.title = trk.title.toInitials(); }
            // .title='artist - title' => .artist & .title
            if (trk.title.split(' - ').length > 1) {
                trk.artist = trk.title.split(' - ')[0];
                trk.title = trk.title.split(' - ')[1];
            }
            // track URL - unused for now
            trk.url = trackrows[t].querySelector('a.soundTitle__title').href.tidyurl();
            // append to tracklist array
            rls.tracklist.push(trk);
        }
        rls.tracks = t.toString();
    }

    // HEURISTICS on title, artist, (uploaded) by, label, catalog#
    rls.normalizeProfile();
    // return Release object with collected information
    return rls;
};



// ==================================================================================================================
// SITE-SPECIFIC SUPPORT FUNCTIONS
// this section needs amending to add support to other discographic release pages with identification of source
// and call to the appropriate getRelease_[source]() data collection function
// ==================================================================================================================

function releaseTXT_DetectNavChange_soundcloud() {
    // document URL changed by soundcloud script => automatically trigger release text box re-set according to new page/track/set
    // div id=content first child <div> is tagged by releaseTXT_main() with current URL & sound title on first SC page visit
    // TODO? integrate back into releaseTXT_main()
    var htmldoc = window.top.document,
        pageURL = (htmldoc.querySelector('div#content>div').attributes['nav-url'] === undefined) ? '' : htmldoc.querySelector('div#content>div').attributes['nav-url'].value;
    if (pageURL !== htmldoc.URL) {
        console.log('url change: "' + pageURL + '" => "' + htmldoc.URL);
        // handing over to releaseTXT_main => we don't want to trigger url change detection on the current page's every content div change event anymore
        htmldoc.querySelector('div#content').removeEventListener('DOMNodeRemoved', releaseTXT_DetectNavChange_soundcloud, false);
        setTimeout(releaseTXT_main('url-change'), 500); // let's give page div content some time to change unattended
    }
}



// ==================================================================================================================
// SITE-SPECIFIC MAIN FUNCTIONS
// this section needs amending to add support to other discographic release pages with identification of source
// and call to the appropriate getRelease_[source]() data collection function
// ==================================================================================================================

function releaseTXT_main(mode) {

    // main function called by the 'release:txt' button.
    var htmldoc = window.top.document,
        txtbox = htmldoc.getElementById('releaseTXT_txtbox'),
        releaseTXT = 'page loading...',
        pageTitle = '',
        rls = new Release();

    // set UI text box to 'loading...' state
    txtbox.style.backgroundColor = '#FFD700'; // light red-orange wait state
    txtbox.value = releaseTXT;

    // collect release data text version from current page
    switch (htmldoc.domain.parentDomain()) {
    case 'bandcamp.com':
        releaseTXT = rls.get_bandcamp().TXT();
        break;
    case 'beatport.com':
        // UI is included in all pages because beatport implements dynamic content replacement navigation.
        // TODO: implement dynamic url change detection (user just needs to press "release:txt" to refresh the txt until then)
        if (htmldoc.URL.tidyurl().match(/^(www|mixes)\.beatport\.com\/(charts|mix|release)\//i) === null) {

            console.log('fill text box: not a release @ ' + htmldoc.URL);
            pageTitle = 'none';
            releaseTXT = 'not a release';
            txtbox.style.backgroundColor = '#d5d5d5'; // light grey

        } else {
            releaseTXT = rls.get_beatport().TXT();
        }
        break;
    case 'discogs.com':
		releaseTXT = rls.get_discogs().TXT();
        break;
    case 'junodownload.com':
		releaseTXT = rls.get_junodownload().TXT();
        break;

    case 'mixcloud.com':
        // UI is included in all pages because mixcloud 2014 switched to dynamic content replacement navigation.
        // TODO: implement dynamic url change detection (user just needs to press "release:txt" to refresh the txt until then)

        if (htmldoc.URL.tidyurl().match(/^www\.mixcloud\.com$/i) !== null ||
            htmldoc.URL.tidyurl().match(/^www\.mixcloud\.com\/[\w\d\-]+\/$/i) !== null ||
            htmldoc.URL.tidyurl().match(/^www\.mixcloud\.com\/[\w\d\-]+\/(favorites|followers|following|listens|playlists|uploads)\//i) !== null ||
            htmldoc.URL.tidyurl().match(/^www\.mixcloud\.com\/(ads|artist|categories|competitions|dashboard|developers|groups|jobs|media|myaccount|partners|player|projects|tag|terms|track|tracklist|upload)\//i) !== null) {

            console.log('fill text box: not a cloudcast @ ' + htmldoc.URL);
            pageTitle = 'none';
            releaseTXT = 'not a cloudcast';
            txtbox.style.backgroundColor = '#d5d5d5'; // light grey

        } else {

            // If tracklist parent node <div ng-controller="CloudcastHeaderCtrl"..> has a "ng-init" attribute,
            // the <div class="cloudcast-tracklist" ...> tracklist container is populated dynamically after the initial page load
            // => we run this script again until required tracklist info has been loaded.
            if (htmldoc.querySelector('div[ng-controller=CloudcastHeaderCtrl]').attributes['ng-init'] !== undefined) {
                 if (htmldoc.querySelectorAll('div#fb-root>div').length === 0 && htmldoc.querySelectorAll('div.cloudcast-tracklist>div.track-row').length === 0) {
                    // first run => set trigger to run releaseTXT_main() again when <div id="fb-root" ...> tag gets updated with content
                    htmldoc.querySelector('div#fb-root').addEventListener('DOMNodeInserted', releaseTXT_main, false);
                    console.log('cloudcast with dynamically loaded tracklist: detected');
                    releaseTXT = "loading tracklist...";
                } else if (htmldoc.querySelectorAll('div#fb-root>div').length === 1) {
                    // interim re-run, no change to Event listeners
                    console.log('cloudcast with dynamically loaded tracklist: loading');
                    releaseTXT = "loading tracklist...";
                } else if (htmldoc.querySelectorAll('div#fb-root>div').length === 2) {
                    // <div id="fb-root" ...> is populated with all 2 child <div ...> tags, we can expect tracklist to be fully loaded.
                    // remove event listener from <div id="fb-root" ...> tag
                    htmldoc.querySelector('#fb-root').removeEventListener('DOMNodeInserted', releaseTXT_main, false);
                     // security: listen in case tracklist unexpectedly expands anyway after that
                    htmldoc.querySelector('div[ng-controller=CloudcastHeaderCtrl]').addEventListener('DOMNodeInserted', releaseTXT_main, false);
                    console.log('cloudcast with dynamically loaded tracklist: complete (' + htmldoc.querySelectorAll('div#fb-root>div').length.toString() + 'x div#fb-root>div => ok, remove event listener)');
                    // go ahead acquiring cloudcast profile/tracklist
                    releaseTXT = rls.get_mixcloud().TXT();
                }
            } else {
                // all needed info is up already => go ahead acquiring cloudcast profile/tracklist
                console.log('cloudcast with initial tracklist: go ahead');
                releaseTXT = rls.get_mixcloud().TXT();
            }
        }
        break;

    case 'soundcloud.com':
        // UI is included in all pages because of sc's new design dynamic content replacement navigation.
        if (htmldoc.URL.tidyurl().match(/^soundcloud\.com\/[\w\d\-]+\/sets\/|^soundcloud\.com\/[\w\d\-]+\/[\w\d\-]+/i) !== null &&
            htmldoc.URL.tidyurl().match(/^soundcloud\.com\/[\w\d\-]+\/(apps|comments|favorites|following|followers|groups|likes|stats|tracks)[\/]?/i) === null &&
            htmldoc.URL.tidyurl().match(/^soundcloud\.com\/(101|apps|creativecommons|creators|explore|groups|jobs|messages|people|pages|premium|search|settings|sounds|stream|tags|tour|tracks|upload|you)\//i) === null) {
            
            if (htmldoc.querySelector('div#content>div') === null) {
                setTimeout(releaseTXT_main, 500); // execute this _main() again until the page has the required title & html data
            } else if (htmldoc.querySelector('span.soundTitle__title') === null) {
                setTimeout(releaseTXT_main, 500); // execute this _main() again until the page has the required title & html data
            } else if (htmldoc.querySelector('div#content>div').attributes['nav-title'] !== undefined) {
                if (mode === 'url-change' && htmldoc.querySelector('span.soundTitle__title').textContent === htmldoc.querySelector('div#content>div').attributes['nav-title'].value) {
                    setTimeout(releaseTXT_main, 500); // execute this _main() again until the page has the required title & html data
                } else {
                    console.log('fill text box: track or set @ ' + htmldoc.URL);
                    pageTitle = htmldoc.querySelector('span.soundTitle__title').textContent;
                    releaseTXT = rls.get_soundcloud().TXT();
                }
            } else {
                console.log('fill text box: track or set @ ' + htmldoc.URL);
                pageTitle = htmldoc.querySelector('span.soundTitle__title').textContent;
                releaseTXT = rls.get_soundcloud().TXT();
            }
        } else {
            // not a track or set target page
            if (htmldoc.querySelector('div#content>div') === null) {
                console.log('waiting for content div: ' + htmldoc.URL);
                setTimeout(releaseTXT_main, 500); // execute this _main() again until the page has the required title & html data
            } else {
                console.log('fill text box: not a release @ ' + htmldoc.URL);
                pageTitle = 'none';
                releaseTXT = 'not a track or set';
                txtbox.style.backgroundColor = '#d7d7d7'; // light grey
            }
        }
        if (pageTitle !== '') {
            // set custom attribute flags & EventListener used in dynamic url change detection/handling
            htmldoc.querySelector('div#content>div').setAttribute('nav-url', htmldoc.URL);
            htmldoc.querySelector('div#content>div').setAttribute('nav-title', pageTitle); // TODO? do we need to filter/escape some pageTitle characters ?
            htmldoc.querySelector('div#content').addEventListener('DOMNodeRemoved', releaseTXT_DetectNavChange_soundcloud, false);
        }
        break;

    default:
        if (htmldoc.querySelector('head>meta[content*=".bandcamp.com/"]') !== null) {
            // bandcamp.com rebranded domain page has <meta property="og:url" content="http://(...).bandcamp.com/(...)"> in <head>
            // note: we get here only if user added rebranded domain to this script's Settings>User includes (CH/TM)
            releaseTXT = rls.get_bandcamp().TXT();
        } else {
            // else, we're not supposed to be here...
            releaseTXT = 'ERROR, unexpected source page domain: ' + htmldoc.domain.replace(/^(www|\w+)\./, '');
        }
    }

    // fill text box with formatted release information text
    txtbox.value = releaseTXT;
    if (releaseTXT.match(/^(page loading\.\.\.|not a release|not a track or set|not a cloudcast)$/i) === null) { 
        txtbox.style.backgroundColor = '#FFFFFF';
    }

    // TODO: add lightweight error management in case getReleaseData_...() fails
    //txtbox.value = 'Could not collect the data for this release !! Click the + button for more...\n\n' +
    //               'Please report faulty URL below to userscripts.org/scripts/discuss/156420 :\n\n' + htmldoc.URL + '\n\n' +
    //               'Your help improving this script is appreciated.' ;
}


// ==================================================================================================================
// INITIALIZE
// ==================================================================================================================

function releaseTXT_init() {
    
    // insert UI into the site's source page
    var htmldoc = window.top.document;
    switch (htmldoc.domain.parentDomain()) {
    case 'bandcamp.com':
        releaseTXT_buildUI();
        break;
    case 'beatport.com':
        releaseTXT_buildUI('margin-top: 69px; '); // move UI to below the page's menu+player overlay
        break;
    case 'discogs.com':
        releaseTXT_buildUI('background-color: #d7d7d7; ');
        break;
    case 'junodownload.com':
        releaseTXT_buildUI('background-color: #252525; ');
        break;
    case 'mixcloud.com':
        releaseTXT_buildUI('background-color: #25292b; ');
        break;
    case 'soundcloud.com':
        // TODO: detect pages of old (classic) design and skip UI insert & _main() call + remove related @excludes...
        releaseTXT_buildUI('background-color: #333; ');
        break;
    default:
        releaseTXT_buildUI();
    }
    // get release text for current page
    releaseTXT_main('init');
}


/* FIREFOX/GREASEMONKEY: script wrapper to prevent init() execution for each embedded Ad frame
   added security check on title, as ads frames typically have a <body> but no title */
if (window.top === window.self && window.self.document.title !== '') {
    releaseTXT_init();
}