MusicBrainz function library

Musicbrainz function library. Requires jQuery to run.

Skrip ini tidak untuk dipasang secara langsung. Ini adalah pustaka skrip lain untuk disertakan dengan direktif meta // @require https://update.greasyfork.org/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);
    }
  });
}