Greasy Fork

Greasy Fork is available in English.

release:txt

(WILL NOT WORK! 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

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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 (WILL NOT WORK! 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 http://greasyfork.icu/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();
}