Greasy Fork is available in English.

MusicBrainz function library

Musicbrainz function library. Requires jQuery to run.

Script này sẽ không được không được cài đặt trực tiếp. Nó là một thư viện cho các script khác để bao gồm các chỉ thị meta // @require https://updategreasyfork.deno.dev/scripts/5140/22430/MusicBrainz%20function%20library.js

// ==UserScript==
// @name        MusicBrainz function library
// @namespace   http://www.jens-bertram.net/userscripts/mbz-lib
// @description Musicbrainz function library. Requires jQuery to run.
// @supportURL  https://github.com/JensBee/userscripts
// @icon        https://wiki.musicbrainz.org/-/images/3/39/MusicBrainz_Logo_Square_Transparent.png
// @license     MIT
// @version     0.4beta
//
// @grant       none
// ==/UserScript==
// Function library to work with MusicBrainz pages.
// Please beware that this library is not meant for public use. It may change
// between versions in any incompatible way. If you make use of this library you
// may want to fork it or use a service like greasyfork which is able to point
// to a specific version of this library.
MBZ = null;

/**
  * Event callback for re-using library.
  *	@lib Library passed in from callback.
  */
var loader = function(lib) {
  MBZ = lib;
};

/**
  * Library specification for re-using.
  */
var thisScript = {
  id: 'mbz-lib',
  version: '0.4beta',
  loader: loader
};

// trigger load event
$(window).trigger('MBZLoadingLibrary', thisScript);

// reuse existing library, if already set by callback
if (MBZ) {
  console.log("Reusing library", MBZ);
} else {
  // we have to wrap this in the else statement, because GreasyMonkey does not
  // like a return statement in top-level code

  MBZ = {
    baseUrl: 'https://musicbrainz.org/',
    impl: {} // concrete implementations, unloaded after initialization
  };
  MBZ.iconUrl = MBZ.baseUrl + 'favicon.ico',

  MBZ.impl.Html = function() {
    this.globStyle = null;

    /**
      * Add CSS entry to pages <head/>.
      * @param style definition to add
      */
    function init() {
      if ($('head').length == 0) {
        $('body').append($('<head>'));
      }
      this.globStyle = $('head>style');
      if (this.globStyle.length == 0) {
        this.globStyle = $('<style>');
        this.globStyle.attr('type', 'text/css');
        $('head').append(this.globStyle);
      }
      this.globStyle.append(''
        + 'button.mbzButton{'
          + 'cursor:pointer;'
          + 'text-decoration:none;'
          + 'text-shadow:-1px -1px 0 rgba(255,201,97,0.3);'
          + 'font-weight:bold;'
          + 'color:#000;'
          + 'padding:5px 5px 5px 25px;'
          + 'border-radius:5px;'
          + 'border-top:1px solid #736CAE;'
          + 'border-left:1px solid #736CAE;'
          + 'border-bottom:1px solid #FFC961;'
          + 'border-right:1px solid #FFC961;'
          + 'background:#FFE3B0 url("' + MBZ.iconUrl + '") no-repeat 5px center;'
        + '}'
        + 'button.mbzButton:hover{'
          + 'border:1px solid #454074;'
          + 'background-color:#FFD88C;'
        + '}'
        + 'button.mbzButton:disabled{'
          + 'cursor:default;'
          + 'border:1px solid #ccc;'
          + 'background-color:#ccc;'
          + 'color:#5a5a5a;'
        + '}'
        + 'div#mbzDialog{'
          + 'margin:0.5em 0.5em 0.5em 0;'
          + 'padding:0.5em;'
          + 'background-color:#FFE3B0;'
          + 'border-top:1px solid #736CAE;'
          + 'border-left:1px solid #736CAE;'
          + 'border-bottom:1px solid #FFC961;'
          + 'border-right:1px solid #FFC961;'
        + '}'
      );
    };

    /**
      * Add some CSS to the global page style.
      * @style CSS to add
      */
    this.addStyle = function(style) {
      this.globStyle.append(style);
    };

    // constructor
    init.call(this);
  };
  MBZ.impl.Html.prototype = {
    mbzIcon: '<img src="' + MBZ.iconUrl + '" />',

    /**
      * Create a MusicBrainz link.
      * @params[type] type to link to (e.g. release)
      * @params[id] mbid to link to (optional)
      * @params[more] stuff to add after mbid + '/' (optional)
      * @return plain link text
      */
    getLink: function (params) {
      return MBZ.baseUrl + params.type + '/'
        + (params.id ? params.id + '/' : '') + (params.more || '');
    },

    /**
      * Create a MusicBrainz link.
      * @params[type] type to link to (e.g. release)
      * @params[id] mbid to link to (optional)
      * @params[more] stuff to add after mbid + '/' (optional)
      * @params[title] link title attribute (optional)
      * @params[text] link text (optional)
      * @params[before] stuff to put before link (optional)
      * @params[after] stuff to put after link (optional)
      * @params[icon] true/false: include MusicBrainz icon (optional,
      *		default: true)
      * @return link jQuery object
      */
    getLinkElement: function (params) {
      params.icon = (typeof params.icon !== 'undefined'
        && params.icon == false ? false : true);
      var retEl = $('<div style="display:inline-block;">');
      if (params.before) {
        retEl.append(params.before);
      }
      var linkEl = $('<a>' + (params.icon ? this.mbzIcon : '')
        + (params.text || '') + '</a>');
      linkEl.attr('href', this.getLink({
        type: params.type,
        id: params.id,
        more: params.more
      })).attr('target', '_blank');
      if (params.title) {
        linkEl.attr('title', params.title);
      }
      retEl.append(linkEl);
      if (params.after) {
        retEl.append(params.after);
      }
      return retEl;
    },

    getMbzButton: function(caption, title) {
      var btn = $('<button type="button" class="mbzButton">' + caption
        + '</button>');
      if (title) {
        btn.attr('title', title);
      }
      return btn;
    }
  };

  /**
    * Utility functions.
    */
  MBZ.impl.Util = function() {};
  MBZ.impl.Util.prototype = {
    /**
     * Convert anything to string.
     * @data object
     */
    asString: function (data) {
      if (data == null) {
        return '';
      }
      switch (typeof data) {
        case 'string':
          return data.trim();
        case 'object':
          return data.toString().trim();
        case 'function':
          return 'function';
        case 'undefined':
          return '';
        default:
          data = data + '';
          return data.trim();
      }
    },

    /**
      * Creates http + https url from a given https? url.
      * @url http/https url
      * @return array with given url prefixed with http + https or single url,
      * if not https? protocol
      */
    expandProtocol: function(url) {
      var urls;
      if (url.toLowerCase().startsWith('http')) {
        var urlPath = url.replace(/^https?:\/\//,'');
        urls = ['http://' + urlPath, 'https://' + urlPath];
      } else {
        urls = [url];
      }
      return urls;
    },

    /**
     * Creates http + https urls from a given array of https? urls.
     * @urls array of http/https urls
     * @return array with given urls prefixed with http + https
     */
    expandProtocols: function(urls) {
      var newUrls = [];
      var self = this;
      $.each(urls, function(idx, val){
        newUrls = newUrls.concat(self.expandProtocol(val));
      });
      return newUrls;
    },

    /**
      * Get the last path segment from a URL.
      */
    getLastPathSegment: function(str) {
      if (!str || typeof str !== 'string' || str.indexOf('/') == -1) {
        return str;
      }
      var seg = str.split('/');
      return seg[seg.length -1];
    },

    /**
      * Detect the MusicBrainz page we're on.
      */
    getMbzPageType: function() {
      var type = [];
      if (this.isMbzPage()) {
        var path = window.location.pathname;
        if (path.contains("/artist/")) {
          type.push("artist");
        } else if (path.contains("/recording/")) {
          type.push("recording");
        } else if (path.contains("/release/")) {
          type.push("release");
        } else if (path.contains('/release-group/')) {
          type.push("release-group");
        }
        var lps = this.getLastPathSegment(path);
        // exclude id strings
        if (!lps.match(/^[0-9a-f]+-[0-9a-f]+-[0-9a-f]+-[0-9a-f]+-[0-9a-f]+$/)) {
          type.push(lps);
        }
      }
      return type;
    },

    /**
     * Convert HH:MM:SS, MM:SS, SS to seconds.
     * http://stackoverflow.com/a/9640417
     * @str string
     * @return seconds extracted from initial string
     */
    hmsToSeconds: function (str) {
      str = MBZ.Util.asString(str);
      if (str.indexOf(':') > -1) {
        var p = str.split(':'), s = 0, m = 1;

        while (p.length > 0) {
            s += m * parseInt(p.pop(), 10);
            m *= 60;
        }

        return s;
      } else {
        return str;
      }
    },

    /**
      * Check, if we're on a musicbrainz page.
      * @return true if so
      */
    isMbzPage: function() {
      if (window.location.hostname.contains('musicbrainz.org')
          || window.location.hostname.contains('mbsandbox.org')) {
        return true;
      }
      return false;
    },

    /**
      * Convert milliseconds to HH:MM:SS.ss string.
      * https://coderwall.com/p/wkdefg
      */
    msToHms: function (ms) {
      str = this.asString(ms);
      if (str.match(/^[0-9]+$/)) {
        var milliseconds = parseInt((ms % 1000) / 100)
            , seconds = parseInt((ms / 1000) % 60)
            , minutes = parseInt((ms / (1000 * 60)) % 60)
            , hours = parseInt((ms / (1000 * 60 * 60)) % 24);

        hours = (hours < 10) ? "0" + hours : hours;
        minutes = (minutes < 10) ? "0" + minutes : minutes;
        seconds = (seconds < 10) ? "0" + seconds : seconds;

        return (hours && hours != '00' ? (hours + ":") : '') + minutes + ":"
          + seconds + (milliseconds ? ("." + milliseconds) : '');
      } else {
        return ms;
      }
    },

    /**
     * Remove a trailing slash from a string
     * @str string
     * @return intial string with trailing slash removed
     */
    rmTrSlash: function (str) {
      if(str.substr(-1) == '/') {
          return str.substr(0, str.length - 1);
      }
      return str;
    }
  };

  /**
    * Util functions to work with results from MutationObservers.
    */
  MBZ.impl.Util.Mutations = function() {};
  MBZ.impl.Util.Mutations.prototype = {
    /**
      * Checks mutation records if an element with a given tagName was added.
      * If callback function returns true, no further elements will be checked.
      * @mutationRecords mutation records passed by an observer
      * @tName tagname to check for (case is ignored)
      * @cb callback function
      * @scope optionl scope for callback
      * @return if callback returned true, false otherwise
      */
    forAddedTagName: function(mutationRecords, tName, cb, scope) {
      if (!mutationRecords || !cb || !tName || tName.trim().length == 0) {
        return false;
      }
      tName = tName.toLowerCase();
      return mutationRecords.some(function(mutationRecord){
        for (let node of mutationRecord.addedNodes) {
          if (node.tagName && node.tagName.toLowerCase() == tName) {
            var ret;
            if (scope) {
              ret = cb.call(scope, node);
            } else {
              ret = cb(node);
            }
            if (ret == true) {
              return ret;
            }
          }
        };
      });
    }
  };

  /**
    * Shared bubble editor functions.
    */
  MBZ.impl.BubbleEditor = function() {};
  MBZ.impl.BubbleEditor.prototype = {
    /**
      * Add an artist credit.
      * Must be called in scope.
      * @bubble bubble element
      *	@data String or array with 1-3 elements. [mb-artist name, artist as
      *	credited, join phrase]
      * @noAc if true, displaying the autocomplete popup will be disabled
      */
    addArtist: function(data, noAc) {
      if (typeof data === 'string') {
        data = [data];
      }
      if (data && data.length > 0) {
        var rows = this.getCreditRows();
        if (rows.length > 0) {
          var targets = this.getCreditInputs(rows.get(rows.length -1));
          // check, if row is all empty..
          if (targets[0].val() != '' || targets[1].val() != ''
              || targets[2].val() != '') {
            // ..if not, add one row and re-set target
            if (targets[2].val().trim() == '') {
              // at least in track bubble adding a new artist is not possible
              // without a join-phrase - so add one
              targets[2].val(" & ");
              targets[2].trigger('change');
            }
            $(this.getBubble().find('.add-item').get(0)).click();
            rows = this.getCreditRows();
            targets = this.getCreditInputs(rows.get(rows.length -1));
          }
          if (noAc) {
            targets[0].autocomplete({disabled: true});
          }

          targets[0].val(data[0]);

          if (data.length > 1) {
            targets[1].val(data[1]);
          } else {
            targets[1].val(data[0]);
          }
          if (data.length > 2) {
            targets[2].val(data[2]);
          }
          targets[0].trigger('input');

          if (noAc) {
            targets[0].autocomplete({disabled: false});
          }
        }
      }
    },

    /**
      * Get all mb-artist credits currently listed in the bubble editor.
      * Must be called in scope.
      * @return array with artist names
      */
    getArtistCredits: function() {
      var rows = this.getCreditRows();
      var artists = [];

      if (rows.length > 0) {
        var self = this;

        $.each(rows, function() {
          var row = $(this);
          var inputs = self.getCreditInputs(row);
          if (inputs[0]) {
            artists.push(inputs[0].val());
          }
        });
      }

      return artists;
    },

    /**
      * See Observer.addAppearCb.
      */
    onAppear: function(params) {
      return this._bubble.observer.addAppearCb(params);
    },

    /**
      * See Observer.addChangedCb.
      */
    onContentChange: function(params) {
      return this._bubble.observer.addChangedCb(params);
    },

    /**
      * Remove a complete artist credit by it's row.
      * @row artists data row
      */
    removeArtist: function(row) {
      if (row) {
        // may be <button/> or <input/> - so check attribute only
        $(row).find('.remove-artist-credit').click();
      }
    },

    /**
      * Get a new array with artists removed already present in bubble editor.
      * Checks are done against the mb artist name. Check is done by using
      * all lower case letters.
      * Must be called in scope.
      * @artists Array of artist names
      */
    removePresentArtists: function(artists) {
      var rows = this.getCreditRows();
      var newArtists = [];

      var presentArtists = this.getArtistCredits();

      if (rows.length > 0) {
        var presentArtists = [];
        var self = this;

        $.each(rows, function() {
          var row = $(this);
          var inputs = self.getCreditInputs(row);
          if (inputs[0]) {
            presentArtists.push(inputs[0].val().toLowerCase());
          }
        });

        // sort out new ones
        for (let artist of artists) {
          if (presentArtists.indexOf(artist.toLowerCase()) == -1) {
            newArtists.push(artist);
          }
        }
      }

      return newArtists;
    },

    /**
      * Tries to open the bubble by clicking the given handler.
      * @bubble bubble element
      * @handler handler to click
      */
    tryOpen: function(handler) {
      var bubble = this.getBubble();
      if (bubble && !bubble.is(':visible')) {
        handler.click();
      }
    },

    /**
      * Bubble observer class.
      * @instance Bubble class instance.
      * @ids[bubble] Id of bubble element
      * @ids[container] For two-stage loading: container that will contain the
      * bubble (optional)
      */
    Observer: function(instance) {
      var observer = null;
      var disconnected = false;
      var onAppearCb = [];
      var onChangeCb = [];
      var that = instance;
      var noBubble = false;

      function mutated(mutationRecords) {
        if (that._bubble.el) {
          // remove observer, if noone is listening
          if (onChangeCb.length == 0) {
            console.debug("Remove bubble observer - noone listening.");
            observer.disconnect();
            disconnected = true;
          } else {
            for (let cbParams of onChangeCb) {
              cbParams.cb(that._bubble.el, mutationRecords);
            }
          }
        } else {
          var bubble = $(that._bubble.id);
          if (bubble && bubble.length ==1) {
            that._bubble.el = bubble;
            hasAppeared();
          }
        }
      };

      function hasAppeared() {
        // call onAppear callbacks
        while (onAppearCb.length > 0) {
          onAppearCb.pop().cb(that._bubble.el);
        }
        // check if someone is listening for changes
        if (onChangeCb.length == 0) {
          if (observer) {
            console.debug("Remove bubble observer - noone listening.");
            observer.disconnect();
            disconnected = true;
          } else {
            console.debug("Not attaching bubble observer - noone listening.");
          }
        }
      };

      function init() {
        var bubble = $(that._bubble.id);
        var e;
        if (bubble && bubble.length ==1) {
          that._bubble.el = bubble;
          e = bubble.get(0);
          hasAppeared();
        }

        if (!e) {
          console.debug(that.type,
            "Bubble not found. Giving up.");
          noBubble = true;
        } else {
          observer = new MutationObserver(mutated);
          observer.observe(e, {
            childList: true,
            subtree: true
          });
        }
      };

      function reAttach() {
        if (disconnected) {
          console.debug("Re-attach bubble observer - new listener.");
          observer.observe(that._bubble.el.get(0), {
            childList: true,
            subtree: true
          });
        }
      };

      /**
        * Add a listener to listen to appearance of the bubble. Callback is
        * called directly, if bubble is already present.
        * @cb[cb] callcack function
        * @return true, if added or called immediately, false, if there's no
        * bubble to attach to
        */
      this.addAppearCb = function(cb) {
        if (noBubble) {
          console.debug("Not attaching to event. No bubble.");
          return false;
        }
        if (that._bubble.el) {
          // direct call, bubble already there
          cb.cb(that._bubble.el);
        } else {
          // add to stack
          onAppearCb.push(cb);
        }
        return true;
      };

      /**
        * Add a listener to listen to changes to the bubble.
        * @cb[cb] callcack function
        * @return true, if added, false, if there's no bubble to attach to
        */
      this.addChangedCb = function(cb) {
        if (noBubble) {
          console.debug("Not attaching to event. No bubble.");
          return false;
        }
        reAttach();
        onChangeCb.push(cb);
        return false;
      };

      // constructor
      init.call(this);
    }
  };

  /**
    * Bubble editors base class.
    */
  MBZ.BubbleEditor = {
    /**
    * Differenciate types of bubble editors.
    */
    types: {
      artistCredits: 'ArtistCreditBubble',
      trackArtistCredits: 'TrackArtistCreditBubble'
    }
  };

  /**
    * Artists credits bubble.
    */
  MBZ.BubbleEditor.ArtistCredits = function() {
    this.type = MBZ.BubbleEditor.types.artistCredits;
    this._bubble = {
      el: null,
      id: '#artist-credit-bubble',
      observer: null
    };

    /**
      * Get the bubble element.
      */
    this.getBubble = function() {
      return this._bubble.el;
    };

    /**
      * Extract the inputs for mb-artist, credited-artist and join-phrase from a
      *	single data row.
      *	@row data row
      * @return array with input elements for mb-artist, credited-artist and
      * join-phrase from a single data row.
      */
    this.getCreditInputs = function(row) {
      if (!row || (row.length && row.length == 0)) {
        console.debug("Empty row.");
        return [];
      }
      row = $(row);

      var rowData = [];
      var el = row.find('input[type="text"]'); // mb-artist

      if (el.length == 1) {
        rowData.push(el);
        el = row.next().find('input[type="text"]'); // artist as credited
        if (el.length == 1) {
          rowData.push(el);
          el = row.next().next().find('input[type="text"]'); // join phrase
          if (el.length == 1) {
            rowData.push(el);
            return rowData;
          }
        }
      }
      return [];
    };

    /**
      * Get the rows containing inputs for mb-artist, credited-artist and
      * join-phrase from the bubble.
      *	@return jQuery object containing each data row. This is for each entry
      * the first row containing the mb-artist name.
      */
    this.getCreditRows = function() {
      if (this._bubble.el) {
        return this._bubble.el.find('tr:has(input.name)');
      } else {
        console.debug("No rows found. Bubble not present.");
        return $();
      }
    };

    /**
      * Called when bubble has appeared to call the real observer.
      */
    function attachObserver() {
      this._bubble.observer = new this.Observer(this);
    }

    // wait until bubble container appears
    MBZ.ReleaseEditor.onAppear({
      cb: attachObserver,
      scope: this,
      selector: this._bubble.id
    });
  };

  /**
    * Track artists credits bubble.
    */
  MBZ.BubbleEditor.TrackArtistCredits = function() {
    this.type = MBZ.BubbleEditor.types.trackArtistCredits;
    this._bubble = {
      el: null,
      id: '#track-ac-bubble',
      observer: null
    };

    /**
      * Get the bubble element.
      */
    this.getBubble = function() {
      return this._bubble.el;
    };

    /**
      * Get the rows containing inputs for mb-artist, credited-artist and
      * join-phrase from the bubble.
      *	@return jQuery object containing each data row
      */
    this.getCreditRows = function() {
      if (this._bubble.el) {
        return this._bubble.el.find('tr:has(td span.artist)');
      } else {
        console.debg("No rows found. Bubble not present.");
        return $();
      }
    };

    /**
      * Extract the inputs for mb-artist, credited-artist and join-phrase from a
      *	single data row.
      *	@row data row
      * @return array with input elements for mb-artist, credited-artist and
      * join-phrase from a single data row.
      */
    this.getCreditInputs = function(row) {
      if (!row) {
        console.debug("Empty row.");
        return [];
      }

      var inputs = $(row).find('td input[type="text"]');
      if (inputs.length == 3) {
        return [
          $(inputs.get(0)), // mb-artist
          $(inputs.get(1)), // artist as credited
          $(inputs.get(2)) // join-phrase
        ];
      } else {
        return [];
      }
    };

    this._bubble.observer = new this.Observer(this);
  };

  MBZ.impl.ReleaseEditor = function() {
    var observer = new MutationObserver(mutated);
    var e = $('#release-editor');
    var onAppearCb = [];
    var observing = false;

    function checkExistance(cbConf) {
      var result = e.find(cbConf.selector);
      if (result.length > 0) {
        if (cbConf.scope) {
          cbConf.cb.call(cbConf.scope, result);
        } else {
          cbConf.cb(result);
        }
        return true;
      }
      return false;
    };

    function checkObserver() {
      if (onAppearCb.length > 0) {
        if (!observing) {
          observer.observe(e.get(0), {
            childList: true,
            subtree: true
          });
          observing = true;
          console.debug("Attach ReleaseEditor observer - new listener.");
        }
      } else if (observing) {
          observer.disconnect();
          observing = false;
          console.debug("Remove ReleaseEditor observer - noone listening.");
      }
    };

    function mutated(mutationRecords) {
      var surviving = [];
      for (var cbConf of onAppearCb) {
        if (!checkExistance(cbConf)) {
          surviving.push(cbConf);
        }
      }
      onAppearCb = surviving;
      checkObserver();
    };

    /**
      * cbConf[selector] element selector to search for
      * cbConf[cb] callback function
      * cbConf[scope] optional scope for callback
      */
    this.onAppear = function(cbConf) {
      if (!checkExistance(cbConf)) {
        checkObserver();
        onAppearCb.push(cbConf);
      }
    };
  };

  /**
    * Release tracklist.
    */
  MBZ.impl.TrackList = function() {
    var observer;
    var id = '#tracklist';

    var Observer = function() {
      var observer;
      var onChangeCb = [];

      function attach() {
        console.debug("Creating tracklist observer - new listener.");
        observer = new MutationObserver(mutated);
        observer.observe($(id).get(0), {
          childList: true,
          subtree: true
        });
      };

      function mutated(mutationRecords) {
        var element = $(id);
        for (cb of onChangeCb) {
          cb.cb(element, mutationRecords);
        }
      };

      /**
        * Add a listener to listen to changes to the bubble.
        * @cb[cb] callcack function
        */
      this.addChangedCb = function(cb) {
        if (!observer) {
          attach();
        }
        onChangeCb.push(cb);
      };
    };

    this.getList = function() {
      return $(id);
    };

    this.onContentChange = function(params) {
      if (!observer) {
        console.debug("Not attaching to event. No tracklist.");
        return false;
      }
      return observer.addChangedCb(params);
    };

    if ($(id).length == 1) {
      observer = new Observer();
    }
  };

  /**
    * Cover art archive.
    */
  MBZ.impl.CA = function() {};
  MBZ.impl.CA.prototype = {
    baseUrl: 'https://coverartarchive.org/',
    originBaseUrl: 'https://cors-anywhere.herokuapp.com/coverartarchive.org:443/',

    /**
      * Create a CoverArtArchive link.
      * @params[type] type to link to (e.g. release)
      * @params[id] mbid to link to (optional)
      * @params[more] stuff to add after mbid (optional)
      */
    getLink: function (params) {
      return this.originBaseUrl + params.type + '/'
        + (params.id ? params.id + '/' : '') + (params.more || '');
    }
  };

  /**
   * MusicBrainz web service v2 interface.
   */
  MBZ.impl.WS = function() {};
  MBZ.impl.WS.prototype = {
    _baseUrl: MBZ.baseUrl + 'ws/2/',
    _queue: [],
    _pollFreq: 1100,
    _pollInterval: null,

    /**
     * Add to request queue.
     * @params[cb] callback
     * @params[url] request url
     * @params[args] callback function parameters object
     * @params[scope] scope for calling callback function
     */
    _qAdd: function(params) {
      this._queue.push(params);
      if (!this._pollInterval) {
        if (this._queue.length == 1) {
          this._qPoll();
        }
        this._pollInterval = setInterval(this._qPoll, this._pollFreq);
      }
    },

    /**
     * Execute queued requests.
     */
    _qPoll: function() {
      if (MBZ.WS._queue.length > 0) {
        var item = MBZ.WS._queue.pop();
        $.getJSON(item.url, function(data) {
          if (item.args) {
            if (item.scope) {
              item.cb.call(item.scope, data, item.args);
            } else {
              item.cb(data, item.args);
            }
          } else {
            if (item.scope) {
              item.cb.call(item.scope, data);
            } else {
              item.cb(data);
            }
          }
        }).fail(function(jqxhr, textStatus, error) {
          var err = textStatus + ', ' + error;
          console.error("Request (" + item.url + ") failed: " + err);
          if (item.scope) {
            item.cb.call(item.scope);
          } else {
            item.cb();
          }
        });
      } else if (MBZ.WS._queue.length == 0 && MBZ.WS._pollInterval) {
        clearInterval(MBZ.WS._pollInterval);
      }
    },

    /**
      * Lookup a musicbrainz url relation
      * @params[cb] callback function
      * @params[res] url to lookup
      * @params[rel] relation type
      * @params[scope] scope for callback function
      */
    getUrlRelation: function (params) {
      this._qAdd({
        cb: params.cb,
        url: this._baseUrl + 'url?resource=' + encodeURIComponent(params.res)
          + '&inc=' + params.rel + '-rels',
        scope: params.scope
      });
    },

    /**
      * Lookup musicbrainz url relations
      * @params[urls] array of urls to lookup
      * @params[rel] relation type
      * @params[cb] callback function for each response
      * @params[cbInc] callback for each item looked up
      * @params[cbDone] callback to call if all items have been looked up
      * @params[scope] scope for callback functions
      */
    getUrlRelations: function(params) {
      var self = this;
      var count = params.urls.length;
      var current = 0;
      function localCb(data) {
        if (params.scope) {
          params.cb.call(params.scope, data);
        } else {
          params.cb(data);
        }
        if (typeof params.cbInc === 'function') {
          if (params.scope) {
            params.cbInc.call(params.scope);
          } else {
            params.cbInc();
          }
        }
        if (++current == count && typeof params.cbDone === 'function') {
          if (params.scope) {
            params.cbDone.call(params.scope);
          } else {
            params.cbDone();
          }
        }
      }
      $.each(params.urls, function(idx, val) {
        self.getUrlRelation({
          cb: localCb,
          res: val,
          rel: params.rel
        });
      });
    }
  };

  /**
    * Library initialization.
    */
  function init() {
    // base
    console.debug("Loading MBZ base classes");
    MBZ.Html = new MBZ.impl.Html();
    MBZ.Util = new MBZ.impl.Util();
    MBZ.Util.Mutations = new MBZ.impl.Util.Mutations();
    MBZ.CA = new MBZ.impl.CA();
    MBZ.WS = new MBZ.impl.WS();

    // initialize the following only on MusicBrainz pages
    var pageType = MBZ.Util.getMbzPageType();
    if (pageType.length > 0) {
      // release editor
      if (pageType.indexOf("release") > -1 || pageType.indexOf("edit") > -1) {
        console.debug("Loading MBZ.ReleaseEditor");
        MBZ.ReleaseEditor = new MBZ.impl.ReleaseEditor();
      }

      // bubble editors
      if (pageType.indexOf("edit") > -1 || pageType.indexOf("add") > -1) {
        // track editor only, if we edit releases
        if (pageType.indexOf("release") > -1) {
          console.debug("Loading MBZ.BubbleEditor.TrackArtistCredits");
          MBZ.BubbleEditor.TrackArtistCredits.prototype =
            new MBZ.impl.BubbleEditor();
          MBZ.BubbleEditor.TrackArtistCredits =
            new MBZ.BubbleEditor.TrackArtistCredits();
        }

        // artist editor on artist edit or release types
        if (pageType.indexOf("artist") > -1
            || pageType.indexOf("release") > -1
            || pageType.indexOf("release-group") > -1) {
          console.debug("Loading MBZ.BubbleEditor.ArtistCredits");
          MBZ.BubbleEditor.ArtistCredits.prototype =
              new MBZ.impl.BubbleEditor();
          MBZ.BubbleEditor.ArtistCredits = new MBZ.BubbleEditor.ArtistCredits();
        }
      }

      // tracklist is only available on release pages
      if (pageType.indexOf("release") > -1) {
        console.debug("Loading MBZ.TrackList");
        MBZ.TrackList = new MBZ.impl.TrackList();
      }
    }

    // release MBZ.impl.* classes to garbage collection
    console.debug("Unloading MBZ.impl.*");
    delete MBZ.impl;
  }
  init();

  // Library initialization finished.
  // ============================== On demand classes - created by users =======

  /**
   * Release related functions.
   */
  MBZ.Release = function() {
    var form = $('<form method="post" id="' + MBZ.Release._form.baseName + '-'
      + (MBZ.Release._form.count++) + '" target="_blank" action="'
      + MBZ.Release._form.target + '" acceptCharset="UTF-8"></form>');
    var submitted = false;

    this.data = {
      annotation: '', // content
      artists: [],
      labels: [],
      mediums: [],
      note: '', // content
      packaging: '', // type
      releases: [],
      title: '', // content
      tracks: [],
      urls: [] // [target, type]
    };

    function addField(name, value) {
      name = MBZ.Util.asString(name);
      value = MBZ.Util.asString(value);
      if (name.length > 0 && value.length > 0) {
        form.append($('<input type="hidden" name="' + name + '" value="' + value
          .replace(/&/g, '&amp;')
          .replace(/"/g, '&quot;')
          .replace(/'/g, '&#39;')
          .replace(/</g, '&lt;')
          .replace(/>/g, '&gt;')
        + '"/>'));
      }
    }

    function buildForm(dataSet) {
      if (dataSet.annotation != '') {
        addField('annotation', dataSet.annotation);
      }

      if (dataSet.artists.length > 0) {
        $.each(dataSet.artists, function(idx, val) {
          var prefix = 'artist_credit.names.' + (val.idx || idx);
          addField(prefix + '.name', val.cred);
          addField(prefix + '.mbid', val.id);
          addField(prefix + '.artist.name', val.name);
          addField(prefix + '.join_phrase', val.join);
        });
      }

      if (dataSet.labels.length > 0) {
        $.each(dataSet.labels, function(idx, val) {
          var prefix = 'labels.' + (val.idx || idx);
          addField(prefix + '.mbid', val.id);
          addField(prefix + '.name', val.name);
          addField(prefix + '.catalog_number', val.catNo);
        });
      }

      if (dataSet.note != '') {
        addField('edit_note', dataSet.note);
      }

      if (dataSet.releases.length > 0) {
        $.each(dataSet.releases, function(idx, val) {
          var prefix = 'events.' + (val.idx || idx);
          addField(prefix + '.date.year', val.y);
          addField(prefix + '.date.month', val.m);
          addField(prefix + '.date.day', val.d);
          addField(prefix + '.country', val.cc);
        });
      }

      $.each(dataSet.mediums, function(idx, val) {
        var prefix = 'mediums.' + (val.idx || idx);
        addField(prefix + '.format', val.fmt);
        addField(prefix + '.name', val.name);
      });

      if (dataSet.packaging != '') {
        addField('packaging', dataSet.packaging);
      }

      if (dataSet.title != '') {
        addField('name', dataSet.title);
      }

      $.each(dataSet.tracks, function(idx, val) {
        var prefix = 'mediums.' + val.med + '.track.' + (val.idx || idx);
        addField(prefix + '.name', val.tit);
        addField(prefix + '.number', val.num);
        addField(prefix + '.recording', val.recId);
        addField(prefix + '.length', val.dur);

        if (val.artists) {
          $.each(val.artists, function(aIdx, aVal) {
            var aPrefix = prefix + '.artist_credit.names.' + (aVal.idx || aIdx);
            addField(aPrefix + '.name', aVal.cred);
            addField(aPrefix + '.mbid', aVal.id);
            addField(aPrefix + '.artist.name', aVal.name);
            addField(aPrefix + '.join_phrase', aVal.join);
          });
        }
      });

      if (dataSet.urls.length > 0) {
        $.each(dataSet.urls, function(idx, val) {
          addField('urls.' + idx + '.url', val[0]);
          addField('urls.' + idx + '.link_type', val[1]);
        });
      }
    }

    /**
      * Submit data to musicbrainz.
      */
    this.submitRelease = function() {
      if (!submitted) {
        buildForm(this.data);
        $('body').append(form);
        submitted = true;
      }
      form.submit();
    };
  };

  MBZ.Release._relationCb = function(data) {
    if (!data) {
      return {};
    }
    if (data.relations) {
      var rels = {_res: data.resource};
      $.each(data.relations, function(idx, val) {
        var id = val.release.id;
        var type = val.type;
        if (!rels[id]) {
          rels[id] = [];
        }
        if (rels[id].indexOf(type) == -1) {
          rels[id].push(type);
        }
      });
      return rels;
    }
  };

  MBZ.Release._form = {
    baseName: 'mbAddReleaseForm',
    count: 0,
    target: MBZ.baseUrl + 'release/add'
  };

  /**
    * Lookup a musicbrainz url relation for 'release' type.
    * @params[cb] callback function
    * @params[res] url to lookup
    * @params[scope] scope for callback function
    */
  MBZ.Release.getUrlRelation = function(params) {
    function innerCb(cbData) {
      if (params.scope) {
        params.cb.call(params.scope, MBZ.Release._relationCb(cbData));
      } else {
        params.cb(MBZ.Release._relationCb(cbData));
      }
    }
    MBZ.WS.getUrlRelation({
      cb: innerCb,
      res: params.res,
      rel: 'release',
      scope: params.scope
    });
  };

  /**
    * Lookup musicbrainz url relations for 'release' type.
    * @params[urls] array of urls to lookup
    * @params[cb] callback function for each response
    * @params[cbInc] callback for each item looked up
    * @params[cbDone] callback to call if all items have been looked up
    * @params[scope] scope for callback functions
    */
  MBZ.Release.getUrlRelations = function(params) {
    function innerCb(cbData) {
      if (params.scope) {
        params.cb.call(params.scope, MBZ.Release._relationCb(cbData));
      } else {
        params.cb(MBZ.Release._relationCb(cbData));
      }
    }
    MBZ.WS.getUrlRelations({
      urls: params.urls,
      rel: 'release',
      cb: innerCb,
      cbInc: params.cbInc,
      cbDone: params.cbDone,
      scope: params.scope
    });
  };

  /**
    * Insert a link, if a release has MusicBrainz relations.
    * @data key=mbid value=string array: relation types
    * @target target jQuery element to append (optional) or
    * this.mbLinkTarget set in scope
    */
  MBZ.Release.insertMBLink = function(data, target) {
    if (data) {
      var self = this;
      target = target || self.mbLinkTarget;
      if (!target) {
        return;
      }
      $.each(data, function(k, v) {
        if (!k.startsWith('_')) { // skip internal data
          var relLink = MBZ.Html.getLinkElement({
            type: 'release',
            id: k,
            title: "Linked as: " + v.toString(),
            before: '&nbsp;'
          });
          target.after(relLink);
          var editLink = MBZ.Html.getLinkElement({
            type: 'release',
            id: k,
            more: 'edit',
            text: 'edit',
            title: 'Edit release',
            before: ', ',
            icon: false
          });
          var artLinkTitle = 'set';
          $.ajax({
            url: MBZ.CA.getLink({
              type: 'release',
              id: k,
              more: 'front'
            })
          }).success(function(){
            artLinkTitle = 'edit';
          }).always(function() {
            var artLink = MBZ.Html.getLinkElement({
              type: 'release',
              id: k,
              more: 'cover-art',
              text: artLinkTitle + ' art',
              title: artLinkTitle + ' cover art for release',
              before: ', ',
              icon: false
            });
            relLink.after('<sup> ' + v.length + editLink.html()
              + artLink.html() + '</sup>');
          });
        }
      });
    }
  };

  MBZ.Release.prototype = {
    /**
      * Add an artist entry.
      * @params plain artist name as string or object:
      *   params[cred] artist name as credited
      *   params[id] artists mbid
      *   params[idx] position
      *   params[join] phrase to join with next artist
      *   params[name] artist name
      */
    addArtist: function(params) {
      if (typeof params === 'string') {
        this.data.artists.push({name: params});
      } else {
        this.data.artists.push(params);
      }
    },

    /**
      * Add a label entry.
      * @params plain label name as string or object.
      *   params[catNo] catalog number
      *   params[id] mbid
      *   params[idx] position
      *   params[name] label name
      */
    addLabel: function(params) {
      if (typeof params === 'string') {
        this.data.labels.push({name: params});
      } else {
        this.data.labels.push(params);
      }
    },

    /**
      * Set format of a medium.
      * @params[idx] position
      * @params[fmt] format type name
      * @params[name] name
      */
    addMedium: function(params) {
      this.data.mediums.push(params)
    },

    /**
      * Add a release event.
      * @params[y] YYYY
      * @params[m] MM
      * @params[d] DD
      * @params[cc] country code
      * @params[idx] position
      */
    addRelease: function(params) {
      this.data.releases.push(params);
    },

    /**
      * Add a track.
      * @params[med] medium number
      * @params[tit] track name
      * @params[idx] track number
      * @params[num] track number (free-form)
      * @params[dur] length in MM:SS or milliseconds
      * @params[recId] mbid of existing recording to associate
      * @params[artists] array of objects:
      *   obj[cred] artist name as credited
      *   obj[id] artists mbid
      *   obj[idx] position
      *   obj[join] phrase to join with next artist
      *   obj[name] artist name
      */
    addTrack: function(params) {
      this.data.tracks.push(params);
    },

    /**
      * @url target url
      * @type musicbrainz url type
      * @return true if value was added
      */
    addUrl: function(url, type) {
      url = MBZ.Util.asString(url);
      type = MBZ.Util.asString(type);

      this.data.urls.push([url, type]);
      return true;
    },

    /**
      * Dump current data (best viewed in FireBug).
      */
    dump: function() {
      console.log(this.data);
    },

    /**
      * @content annotation content
      * @return old value
      */
    setAnnotation: function(content) {
      var old = this.data.annotation;
      this.data.annotation = MBZ.Util.asString(content);
      return old;
    },

    /**
      * @content edeting note content
      * @return old value
      */
    setNote: function(content) {
      var old = this.data.note;
      this.data.note = MBZ.Util.asString(content);
      return old;
    },

    /**
      * @content packaging type
      * @return old value
      */
    setPackaging: function(type) {
      var old = this.data.packaging;
      this.data.packaging = MBZ.Util.asString(type);
      return old;
    },

    /**
      * @name release title
      * @return old value
      */
    setTitle: function(name) {
      var old = this.data.title;
      this.data.title = MBZ.Util.asString(name);
      return old;
    },
  };

  $(window).on('MBZLoadingLibrary', function(e, ts){
    if (ts.id == thisScript.id && ts.version == thisScript.version
        && typeof ts.loader === 'function') {
      ts.loader(MBZ);
    }
  });
}