// ==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/* // @downloadURL https://update.greasyfork.icu/scripts/41091/release%3Atxt.user.js // @updateURL https://update.greasyfork.icu/scripts/41091/release%3Atxt.meta.js // ==/UserScript== // updated November 2020, beatport.com, mixcloud.com, junodownload.com and soundcloud.com no longer work; all the respective code is kept if someone wants to commit fixes // updated April 2018 with own discogs/bandcamp code changes + soundcloud/beatport fixes from https://greasyfork.org/forum/discussion/2299/release-txt-reupload-of-script /*jslint browser: true, passfail: false, sloppy: true, nomen: false, vars: true, white: true, todo: false*/ // BEGIN CONFIGURATION var releaseLineFormat = '%artist% - %title% - %year%'; var sectionLineSeparator = '_'; var textWidth = 90; // END CONFIGURATION // ================================================================================================================== // Debugging/Text patterns analysis // ================================================================================================================== String.prototype.anal = function anal(prefix) { // return text showing text linefeeds, carriage returns, tabs, non-breaking spaces var text = this.replace(/[\xA0\u200e]/g, '_').replace(/\r/g, '\\r').replace(/\n/g, '\\n').replace(/\t/g, '\\t'); console.log(((prefix === undefined) ? '' : prefix + ': ') + text); return text; }; // ================================================================================================================== // JAVASCRIPT OBJECTS PROTOTYPE FUNCTIONS TOOLBOX // String, data/time... objects extensions. // ================================================================================================================== /* FIREFOX COMPATIBILITY - PARTIAL EMULATION with .textContent of Chrome's elegant .innerText property based on tags seen in the target sites descriptions: this does NOT aim at being a spec-abiding emulation ! Native browser or added prototype .innerText are NOT overriden if present. Used only for description texts */ if (!HTMLElement.prototype.hasOwnProperty("innerText")) { Object.defineProperty(HTMLElement.prototype, "innerText", { get: function () { // linebreaks optimization before .textContent with support for just a few very basic HTML tags. var thisHTML = this.innerHTML, text; thisHTML = thisHTML.replace(/\s+/g, ' '); // discogs.com: in-text multiple space+tabs+linebreaks mess fixed to single-space chars thisHTML = thisHTML.replace(/<\/p>/ig, '\n\n

'); // 2x linebreaks before element closing thisHTML = thisHTML.replace(/<\/(li|ul|ol|table|tr)>/ig, '\n'); // 1 linebreak before element closing thisHTML = thisHTML.replace(/
/ig, '\n
'); // 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
in 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
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 '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
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
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
tracklist parent tag // Only mixcloud cloudcasts with a dynamically populating tracklist (seem to) have it // Ex. ng-init param:
//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 //   — Buy // 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 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. //
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
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 //
// ex. http://www.mixcloud.com/falentinvreigeist/kyodai-at-attitude-club-paristokyo-dec-2012-dj-set/ // skips first cloudcast tracklist node if present:
// 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/ //
// 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
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
has a "ng-init" attribute, // the
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
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) { //
is populated with all 2 child
tags, we can expect tracklist to be fully loaded. // remove event listener from
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 in // 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 but no title */ if (window.top === window.self && window.self.document.title !== '') { releaseTXT_init(); }