MusicBrainz function library

Musicbrainz function library. Requires jQuery to run.

Fra og med 13.10.2014. Se den nyeste version.

Dette script bør ikke installeres direkte. Det er et bibliotek, som andre scripts kan inkludere med metadirektivet // @require https://update.greatest.deepsurf.us/scripts/5140/21079/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.2.1beta
//
// @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 = {};

MBZ.baseUrl = 'https://musicbrainz.org/';
MBZ.iconUrl = MBZ.baseUrl + 'favicon.ico',

MBZ.Html = function() {
  this.globStyle = null;
  this.mbzIcon = '<img src="' + MBZ.iconUrl + '" />';

  /**
    * 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);
  };

  /**
    * 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
    */
  this.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
    */
  this.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;
  };

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

  init.call(this);
};
MBZ.Html = new MBZ.Html();

MBZ.Util = {
  /**
   * 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;
  },

  getLastPathSegment: function(str) {
    if (!str || typeof str !== 'string' || str.indexOf('/') == -1) {
      return str;
    }
    var seg = str.split('/');
    return seg[seg.length -1];
  },

  /**
   * 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;
    }
  },

  /**
    * Convert milliseconds to HH:MM:SS.ss string.
    * https://coderwall.com/p/wkdefg
    */
  msToHms: function (ms) {
    str = MBZ.Util.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.Util.Mutations = {
  /**
    * 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.BubbleEditor = function() {};
MBZ.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
          $(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 stagedLoading = false;
    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;
          if (stagedLoading) {
            // switch to real bubble element from container
            console.debug("StagedLoading: switching observer to bubble", bubble);
            observer.disconnect();
            observer.observe(bubble.get(0), {
              childList: true,
              subtree: true
            });
          }
          hasAppeared();
        }
      }
    };

    function hasAppeared() {
      // call onAppear callbacks
      while (onAppearCb.length > 0) {
        onAppearCb.pop().cb(that._bubble.el);
      }
    };

    function init() {
      var bubble = $(that._bubble.id);
      var e;
      if (bubble && bubble.length ==1) {
        that._bubble.el = bubble;
        e = bubble.get(0);
        hasAppeared();
      } else if (that._bubble.containerId) {
        stagedLoading = true;
        e = $(that._bubble.containerId).get(0);
      } else {
        console.debug("Bubble not found and no container specified. Giving up.");
        noBubble = true;
        return;
      }
      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;
    };

    init.call(this);
  }
};

/**
  * Differenciate types of bubble editors.
  */
MBZ.BubbleEditor.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',
    containerId: '#release-editor',
    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 $();
    }
  };

  this._bubble.observer = new this.Observer(this);
};
MBZ.BubbleEditor.ArtistCredits.prototype = new MBZ.BubbleEditor();
MBZ.BubbleEditor.ArtistCredits = new MBZ.BubbleEditor.ArtistCredits();

/**
  * 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.BubbleEditor.TrackArtistCredits.prototype = new MBZ.BubbleEditor();
MBZ.BubbleEditor.TrackArtistCredits = new MBZ.BubbleEditor.TrackArtistCredits();

/**
  * Release tracklist.
  */
MBZ.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();
  }
};
MBZ.TrackList = new MBZ.TrackList();

/**
  * Cover art archive.
  */
MBZ.CA = {
  baseUrl: 'https://coverartarchive.org/',
  // no https here (bad_cert notice)
  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.WS = {
  _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
      });
    });
  }
};

/**
 * 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>');

  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() {
    buildForm(this.data);
    $('body').append(form);
    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;
  },
};