MusicBrainz function library

Musicbrainz function library. Requires jQuery to run.

目前为 2014-10-17 提交的版本。查看 最新版本

此脚本不应直接安装,它是一个供其他脚本使用的外部库。如果您需要使用该库,请在脚本元属性加入:// @require https://update.cn-greasyfork.org/scripts/5140/21630/MusicBrainz%20function%20library.js

  1. // ==UserScript==
  2. // @name MusicBrainz function library
  3. // @namespace http://www.jens-bertram.net/userscripts/mbz-lib
  4. // @description Musicbrainz function library. Requires jQuery to run.
  5. // @supportURL https://github.com/JensBee/userscripts
  6. // @icon https://wiki.musicbrainz.org/-/images/3/39/MusicBrainz_Logo_Square_Transparent.png
  7. // @license MIT
  8. // @version 0.3beta
  9. //
  10. // @grant none
  11. // ==/UserScript==
  12. // Function library to work with MusicBrainz pages.
  13. // Please beware that this library is not meant for public use. It may change
  14. // between versions in any incompatible way. If you make use of this library you
  15. // may want to fork it or use a service like greasyfork which is able to point
  16. // to a specific version of this library.
  17. MBZ = null;
  18.  
  19. /**
  20. * Event callback for re-using library.
  21. * @lib Library passed in from callback.
  22. */
  23. var loader = function(lib) {
  24. MBZ = lib;
  25. };
  26.  
  27. /**
  28. * Library specification for re-using.
  29. */
  30. var thisScript = {
  31. id: 'mbz-lib',
  32. version: '0.3beta',
  33. loader: loader
  34. };
  35.  
  36. // trigger load event
  37. $(window).trigger('MBZLoadingLibrary', thisScript);
  38.  
  39. // reuse existing library, if already set by callback
  40. if (MBZ) {
  41. console.log("Reusing library", MBZ);
  42. } else {
  43. // we have to wrap this in the else statement, because GreasyMonkey does not
  44. // like a return statement in top-level code
  45.  
  46. MBZ = {
  47. baseUrl: 'https://musicbrainz.org/',
  48. impl: {} // concrete implementations, unloaded after initialization
  49. };
  50. MBZ.iconUrl = MBZ.baseUrl + 'favicon.ico',
  51.  
  52. MBZ.impl.Html = function() {
  53. this.globStyle = null;
  54.  
  55. /**
  56. * Add CSS entry to pages <head/>.
  57. * @param style definition to add
  58. */
  59. function init() {
  60. if ($('head').length == 0) {
  61. $('body').append($('<head>'));
  62. }
  63. this.globStyle = $('head>style');
  64. if (this.globStyle.length == 0) {
  65. this.globStyle = $('<style>');
  66. this.globStyle.attr('type', 'text/css');
  67. $('head').append(this.globStyle);
  68. }
  69. this.globStyle.append(''
  70. + 'button.mbzButton{'
  71. + 'cursor:pointer;'
  72. + 'text-decoration:none;'
  73. + 'text-shadow:-1px -1px 0 rgba(255,201,97,0.3);'
  74. + 'font-weight:bold;'
  75. + 'color:#000;'
  76. + 'padding:5px 5px 5px 25px;'
  77. + 'border-radius:5px;'
  78. + 'border-top:1px solid #736CAE;'
  79. + 'border-left:1px solid #736CAE;'
  80. + 'border-bottom:1px solid #FFC961;'
  81. + 'border-right:1px solid #FFC961;'
  82. + 'background:#FFE3B0 url("' + MBZ.iconUrl + '") no-repeat 5px center;'
  83. + '}'
  84. + 'button.mbzButton:hover{'
  85. + 'border:1px solid #454074;'
  86. + 'background-color:#FFD88C;'
  87. + '}'
  88. + 'button.mbzButton:disabled{'
  89. + 'cursor:default;'
  90. + 'border:1px solid #ccc;'
  91. + 'background-color:#ccc;'
  92. + 'color:#5a5a5a;'
  93. + '}'
  94. + 'div#mbzDialog{'
  95. + 'margin:0.5em 0.5em 0.5em 0;'
  96. + 'padding:0.5em;'
  97. + 'background-color:#FFE3B0;'
  98. + 'border-top:1px solid #736CAE;'
  99. + 'border-left:1px solid #736CAE;'
  100. + 'border-bottom:1px solid #FFC961;'
  101. + 'border-right:1px solid #FFC961;'
  102. + '}'
  103. );
  104. };
  105.  
  106. /**
  107. * Add some CSS to the global page style.
  108. * @style CSS to add
  109. */
  110. this.addStyle = function(style) {
  111. this.globStyle.append(style);
  112. };
  113.  
  114. // constructor
  115. init.call(this);
  116. };
  117. MBZ.impl.Html.prototype = {
  118. mbzIcon: '<img src="' + MBZ.iconUrl + '" />',
  119.  
  120. /**
  121. * Create a MusicBrainz link.
  122. * @params[type] type to link to (e.g. release)
  123. * @params[id] mbid to link to (optional)
  124. * @params[more] stuff to add after mbid + '/' (optional)
  125. * @return plain link text
  126. */
  127. getLink: function (params) {
  128. return MBZ.baseUrl + params.type + '/'
  129. + (params.id ? params.id + '/' : '') + (params.more || '');
  130. },
  131.  
  132. /**
  133. * Create a MusicBrainz link.
  134. * @params[type] type to link to (e.g. release)
  135. * @params[id] mbid to link to (optional)
  136. * @params[more] stuff to add after mbid + '/' (optional)
  137. * @params[title] link title attribute (optional)
  138. * @params[text] link text (optional)
  139. * @params[before] stuff to put before link (optional)
  140. * @params[after] stuff to put after link (optional)
  141. * @params[icon] true/false: include MusicBrainz icon (optional,
  142. * default: true)
  143. * @return link jQuery object
  144. */
  145. getLinkElement: function (params) {
  146. params.icon = (typeof params.icon !== 'undefined'
  147. && params.icon == false ? false : true);
  148. var retEl = $('<div style="display:inline-block;">');
  149. if (params.before) {
  150. retEl.append(params.before);
  151. }
  152. var linkEl = $('<a>' + (params.icon ? this.mbzIcon : '')
  153. + (params.text || '') + '</a>');
  154. linkEl.attr('href', this.getLink({
  155. type: params.type,
  156. id: params.id,
  157. more: params.more
  158. })).attr('target', '_blank');
  159. if (params.title) {
  160. linkEl.attr('title', params.title);
  161. }
  162. retEl.append(linkEl);
  163. if (params.after) {
  164. retEl.append(params.after);
  165. }
  166. return retEl;
  167. },
  168.  
  169. getMbzButton: function(caption, title) {
  170. var btn = $('<button type="button" class="mbzButton">' + caption
  171. + '</button>');
  172. if (title) {
  173. btn.attr('title', title);
  174. }
  175. return btn;
  176. }
  177. };
  178.  
  179. /**
  180. * Utility functions.
  181. */
  182. MBZ.impl.Util = function() {};
  183. MBZ.impl.Util.prototype = {
  184. /**
  185. * Convert anything to string.
  186. * @data object
  187. */
  188. asString: function (data) {
  189. if (data == null) {
  190. return '';
  191. }
  192. switch (typeof data) {
  193. case 'string':
  194. return data.trim();
  195. case 'object':
  196. return data.toString().trim();
  197. case 'function':
  198. return 'function';
  199. case 'undefined':
  200. return '';
  201. default:
  202. data = data + '';
  203. return data.trim();
  204. }
  205. },
  206.  
  207. /**
  208. * Creates http + https url from a given https? url.
  209. * @url http/https url
  210. * @return array with given url prefixed with http + https or single url,
  211. * if not https? protocol
  212. */
  213. expandProtocol: function(url) {
  214. var urls;
  215. if (url.toLowerCase().startsWith('http')) {
  216. var urlPath = url.replace(/^https?:\/\//,'');
  217. urls = ['http://' + urlPath, 'https://' + urlPath];
  218. } else {
  219. urls = [url];
  220. }
  221. return urls;
  222. },
  223.  
  224. /**
  225. * Creates http + https urls from a given array of https? urls.
  226. * @urls array of http/https urls
  227. * @return array with given urls prefixed with http + https
  228. */
  229. expandProtocols: function(urls) {
  230. var newUrls = [];
  231. var self = this;
  232. $.each(urls, function(idx, val){
  233. newUrls = newUrls.concat(self.expandProtocol(val));
  234. });
  235. return newUrls;
  236. },
  237.  
  238. /**
  239. * Get the last path segment from a URL.
  240. */
  241. getLastPathSegment: function(str) {
  242. if (!str || typeof str !== 'string' || str.indexOf('/') == -1) {
  243. return str;
  244. }
  245. var seg = str.split('/');
  246. return seg[seg.length -1];
  247. },
  248.  
  249. /**
  250. * Detect the MusicBrainz page we're on.
  251. */
  252. getMbzPageType: function() {
  253. var type = [];
  254. if (this.isMbzPage()) {
  255. var path = window.location.pathname;
  256. if (path.contains("/artist/")) {
  257. type.push("artist");
  258. } else if (path.contains("/recording/")) {
  259. type.push("recording");
  260. } else if (path.contains("/release/")) {
  261. type.push("release");
  262. } else if (path.contains('/release-group/')) {
  263. type.push("release-group");
  264. }
  265. var lps = this.getLastPathSegment(path);
  266. // exclude id strings
  267. if (!lps.match(/^[0-9a-f]+-[0-9a-f]+-[0-9a-f]+-[0-9a-f]+-[0-9a-f]+$/)) {
  268. type.push(lps);
  269. }
  270. }
  271. return type;
  272. },
  273.  
  274. /**
  275. * Convert HH:MM:SS, MM:SS, SS to seconds.
  276. * http://stackoverflow.com/a/9640417
  277. * @str string
  278. * @return seconds extracted from initial string
  279. */
  280. hmsToSeconds: function (str) {
  281. str = MBZ.Util.asString(str);
  282. if (str.indexOf(':') > -1) {
  283. var p = str.split(':'), s = 0, m = 1;
  284.  
  285. while (p.length > 0) {
  286. s += m * parseInt(p.pop(), 10);
  287. m *= 60;
  288. }
  289.  
  290. return s;
  291. } else {
  292. return str;
  293. }
  294. },
  295.  
  296. /**
  297. * Check, if we're on a musicbrainz page.
  298. * @return true if so
  299. */
  300. isMbzPage: function() {
  301. if (window.location.hostname.contains('musicbrainz.org')
  302. || window.location.hostname.contains('mbsandbox.org')) {
  303. return true;
  304. }
  305. return false;
  306. },
  307.  
  308. /**
  309. * Convert milliseconds to HH:MM:SS.ss string.
  310. * https://coderwall.com/p/wkdefg
  311. */
  312. msToHms: function (ms) {
  313. str = this.asString(ms);
  314. if (str.match(/^[0-9]+$/)) {
  315. var milliseconds = parseInt((ms % 1000) / 100)
  316. , seconds = parseInt((ms / 1000) % 60)
  317. , minutes = parseInt((ms / (1000 * 60)) % 60)
  318. , hours = parseInt((ms / (1000 * 60 * 60)) % 24);
  319.  
  320. hours = (hours < 10) ? "0" + hours : hours;
  321. minutes = (minutes < 10) ? "0" + minutes : minutes;
  322. seconds = (seconds < 10) ? "0" + seconds : seconds;
  323.  
  324. return (hours && hours != '00' ? (hours + ":") : '') + minutes + ":"
  325. + seconds + (milliseconds ? ("." + milliseconds) : '');
  326. } else {
  327. return ms;
  328. }
  329. },
  330.  
  331. /**
  332. * Remove a trailing slash from a string
  333. * @str string
  334. * @return intial string with trailing slash removed
  335. */
  336. rmTrSlash: function (str) {
  337. if(str.substr(-1) == '/') {
  338. return str.substr(0, str.length - 1);
  339. }
  340. return str;
  341. }
  342. };
  343.  
  344. /**
  345. * Util functions to work with results from MutationObservers.
  346. */
  347. MBZ.impl.Util.Mutations = function() {};
  348. MBZ.impl.Util.Mutations.prototype = {
  349. /**
  350. * Checks mutation records if an element with a given tagName was added.
  351. * If callback function returns true, no further elements will be checked.
  352. * @mutationRecords mutation records passed by an observer
  353. * @tName tagname to check for (case is ignored)
  354. * @cb callback function
  355. * @scope optionl scope for callback
  356. * @return if callback returned true, false otherwise
  357. */
  358. forAddedTagName: function(mutationRecords, tName, cb, scope) {
  359. if (!mutationRecords || !cb || !tName || tName.trim().length == 0) {
  360. return false;
  361. }
  362. tName = tName.toLowerCase();
  363. return mutationRecords.some(function(mutationRecord){
  364. for (let node of mutationRecord.addedNodes) {
  365. if (node.tagName && node.tagName.toLowerCase() == tName) {
  366. var ret;
  367. if (scope) {
  368. ret = cb.call(scope, node);
  369. } else {
  370. ret = cb(node);
  371. }
  372. if (ret == true) {
  373. return ret;
  374. }
  375. }
  376. };
  377. });
  378. }
  379. };
  380.  
  381. /**
  382. * Shared bubble editor functions.
  383. */
  384. MBZ.impl.BubbleEditor = function() {};
  385. MBZ.impl.BubbleEditor.prototype = {
  386. /**
  387. * Add an artist credit.
  388. * Must be called in scope.
  389. * @bubble bubble element
  390. * @data String or array with 1-3 elements. [mb-artist name, artist as
  391. * credited, join phrase]
  392. * @noAc if true, displaying the autocomplete popup will be disabled
  393. */
  394. addArtist: function(data, noAc) {
  395. if (typeof data === 'string') {
  396. data = [data];
  397. }
  398. if (data && data.length > 0) {
  399. var rows = this.getCreditRows();
  400. if (rows.length > 0) {
  401. var targets = this.getCreditInputs(rows.get(rows.length -1));
  402. // check, if row is all empty..
  403. if (targets[0].val() != '' || targets[1].val() != ''
  404. || targets[2].val() != '') {
  405. // ..if not, add one row and re-set target
  406. if (targets[2].val().trim() == '') {
  407. // at least in track bubble adding a new artist is not possible
  408. // without a join-phrase - so add one
  409. targets[2].val(" & ");
  410. targets[2].trigger('change');
  411. }
  412. $(this.getBubble().find('.add-item').get(0)).click();
  413. rows = this.getCreditRows();
  414. targets = this.getCreditInputs(rows.get(rows.length -1));
  415. }
  416. if (noAc) {
  417. targets[0].autocomplete({disabled: true});
  418. }
  419.  
  420. targets[0].val(data[0]);
  421.  
  422. if (data.length > 1) {
  423. targets[1].val(data[1]);
  424. } else {
  425. targets[1].val(data[0]);
  426. }
  427. if (data.length > 2) {
  428. targets[2].val(data[2]);
  429. }
  430. targets[0].trigger('input');
  431.  
  432. if (noAc) {
  433. targets[0].autocomplete({disabled: false});
  434. }
  435. }
  436. }
  437. },
  438.  
  439. /**
  440. * Get all mb-artist credits currently listed in the bubble editor.
  441. * Must be called in scope.
  442. * @return array with artist names
  443. */
  444. getArtistCredits: function() {
  445. var rows = this.getCreditRows();
  446. var artists = [];
  447.  
  448. if (rows.length > 0) {
  449. var self = this;
  450.  
  451. $.each(rows, function() {
  452. var row = $(this);
  453. var inputs = self.getCreditInputs(row);
  454. if (inputs[0]) {
  455. artists.push(inputs[0].val());
  456. }
  457. });
  458. }
  459.  
  460. return artists;
  461. },
  462.  
  463. /**
  464. * See Observer.addAppearCb.
  465. */
  466. onAppear: function(params) {
  467. return this._bubble.observer.addAppearCb(params);
  468. },
  469.  
  470. /**
  471. * See Observer.addChangedCb.
  472. */
  473. onContentChange: function(params) {
  474. return this._bubble.observer.addChangedCb(params);
  475. },
  476.  
  477. /**
  478. * Remove a complete artist credit by it's row.
  479. * @row artists data row
  480. */
  481. removeArtist: function(row) {
  482. if (row) {
  483. // may be <button/> or <input/> - so check attribute only
  484. $(row).find('.remove-artist-credit').click();
  485. }
  486. },
  487.  
  488. /**
  489. * Get a new array with artists removed already present in bubble editor.
  490. * Checks are done against the mb artist name. Check is done by using
  491. * all lower case letters.
  492. * Must be called in scope.
  493. * @artists Array of artist names
  494. */
  495. removePresentArtists: function(artists) {
  496. var rows = this.getCreditRows();
  497. var newArtists = [];
  498.  
  499. var presentArtists = this.getArtistCredits();
  500.  
  501. if (rows.length > 0) {
  502. var presentArtists = [];
  503. var self = this;
  504.  
  505. $.each(rows, function() {
  506. var row = $(this);
  507. var inputs = self.getCreditInputs(row);
  508. if (inputs[0]) {
  509. presentArtists.push(inputs[0].val().toLowerCase());
  510. }
  511. });
  512.  
  513. // sort out new ones
  514. for (let artist of artists) {
  515. if (presentArtists.indexOf(artist.toLowerCase()) == -1) {
  516. newArtists.push(artist);
  517. }
  518. }
  519. }
  520.  
  521. return newArtists;
  522. },
  523.  
  524. /**
  525. * Tries to open the bubble by clicking the given handler.
  526. * @bubble bubble element
  527. * @handler handler to click
  528. */
  529. tryOpen: function(handler) {
  530. var bubble = this.getBubble();
  531. if (bubble && !bubble.is(':visible')) {
  532. handler.click();
  533. }
  534. },
  535.  
  536. /**
  537. * Bubble observer class.
  538. * @instance Bubble class instance.
  539. * @ids[bubble] Id of bubble element
  540. * @ids[container] For two-stage loading: container that will contain the
  541. * bubble (optional)
  542. */
  543. Observer: function(instance) {
  544. var observer = null;
  545. var disconnected = false;
  546. var onAppearCb = [];
  547. var onChangeCb = [];
  548. var that = instance;
  549. var stagedLoading = false;
  550. var noBubble = false;
  551.  
  552. function mutated(mutationRecords) {
  553. if (that._bubble.el) {
  554. // remove observer, if noone is listening
  555. if (onChangeCb.length == 0) {
  556. console.debug("Remove bubble observer - noone listening.");
  557. observer.disconnect();
  558. disconnected = true;
  559. } else {
  560. for (let cbParams of onChangeCb) {
  561. cbParams.cb(that._bubble.el, mutationRecords);
  562. }
  563. }
  564. } else {
  565. var bubble = $(that._bubble.id);
  566. if (bubble && bubble.length ==1) {
  567. that._bubble.el = bubble;
  568. if (stagedLoading) {
  569. // switch to real bubble element from container
  570. console.debug("StagedLoading: switching observer to bubble",
  571. bubble);
  572. observer.disconnect();
  573. observer.observe(bubble.get(0), {
  574. childList: true,
  575. subtree: true
  576. });
  577. }
  578. hasAppeared();
  579. }
  580. }
  581. };
  582.  
  583. function hasAppeared() {
  584. // call onAppear callbacks
  585. while (onAppearCb.length > 0) {
  586. onAppearCb.pop().cb(that._bubble.el);
  587. }
  588. };
  589.  
  590. function init() {
  591. var bubble = $(that._bubble.id);
  592. var e;
  593. if (bubble && bubble.length ==1) {
  594. that._bubble.el = bubble;
  595. e = bubble.get(0);
  596. hasAppeared();
  597. } else if (that._bubble.containerId) {
  598. stagedLoading = true;
  599. e = $(that._bubble.containerId).get(0);
  600. }
  601.  
  602. if (!e) {
  603. console.debug(that.type,
  604. "Bubble not found and no container specified. Giving up.");
  605. noBubble = true;
  606. } else {
  607. observer = new MutationObserver(mutated);
  608. observer.observe(e, {
  609. childList: true,
  610. subtree: true
  611. });
  612. }
  613. };
  614.  
  615. function reAttach() {
  616. if (disconnected) {
  617. console.debug("Re-attach bubble observer - new listener.");
  618. observer.observe(that._bubble.el.get(0), {
  619. childList: true,
  620. subtree: true
  621. });
  622. }
  623. };
  624.  
  625. /**
  626. * Add a listener to listen to appearance of the bubble. Callback is
  627. * called directly, if bubble is already present.
  628. * @cb[cb] callcack function
  629. * @return true, if added or called immediately, false, if there's no
  630. * bubble to attach to
  631. */
  632. this.addAppearCb = function(cb) {
  633. if (noBubble) {
  634. console.debug("Not attaching to event. No bubble.");
  635. return false;
  636. }
  637. if (that._bubble.el) {
  638. // direct call, bubble already there
  639. cb.cb(that._bubble.el);
  640. } else {
  641. // add to stack
  642. onAppearCb.push(cb);
  643. }
  644. return true;
  645. };
  646.  
  647. /**
  648. * Add a listener to listen to changes to the bubble.
  649. * @cb[cb] callcack function
  650. * @return true, if added, false, if there's no bubble to attach to
  651. */
  652. this.addChangedCb = function(cb) {
  653. if (noBubble) {
  654. console.debug("Not attaching to event. No bubble.");
  655. return false;
  656. }
  657. reAttach();
  658. onChangeCb.push(cb);
  659. return false;
  660. };
  661.  
  662. // constructor
  663. init.call(this);
  664. }
  665. };
  666.  
  667. /**
  668. * Bubble editors base class.
  669. */
  670. MBZ.BubbleEditor = {
  671. /**
  672. * Differenciate types of bubble editors.
  673. */
  674. types: {
  675. artistCredits: 'ArtistCreditBubble',
  676. trackArtistCredits: 'TrackArtistCreditBubble'
  677. }
  678. };
  679.  
  680. /**
  681. * Artists credits bubble.
  682. */
  683. MBZ.BubbleEditor.ArtistCredits = function() {
  684. this.type = MBZ.BubbleEditor.types.artistCredits;
  685. this._bubble = {
  686. el: null,
  687. id: '#artist-credit-bubble',
  688. containerId: '#release-editor',
  689. observer: null
  690. };
  691.  
  692. /**
  693. * Get the bubble element.
  694. */
  695. this.getBubble = function() {
  696. return this._bubble.el;
  697. };
  698.  
  699. /**
  700. * Extract the inputs for mb-artist, credited-artist and join-phrase from a
  701. * single data row.
  702. * @row data row
  703. * @return array with input elements for mb-artist, credited-artist and
  704. * join-phrase from a single data row.
  705. */
  706. this.getCreditInputs = function(row) {
  707. if (!row || (row.length && row.length == 0)) {
  708. console.debug("Empty row.");
  709. return [];
  710. }
  711. row = $(row);
  712.  
  713. var rowData = [];
  714. var el = row.find('input[type="text"]'); // mb-artist
  715.  
  716. if (el.length == 1) {
  717. rowData.push(el);
  718. el = row.next().find('input[type="text"]'); // artist as credited
  719. if (el.length == 1) {
  720. rowData.push(el);
  721. el = row.next().next().find('input[type="text"]'); // join phrase
  722. if (el.length == 1) {
  723. rowData.push(el);
  724. return rowData;
  725. }
  726. }
  727. }
  728. return [];
  729. };
  730.  
  731. /**
  732. * Get the rows containing inputs for mb-artist, credited-artist and
  733. * join-phrase from the bubble.
  734. * @return jQuery object containing each data row. This is for each entry
  735. * the first row containing the mb-artist name.
  736. */
  737. this.getCreditRows = function() {
  738. if (this._bubble.el) {
  739. return this._bubble.el.find('tr:has(input.name)');
  740. } else {
  741. console.debug("No rows found. Bubble not present.");
  742. return $();
  743. }
  744. };
  745.  
  746. this._bubble.observer = new this.Observer(this);
  747. };
  748.  
  749. /**
  750. * Track artists credits bubble.
  751. */
  752. MBZ.BubbleEditor.TrackArtistCredits = function() {
  753. this.type = MBZ.BubbleEditor.types.trackArtistCredits;
  754. this._bubble = {
  755. el: null,
  756. id: '#track-ac-bubble',
  757. observer: null
  758. };
  759.  
  760. /**
  761. * Get the bubble element.
  762. */
  763. this.getBubble = function() {
  764. return this._bubble.el;
  765. };
  766.  
  767. /**
  768. * Get the rows containing inputs for mb-artist, credited-artist and
  769. * join-phrase from the bubble.
  770. * @return jQuery object containing each data row
  771. */
  772. this.getCreditRows = function() {
  773. if (this._bubble.el) {
  774. return this._bubble.el.find('tr:has(td span.artist)');
  775. } else {
  776. console.debg("No rows found. Bubble not present.");
  777. return $();
  778. }
  779. };
  780.  
  781. /**
  782. * Extract the inputs for mb-artist, credited-artist and join-phrase from a
  783. * single data row.
  784. * @row data row
  785. * @return array with input elements for mb-artist, credited-artist and
  786. * join-phrase from a single data row.
  787. */
  788. this.getCreditInputs = function(row) {
  789. if (!row) {
  790. console.debug("Empty row.");
  791. return [];
  792. }
  793.  
  794. var inputs = $(row).find('td input[type="text"]');
  795. if (inputs.length == 3) {
  796. return [
  797. $(inputs.get(0)), // mb-artist
  798. $(inputs.get(1)), // artist as credited
  799. $(inputs.get(2)) // join-phrase
  800. ];
  801. } else {
  802. return [];
  803. }
  804. };
  805.  
  806. this._bubble.observer = new this.Observer(this);
  807. };
  808.  
  809. /**
  810. * Release tracklist.
  811. */
  812. MBZ.impl.TrackList = function() {
  813. var observer;
  814. var id = '#tracklist';
  815.  
  816. var Observer = function() {
  817. var observer;
  818. var onChangeCb = [];
  819.  
  820. function attach() {
  821. console.debug("Creating tracklist observer - new listener.");
  822. observer = new MutationObserver(mutated);
  823. observer.observe($(id).get(0), {
  824. childList: true,
  825. subtree: true
  826. });
  827. };
  828.  
  829. function mutated(mutationRecords) {
  830. var element = $(id);
  831. for (cb of onChangeCb) {
  832. cb.cb(element, mutationRecords);
  833. }
  834. };
  835.  
  836. /**
  837. * Add a listener to listen to changes to the bubble.
  838. * @cb[cb] callcack function
  839. */
  840. this.addChangedCb = function(cb) {
  841. if (!observer) {
  842. attach();
  843. }
  844. onChangeCb.push(cb);
  845. };
  846. };
  847.  
  848. this.getList = function() {
  849. return $(id);
  850. };
  851.  
  852. this.onContentChange = function(params) {
  853. if (!observer) {
  854. console.debug("Not attaching to event. No tracklist.");
  855. return false;
  856. }
  857. return observer.addChangedCb(params);
  858. };
  859.  
  860. if ($(id).length == 1) {
  861. observer = new Observer();
  862. }
  863. };
  864.  
  865. /**
  866. * Cover art archive.
  867. */
  868. MBZ.impl.CA = function() {};
  869. MBZ.impl.CA.prototype = {
  870. baseUrl: 'https://coverartarchive.org/',
  871. originBaseUrl: 'https://cors-anywhere.herokuapp.com/coverartarchive.org:443/',
  872.  
  873. /**
  874. * Create a CoverArtArchive link.
  875. * @params[type] type to link to (e.g. release)
  876. * @params[id] mbid to link to (optional)
  877. * @params[more] stuff to add after mbid (optional)
  878. */
  879. getLink: function (params) {
  880. return this.originBaseUrl + params.type + '/'
  881. + (params.id ? params.id + '/' : '') + (params.more || '');
  882. }
  883. };
  884.  
  885. /**
  886. * MusicBrainz web service v2 interface.
  887. */
  888. MBZ.impl.WS = function() {};
  889. MBZ.impl.WS.prototype = {
  890. _baseUrl: MBZ.baseUrl + 'ws/2/',
  891. _queue: [],
  892. _pollFreq: 1100,
  893. _pollInterval: null,
  894.  
  895. /**
  896. * Add to request queue.
  897. * @params[cb] callback
  898. * @params[url] request url
  899. * @params[args] callback function parameters object
  900. * @params[scope] scope for calling callback function
  901. */
  902. _qAdd: function(params) {
  903. this._queue.push(params);
  904. if (!this._pollInterval) {
  905. if (this._queue.length == 1) {
  906. this._qPoll();
  907. }
  908. this._pollInterval = setInterval(this._qPoll, this._pollFreq);
  909. }
  910. },
  911.  
  912. /**
  913. * Execute queued requests.
  914. */
  915. _qPoll: function() {
  916. if (MBZ.WS._queue.length > 0) {
  917. var item = MBZ.WS._queue.pop();
  918. $.getJSON(item.url, function(data) {
  919. if (item.args) {
  920. if (item.scope) {
  921. item.cb.call(item.scope, data, item.args);
  922. } else {
  923. item.cb(data, item.args);
  924. }
  925. } else {
  926. if (item.scope) {
  927. item.cb.call(item.scope, data);
  928. } else {
  929. item.cb(data);
  930. }
  931. }
  932. }).fail(function(jqxhr, textStatus, error) {
  933. var err = textStatus + ', ' + error;
  934. console.error("Request (" + item.url + ") failed: " + err);
  935. if (item.scope) {
  936. item.cb.call(item.scope);
  937. } else {
  938. item.cb();
  939. }
  940. });
  941. } else if (MBZ.WS._queue.length == 0 && MBZ.WS._pollInterval) {
  942. clearInterval(MBZ.WS._pollInterval);
  943. }
  944. },
  945.  
  946. /**
  947. * Lookup a musicbrainz url relation
  948. * @params[cb] callback function
  949. * @params[res] url to lookup
  950. * @params[rel] relation type
  951. * @params[scope] scope for callback function
  952. */
  953. getUrlRelation: function (params) {
  954. this._qAdd({
  955. cb: params.cb,
  956. url: this._baseUrl + 'url?resource=' + encodeURIComponent(params.res)
  957. + '&inc=' + params.rel + '-rels',
  958. scope: params.scope
  959. });
  960. },
  961.  
  962. /**
  963. * Lookup musicbrainz url relations
  964. * @params[urls] array of urls to lookup
  965. * @params[rel] relation type
  966. * @params[cb] callback function for each response
  967. * @params[cbInc] callback for each item looked up
  968. * @params[cbDone] callback to call if all items have been looked up
  969. * @params[scope] scope for callback functions
  970. */
  971. getUrlRelations: function(params) {
  972. var self = this;
  973. var count = params.urls.length;
  974. var current = 0;
  975. function localCb(data) {
  976. if (params.scope) {
  977. params.cb.call(params.scope, data);
  978. } else {
  979. params.cb(data);
  980. }
  981. if (typeof params.cbInc === 'function') {
  982. if (params.scope) {
  983. params.cbInc.call(params.scope);
  984. } else {
  985. params.cbInc();
  986. }
  987. }
  988. if (++current == count && typeof params.cbDone === 'function') {
  989. if (params.scope) {
  990. params.cbDone.call(params.scope);
  991. } else {
  992. params.cbDone();
  993. }
  994. }
  995. }
  996. $.each(params.urls, function(idx, val) {
  997. self.getUrlRelation({
  998. cb: localCb,
  999. res: val,
  1000. rel: params.rel
  1001. });
  1002. });
  1003. }
  1004. };
  1005.  
  1006. /**
  1007. * Library initialization.
  1008. */
  1009. function init() {
  1010. // base
  1011. console.debug("Loading MBZ base classes");
  1012. MBZ.Html = new MBZ.impl.Html();
  1013. MBZ.Util = new MBZ.impl.Util();
  1014. MBZ.Util.Mutations = new MBZ.impl.Util.Mutations();
  1015. MBZ.CA = new MBZ.impl.CA();
  1016. MBZ.WS = new MBZ.impl.WS();
  1017.  
  1018. // initialize the following only on MusicBrainz pages
  1019. var pageType = MBZ.Util.getMbzPageType();
  1020. if (pageType.length > 0) {
  1021. // bubble editors
  1022. if (pageType.indexOf("edit") > -1 || pageType.indexOf("add") > -1) {
  1023. // track editor only, if we edit releases
  1024. if (pageType.indexOf("release") > -1) {
  1025. console.debug("Loading MBZ.BubbleEditor.TrackArtistCredits");
  1026. MBZ.BubbleEditor.TrackArtistCredits.prototype =
  1027. new MBZ.impl.BubbleEditor();
  1028. MBZ.BubbleEditor.TrackArtistCredits =
  1029. new MBZ.BubbleEditor.TrackArtistCredits();
  1030. }
  1031.  
  1032. // artist editor on artist edit or release types
  1033. if (pageType.indexOf("artist") > -1
  1034. || pageType.indexOf("release") > -1
  1035. || pageType.indexOf("release-group") > -1) {
  1036. console.debug("Loading MBZ.BubbleEditor.ArtistCredits");
  1037. MBZ.BubbleEditor.ArtistCredits.prototype =
  1038. new MBZ.impl.BubbleEditor();
  1039. MBZ.BubbleEditor.ArtistCredits = new MBZ.BubbleEditor.ArtistCredits();
  1040. }
  1041. }
  1042.  
  1043. // tracklist is only available on release pages
  1044. if (pageType.indexOf("release") > -1) {
  1045. console.debug("Loading MBZ.TrackList");
  1046. MBZ.TrackList = new MBZ.impl.TrackList();
  1047. }
  1048. }
  1049.  
  1050. // release MBZ.impl.* classes to garbage collection
  1051. console.debug("Unloading MBZ.impl.*");
  1052. delete MBZ.impl;
  1053. }
  1054. init();
  1055.  
  1056. // Library initialization finished.
  1057. // ============================== On demand classes - created by users =======
  1058.  
  1059. /**
  1060. * Release related functions.
  1061. */
  1062. MBZ.Release = function() {
  1063. var form = $('<form method="post" id="' + MBZ.Release._form.baseName + '-'
  1064. + (MBZ.Release._form.count++) + '" target="_blank" action="'
  1065. + MBZ.Release._form.target + '" acceptCharset="UTF-8"></form>');
  1066.  
  1067. this.data = {
  1068. annotation: '', // content
  1069. artists: [],
  1070. labels: [],
  1071. mediums: [],
  1072. note: '', // content
  1073. packaging: '', // type
  1074. releases: [],
  1075. title: '', // content
  1076. tracks: [],
  1077. urls: [] // [target, type]
  1078. };
  1079.  
  1080. function addField(name, value) {
  1081. name = MBZ.Util.asString(name);
  1082. value = MBZ.Util.asString(value);
  1083. if (name.length > 0 && value.length > 0) {
  1084. form.append($('<input type="hidden" name="' + name + '" value="' + value
  1085. .replace(/&/g, '&amp;')
  1086. .replace(/"/g, '&quot;')
  1087. .replace(/'/g, '&#39;')
  1088. .replace(/</g, '&lt;')
  1089. .replace(/>/g, '&gt;')
  1090. + '"/>'));
  1091. }
  1092. }
  1093.  
  1094. function buildForm(dataSet) {
  1095. if (dataSet.annotation != '') {
  1096. addField('annotation', dataSet.annotation);
  1097. }
  1098.  
  1099. if (dataSet.artists.length > 0) {
  1100. $.each(dataSet.artists, function(idx, val) {
  1101. var prefix = 'artist_credit.names.' + (val.idx || idx);
  1102. addField(prefix + '.name', val.cred);
  1103. addField(prefix + '.mbid', val.id);
  1104. addField(prefix + '.artist.name', val.name);
  1105. addField(prefix + '.join_phrase', val.join);
  1106. });
  1107. }
  1108.  
  1109. if (dataSet.labels.length > 0) {
  1110. $.each(dataSet.labels, function(idx, val) {
  1111. var prefix = 'labels.' + (val.idx || idx);
  1112. addField(prefix + '.mbid', val.id);
  1113. addField(prefix + '.name', val.name);
  1114. addField(prefix + '.catalog_number', val.catNo);
  1115. });
  1116. }
  1117.  
  1118. if (dataSet.note != '') {
  1119. addField('edit_note', dataSet.note);
  1120. }
  1121.  
  1122. if (dataSet.releases.length > 0) {
  1123. $.each(dataSet.releases, function(idx, val) {
  1124. var prefix = 'events.' + (val.idx || idx);
  1125. addField(prefix + '.date.year', val.y);
  1126. addField(prefix + '.date.month', val.m);
  1127. addField(prefix + '.date.day', val.d);
  1128. addField(prefix + '.country', val.cc);
  1129. });
  1130. }
  1131.  
  1132. $.each(dataSet.mediums, function(idx, val) {
  1133. var prefix = 'mediums.' + (val.idx || idx);
  1134. addField(prefix + '.format', val.fmt);
  1135. addField(prefix + '.name', val.name);
  1136. });
  1137.  
  1138. if (dataSet.packaging != '') {
  1139. addField('packaging', dataSet.packaging);
  1140. }
  1141.  
  1142. if (dataSet.title != '') {
  1143. addField('name', dataSet.title);
  1144. }
  1145.  
  1146. $.each(dataSet.tracks, function(idx, val) {
  1147. var prefix = 'mediums.' + val.med + '.track.' + (val.idx || idx);
  1148. addField(prefix + '.name', val.tit);
  1149. addField(prefix + '.number', val.num);
  1150. addField(prefix + '.recording', val.recId);
  1151. addField(prefix + '.length', val.dur);
  1152.  
  1153. if (val.artists) {
  1154. $.each(val.artists, function(aIdx, aVal) {
  1155. var aPrefix = prefix + '.artist_credit.names.' + (aVal.idx || aIdx);
  1156. addField(aPrefix + '.name', aVal.cred);
  1157. addField(aPrefix + '.mbid', aVal.id);
  1158. addField(aPrefix + '.artist.name', aVal.name);
  1159. addField(aPrefix + '.join_phrase', aVal.join);
  1160. });
  1161. }
  1162. });
  1163.  
  1164. if (dataSet.urls.length > 0) {
  1165. $.each(dataSet.urls, function(idx, val) {
  1166. addField('urls.' + idx + '.url', val[0]);
  1167. addField('urls.' + idx + '.link_type', val[1]);
  1168. });
  1169. }
  1170. }
  1171.  
  1172. /**
  1173. * Submit data to musicbrainz.
  1174. */
  1175. this.submitRelease = function() {
  1176. buildForm(this.data);
  1177. $('body').append(form);
  1178. form.submit();
  1179. };
  1180. };
  1181.  
  1182. MBZ.Release._relationCb = function(data) {
  1183. if (!data) {
  1184. return {};
  1185. }
  1186. if (data.relations) {
  1187. var rels = {_res: data.resource};
  1188. $.each(data.relations, function(idx, val) {
  1189. var id = val.release.id;
  1190. var type = val.type;
  1191. if (!rels[id]) {
  1192. rels[id] = [];
  1193. }
  1194. if (rels[id].indexOf(type) == -1) {
  1195. rels[id].push(type);
  1196. }
  1197. });
  1198. return rels;
  1199. }
  1200. };
  1201.  
  1202. MBZ.Release._form = {
  1203. baseName: 'mbAddReleaseForm',
  1204. count: 0,
  1205. target: MBZ.baseUrl + 'release/add'
  1206. };
  1207.  
  1208. /**
  1209. * Lookup a musicbrainz url relation for 'release' type.
  1210. * @params[cb] callback function
  1211. * @params[res] url to lookup
  1212. * @params[scope] scope for callback function
  1213. */
  1214. MBZ.Release.getUrlRelation = function(params) {
  1215. function innerCb(cbData) {
  1216. if (params.scope) {
  1217. params.cb.call(params.scope, MBZ.Release._relationCb(cbData));
  1218. } else {
  1219. params.cb(MBZ.Release._relationCb(cbData));
  1220. }
  1221. }
  1222. MBZ.WS.getUrlRelation({
  1223. cb: innerCb,
  1224. res: params.res,
  1225. rel: 'release',
  1226. scope: params.scope
  1227. });
  1228. };
  1229.  
  1230. /**
  1231. * Lookup musicbrainz url relations for 'release' type.
  1232. * @params[urls] array of urls to lookup
  1233. * @params[cb] callback function for each response
  1234. * @params[cbInc] callback for each item looked up
  1235. * @params[cbDone] callback to call if all items have been looked up
  1236. * @params[scope] scope for callback functions
  1237. */
  1238. MBZ.Release.getUrlRelations = function(params) {
  1239. function innerCb(cbData) {
  1240. if (params.scope) {
  1241. params.cb.call(params.scope, MBZ.Release._relationCb(cbData));
  1242. } else {
  1243. params.cb(MBZ.Release._relationCb(cbData));
  1244. }
  1245. }
  1246. MBZ.WS.getUrlRelations({
  1247. urls: params.urls,
  1248. rel: 'release',
  1249. cb: innerCb,
  1250. cbInc: params.cbInc,
  1251. cbDone: params.cbDone,
  1252. scope: params.scope
  1253. });
  1254. };
  1255.  
  1256. /**
  1257. * Insert a link, if a release has MusicBrainz relations.
  1258. * @data key=mbid value=string array: relation types
  1259. * @target target jQuery element to append (optional) or
  1260. * this.mbLinkTarget set in scope
  1261. */
  1262. MBZ.Release.insertMBLink = function(data, target) {
  1263. if (data) {
  1264. var self = this;
  1265. target = target || self.mbLinkTarget;
  1266. if (!target) {
  1267. return;
  1268. }
  1269. $.each(data, function(k, v) {
  1270. if (!k.startsWith('_')) { // skip internal data
  1271. var relLink = MBZ.Html.getLinkElement({
  1272. type: 'release',
  1273. id: k,
  1274. title: "Linked as: " + v.toString(),
  1275. before: '&nbsp;'
  1276. });
  1277. target.after(relLink);
  1278. var editLink = MBZ.Html.getLinkElement({
  1279. type: 'release',
  1280. id: k,
  1281. more: 'edit',
  1282. text: 'edit',
  1283. title: 'Edit release',
  1284. before: ', ',
  1285. icon: false
  1286. });
  1287. var artLinkTitle = 'set';
  1288. $.ajax({
  1289. url: MBZ.CA.getLink({
  1290. type: 'release',
  1291. id: k,
  1292. more: 'front'
  1293. })
  1294. }).success(function(){
  1295. artLinkTitle = 'edit';
  1296. }).always(function() {
  1297. var artLink = MBZ.Html.getLinkElement({
  1298. type: 'release',
  1299. id: k,
  1300. more: 'cover-art',
  1301. text: artLinkTitle + ' art',
  1302. title: artLinkTitle + ' cover art for release',
  1303. before: ', ',
  1304. icon: false
  1305. });
  1306. relLink.after('<sup> ' + v.length + editLink.html()
  1307. + artLink.html() + '</sup>');
  1308. });
  1309. }
  1310. });
  1311. }
  1312. };
  1313.  
  1314. MBZ.Release.prototype = {
  1315. /**
  1316. * Add an artist entry.
  1317. * @params plain artist name as string or object:
  1318. * params[cred] artist name as credited
  1319. * params[id] artists mbid
  1320. * params[idx] position
  1321. * params[join] phrase to join with next artist
  1322. * params[name] artist name
  1323. */
  1324. addArtist: function(params) {
  1325. if (typeof params === 'string') {
  1326. this.data.artists.push({name: params});
  1327. } else {
  1328. this.data.artists.push(params);
  1329. }
  1330. },
  1331.  
  1332. /**
  1333. * Add a label entry.
  1334. * @params plain label name as string or object.
  1335. * params[catNo] catalog number
  1336. * params[id] mbid
  1337. * params[idx] position
  1338. * params[name] label name
  1339. */
  1340. addLabel: function(params) {
  1341. if (typeof params === 'string') {
  1342. this.data.labels.push({name: params});
  1343. } else {
  1344. this.data.labels.push(params);
  1345. }
  1346. },
  1347.  
  1348. /**
  1349. * Set format of a medium.
  1350. * @params[idx] position
  1351. * @params[fmt] format type name
  1352. * @params[name] name
  1353. */
  1354. addMedium: function(params) {
  1355. this.data.mediums.push(params)
  1356. },
  1357.  
  1358. /**
  1359. * Add a release event.
  1360. * @params[y] YYYY
  1361. * @params[m] MM
  1362. * @params[d] DD
  1363. * @params[cc] country code
  1364. * @params[idx] position
  1365. */
  1366. addRelease: function(params) {
  1367. this.data.releases.push(params);
  1368. },
  1369.  
  1370. /**
  1371. * Add a track.
  1372. * @params[med] medium number
  1373. * @params[tit] track name
  1374. * @params[idx] track number
  1375. * @params[num] track number (free-form)
  1376. * @params[dur] length in MM:SS or milliseconds
  1377. * @params[recId] mbid of existing recording to associate
  1378. * @params[artists] array of objects:
  1379. * obj[cred] artist name as credited
  1380. * obj[id] artists mbid
  1381. * obj[idx] position
  1382. * obj[join] phrase to join with next artist
  1383. * obj[name] artist name
  1384. */
  1385. addTrack: function(params) {
  1386. this.data.tracks.push(params);
  1387. },
  1388.  
  1389. /**
  1390. * @url target url
  1391. * @type musicbrainz url type
  1392. * @return true if value was added
  1393. */
  1394. addUrl: function(url, type) {
  1395. url = MBZ.Util.asString(url);
  1396. type = MBZ.Util.asString(type);
  1397.  
  1398. this.data.urls.push([url, type]);
  1399. return true;
  1400. },
  1401.  
  1402. /**
  1403. * Dump current data (best viewed in FireBug).
  1404. */
  1405. dump: function() {
  1406. console.log(this.data);
  1407. },
  1408.  
  1409. /**
  1410. * @content annotation content
  1411. * @return old value
  1412. */
  1413. setAnnotation: function(content) {
  1414. var old = this.data.annotation;
  1415. this.data.annotation = MBZ.Util.asString(content);
  1416. return old;
  1417. },
  1418.  
  1419. /**
  1420. * @content edeting note content
  1421. * @return old value
  1422. */
  1423. setNote: function(content) {
  1424. var old = this.data.note;
  1425. this.data.note = MBZ.Util.asString(content);
  1426. return old;
  1427. },
  1428.  
  1429. /**
  1430. * @content packaging type
  1431. * @return old value
  1432. */
  1433. setPackaging: function(type) {
  1434. var old = this.data.packaging;
  1435. this.data.packaging = MBZ.Util.asString(type);
  1436. return old;
  1437. },
  1438.  
  1439. /**
  1440. * @name release title
  1441. * @return old value
  1442. */
  1443. setTitle: function(name) {
  1444. var old = this.data.title;
  1445. this.data.title = MBZ.Util.asString(name);
  1446. return old;
  1447. },
  1448. };
  1449.  
  1450. $(window).on('MBZLoadingLibrary', function(e, ts){
  1451. if (ts.id == thisScript.id && ts.version == thisScript.version
  1452. && typeof ts.loader === 'function') {
  1453. ts.loader(MBZ);
  1454. }
  1455. });
  1456. }