Greasy Fork is available in English.

Last.fm Bulk Edit

Bulk edit your scrobbles for any artist or album on Last.fm at once.

作者のサイトでサポートを受ける。または、このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください。
// ==UserScript==
// @name Last.fm Bulk Edit
// @description Bulk edit your scrobbles for any artist or album on Last.fm at once.
// @version 1.4.1
// @author Rudey
// @homepage https://github.com/RudeySH/lastfm-bulk-edit
// @supportURL https://github.com/RudeySH/lastfm-bulk-edit/issues
// @match https://www.last.fm/*
// @icon https://raw.githubusercontent.com/RudeySH/lastfm-bulk-edit/main/img/icon.png
// @license AGPL-3.0-or-later
// @namespace https://github.com/RudeySH/lastfm-bulk-edit
// @require https://cdnjs.cloudflare.com/ajax/libs/he/1.2.0/he.min.js
// ==/UserScript==

/******/ (() => { // webpackBootstrap
/******/ 	var __webpack_modules__ = ({

/***/ 433:
/***/ ((module) => {

async function* asyncPool(concurrency, iterable, iteratorFn) {
  const executing = new Set();
  async function consume() {
    const [promise, value] = await Promise.race(executing);
    executing.delete(promise);
    return value;
  }
  for (const item of iterable) {
    // Wrap iteratorFn() in an async fn to ensure we get a promise.
    // Then expose such promise, so it's possible to later reference and
    // remove it from the executing pool.
    const promise = (async () => await iteratorFn(item, iterable))().then(
      value => [promise, value]
    );
    executing.add(promise);
    if (executing.size >= concurrency) {
      yield await consume();
    }
  }
  while (executing.size) {
    yield await consume();
  }
}

module.exports = asyncPool;


/***/ })

/******/ 	});
/************************************************************************/
/******/ 	// The module cache
/******/ 	var __webpack_module_cache__ = {};
/******/ 	
/******/ 	// The require function
/******/ 	function __webpack_require__(moduleId) {
/******/ 		// Check if module is in cache
/******/ 		var cachedModule = __webpack_module_cache__[moduleId];
/******/ 		if (cachedModule !== undefined) {
/******/ 			return cachedModule.exports;
/******/ 		}
/******/ 		// Create a new module (and put it into the cache)
/******/ 		var module = __webpack_module_cache__[moduleId] = {
/******/ 			// no module.id needed
/******/ 			// no module.loaded needed
/******/ 			exports: {}
/******/ 		};
/******/ 	
/******/ 		// Execute the module function
/******/ 		__webpack_modules__[moduleId](module, module.exports, __webpack_require__);
/******/ 	
/******/ 		// Return the exports of the module
/******/ 		return module.exports;
/******/ 	}
/******/ 	
/************************************************************************/
/******/ 	/* webpack/runtime/compat get default export */
/******/ 	(() => {
/******/ 		// getDefaultExport function for compatibility with non-harmony modules
/******/ 		__webpack_require__.n = (module) => {
/******/ 			var getter = module && module.__esModule ?
/******/ 				() => (module['default']) :
/******/ 				() => (module);
/******/ 			__webpack_require__.d(getter, { a: getter });
/******/ 			return getter;
/******/ 		};
/******/ 	})();
/******/ 	
/******/ 	/* webpack/runtime/define property getters */
/******/ 	(() => {
/******/ 		// define getter functions for harmony exports
/******/ 		__webpack_require__.d = (exports, definition) => {
/******/ 			for(var key in definition) {
/******/ 				if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
/******/ 					Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
/******/ 				}
/******/ 			}
/******/ 		};
/******/ 	})();
/******/ 	
/******/ 	/* webpack/runtime/hasOwnProperty shorthand */
/******/ 	(() => {
/******/ 		__webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))
/******/ 	})();
/******/ 	
/************************************************************************/
var __webpack_exports__ = {};
// This entry need to be wrapped in an IIFE because it need to be in strict mode.
(() => {
"use strict";

;// CONCATENATED MODULE: external "he"
const external_he_namespaceObject = he;
var external_he_default = /*#__PURE__*/__webpack_require__.n(external_he_namespaceObject);
;// CONCATENATED MODULE: ./src/features/display-album-name.ts
async function displayAlbumName(element) {
    var _a;
    const rows = element instanceof HTMLTableRowElement ? [element] : [...element.querySelectorAll('tr')];
    const baseHref = document.querySelector('.secondary-nav-item--overview a').getAttribute('href');
    for (const row of rows) {
        if (row.getAttribute('data-edit-scrobble-id') === null || row.querySelector('.chartlist-album') !== null) {
            continue;
        }
        const coverArtAnchor = row.querySelector('.cover-art');
        const albumHref = coverArtAnchor.getAttribute('href');
        const form = row.querySelector('form[data-edit-scrobble]:not([data-edit-scrobbles])');
        let albumName;
        if (form !== null) {
            const formData = new FormData(form);
            albumName = (_a = formData.get('album_name')) === null || _a === void 0 ? void 0 : _a.toString();
        }
        else {
            albumName = coverArtAnchor.querySelector('img').alt;
        }
        // Create and insert th element.
        const table = row.closest('table');
        if (!table.classList.contains('lastfm-bulk-edit-chartlist-scrobbles')) {
            table.classList.add('lastfm-bulk-edit-chartlist-scrobbles');
            const albumHeaderCell = document.createElement('th');
            albumHeaderCell.textContent = 'Album';
            const headerRow = table.tHead.rows[0];
            headerRow.insertBefore(albumHeaderCell, headerRow.children[4]);
        }
        // Create and insert td element.
        const albumCell = document.createElement('td');
        albumCell.className = 'chartlist-album';
        if (albumName) {
            const albumAnchor = document.createElement('a');
            albumAnchor.href = albumHref;
            albumAnchor.title = albumName;
            albumAnchor.textContent = albumName;
            albumCell.appendChild(albumAnchor);
        }
        else {
            const noAlbumText = document.createElement('em');
            noAlbumText.className = 'lastfm-bulk-edit-text-danger';
            noAlbumText.textContent = 'No Album';
            albumCell.appendChild(noAlbumText);
        }
        const nameCell = row.querySelector('.chartlist-name');
        row.insertBefore(albumCell, nameCell.nextElementSibling);
        // Add menu items.
        if (albumName) {
            const menu = row.querySelector('.chartlist-more-menu');
            const albumMenuItem1 = document.createElement('li');
            const menuItemAnchor1 = document.createElement('a');
            menuItemAnchor1.href = albumHref;
            menuItemAnchor1.className = 'dropdown-menu-clickable-item more-item--album';
            menuItemAnchor1.textContent = 'Go to album';
            albumMenuItem1.appendChild(menuItemAnchor1);
            const albumMenuItem2 = document.createElement('li');
            const menuItemAnchor2 = document.createElement('a');
            menuItemAnchor2.href = baseHref + '/library' + albumHref;
            menuItemAnchor2.className = 'dropdown-menu-clickable-item more-item--album';
            menuItemAnchor2.textContent = 'Go to album in library';
            albumMenuItem2.appendChild(menuItemAnchor2);
            const artistMenuItem = menu.querySelector('.more-item--artist').parentNode;
            menu.insertBefore(albumMenuItem1, artistMenuItem);
            menu.insertBefore(albumMenuItem2, artistMenuItem);
        }
    }
}

// EXTERNAL MODULE: ./node_modules/tiny-async-pool/lib/es9.js
var es9 = __webpack_require__(433);
var es9_default = /*#__PURE__*/__webpack_require__.n(es9);
;// CONCATENATED MODULE: ./src/features/enhance-automatic-edits-page.ts

const viewAllButtonTemplate = document.createElement('template');
viewAllButtonTemplate.innerHTML = `
    <button type="button" class="btn-primary" disabled>
        View All At Once
    </button>`;
const domParser = new DOMParser();
let loadPagesPromise = undefined;
let loadPagesProgressElement = undefined;
async function enhanceAutomaticEditsPage(element) {
    if (!document.URL.startsWith('https://www.last.fm/settings/subscription/automatic-edits')) {
        return;
    }
    const section = element.querySelector('#subscription-corrections');
    const table = section === null || section === void 0 ? void 0 : section.querySelector('table');
    if (!section || !table) {
        return;
    }
    const viewAllButton = viewAllButtonTemplate.content.firstElementChild.cloneNode(true);
    section.insertBefore(viewAllButton, section.firstElementChild);
    enhanceTable(table);
    const paginationList = section === null || section === void 0 ? void 0 : section.querySelector('.pagination-list');
    if (!paginationList) {
        return;
    }
    const paginationListItems = [...paginationList.querySelectorAll('.pagination-page')];
    const currentPageNumber = parseInt(paginationListItems.find(x => x.getAttribute('aria-current') === 'page').textContent, 10);
    const pageCount = parseInt(paginationListItems[paginationListItems.length - 1].textContent, 10);
    if (pageCount === 1) {
        return;
    }
    loadPagesProgressElement = document.createElement('span');
    viewAllButton.insertAdjacentElement('afterend', loadPagesProgressElement);
    viewAllButton.insertAdjacentText('afterend', ' ');
    loadPagesPromise !== null && loadPagesPromise !== void 0 ? loadPagesPromise : (loadPagesPromise = loadPages(table, currentPageNumber, pageCount));
    const pages = await loadPagesPromise;
    section.removeChild(loadPagesProgressElement);
    const artistMap = new Map();
    for (const page of pages) {
        for (const row of page.rows) {
            const formData = getFormData(row);
            const artistName = formData.get('artist_name_original').toString();
            const key = artistName.toLowerCase();
            if (!artistMap.has(key)) {
                artistMap.set(key, { artistName, pageNumber: page.pageNumber });
            }
        }
    }
    const artistSelect = document.createElement('select');
    for (const value of artistMap.values()) {
        const option = document.createElement('option');
        option.text = value.artistName;
        option.value = value.pageNumber.toString();
        artistSelect.appendChild(option);
    }
    artistSelect.selectedIndex = -1;
    artistSelect.addEventListener('change', () => {
        const pageNumber = parseInt(artistSelect.value);
        const anchor = document.createElement('a');
        anchor.href = '/settings/subscription/automatic-edits?page=' + pageNumber;
        document.body.appendChild(anchor);
        anchor.click();
        document.body.removeChild(anchor);
    });
    viewAllButton.insertAdjacentElement('afterend', artistSelect);
    viewAllButton.insertAdjacentText('afterend', ' Go to artist: ');
    viewAllButton.disabled = false;
    viewAllButton.addEventListener('click', async () => {
        if (pages.length >= 100 && !window.confirm(`You are about to view ${pages.length} pages at once. This might take a long time to load. Are you sure?`)) {
            return;
        }
        viewAllButton.disabled = true;
        table.style.tableLayout = 'fixed';
        const tableBody = table.tBodies[0];
        const firstRow = tableBody.rows[0];
        for (const page of pages) {
            if (page.pageNumber === currentPageNumber) {
                continue;
            }
            for (const row of page.rows) {
                enhanceRow(row);
                if (page.pageNumber < currentPageNumber) {
                    firstRow.insertAdjacentElement('beforebegin', row);
                }
                else {
                    tableBody.appendChild(row);
                }
            }
            if (page.pageNumber % 10 === 0) {
                await delay(1);
            }
        }
    });
}
function enhanceTable(table) {
    document.body.style.backgroundColor = '#fff';
    table.style.tableLayout = 'auto';
    const headerRow = table.tHead.rows[0];
    const body = table.tBodies[0];
    let sortedCellIndex = 1;
    const keys = [
        'track_name_original',
        'artist_name_original',
        'album_name_original',
        'album_artist_name_original',
    ];
    for (let i = 0; i < 4; i++) {
        const key = keys[i];
        const cell = headerRow.cells[i];
        cell.innerHTML = `<a href="javascript:void(0)" role="button">${cell.textContent}</a>`;
        cell.addEventListener('click', () => {
            const dir = sortedCellIndex === i ? -1 : 1;
            sortedCellIndex = sortedCellIndex === i ? -1 : i;
            const rows = [...body.rows].map(row => {
                let value = row.dataset[key];
                if (!value) {
                    value = row.querySelector(`input[name="${key}"]`).value;
                    row.dataset[key] = value;
                }
                return { row, value };
            });
            rows.sort((a, b) => a.value.localeCompare(b.value) * dir);
            for (const row of rows) {
                body.appendChild(row.row);
            }
        });
    }
    for (const row of body.rows) {
        enhanceRow(row);
    }
}
function enhanceRow(row) {
    if (row.dataset['enhanced'] === 'true') {
        return;
    }
    row.dataset['enhanced'] = 'true';
    const formData = getFormData(row);
    const trackName = formData.get('track_name').toString();
    const artistName = formData.get('artist_name').toString();
    const albumName = formData.get('album_name').toString();
    const albumArtistName = formData.get('album_artist_name').toString();
    function emphasize(cell, content) {
        var _a;
        cell.style.lineHeight = '1';
        cell.innerHTML = `
            <div>
                <span class="sr-only">
                    ${cell.textContent}
                </span>
                <b>
                    ${content}
                </b>
            </div>
            <small>
                Originally "${(_a = cell.textContent) === null || _a === void 0 ? void 0 : _a.trim()}"
            </small>`;
    }
    if (trackName !== formData.get('track_name_original')) {
        emphasize(row.cells[0], trackName);
    }
    else {
        // remove bold
        row.cells[0].innerHTML = row.cells[0].textContent;
    }
    if (artistName !== formData.get('artist_name_original')) {
        emphasize(row.cells[1], artistName);
    }
    if (albumName !== formData.get('album_name_original')) {
        emphasize(row.cells[2], albumName);
    }
    if (albumArtistName !== formData.get('album_artist_name_original')) {
        emphasize(row.cells[3], albumArtistName);
    }
}
function getFormData(row) {
    return new FormData(row.querySelector('form'));
}
async function loadPages(table, currentPageNumber, pageCount) {
    const pages = [{ pageNumber: currentPageNumber, rows: [...table.tBodies[0].rows] }];
    const pageNumbersToLoad = [...Array(pageCount).keys()].map(i => i + 1).filter(i => i !== currentPageNumber);
    updateProgress(1, pageCount);
    for await (const page of es9_default()(6, pageNumbersToLoad, loadPage)) {
        pages.push(page);
        updateProgress(pages.length, pageCount);
    }
    pages.sort((a, b) => a.pageNumber < b.pageNumber ? -1 : 1);
    return pages;
}
async function loadPage(pageNumber) {
    const response = await fetch(`/settings/subscription/automatic-edits?page=${pageNumber}&_pjax=%23content`, {
        credentials: 'include',
        headers: {
            'X-Pjax': 'true',
            'X-Pjax-Container': '#content',
        },
    });
    const text = await response.text();
    const doc = domParser.parseFromString(text, 'text/html');
    const table = doc.querySelector('.chart-table');
    return {
        pageNumber,
        rows: [...table.tBodies[0].rows],
    };
}
function updateProgress(current, total) {
    loadPagesProgressElement.textContent = `${current} / ${total} (${(current * 100 / total).toFixed(0)}%)`;
}
function delay(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
}

;// CONCATENATED MODULE: ./src/index.ts



const namespace = 'lastfm-bulk-edit';
// use the top-right link to determine the current user
const authLink = document.querySelector('a.auth-link');
// https://regex101.com/r/UCmC8f/1
const albumRegExp = new RegExp(`^${authLink === null || authLink === void 0 ? void 0 : authLink.href}/library/music(/\\+[^/]*)*(/[^+][^/]*){2}$`);
const artistRegExp = new RegExp(`^${authLink === null || authLink === void 0 ? void 0 : authLink.href}/library/music(/\\+[^/]*)*(/[^+][^/]*){1}(/\\+[^/]*)?$`);
const src_domParser = new DOMParser();
const editScrobbleFormTemplate = document.createElement('template');
editScrobbleFormTemplate.innerHTML = `
    <form method="POST" action="${authLink === null || authLink === void 0 ? void 0 : authLink.href}/library/edit?edited-variation=library-track-scrobble" data-edit-scrobble data-edit-scrobbles>
        <input type="hidden" name="csrfmiddlewaretoken" value="">
        <input type="hidden" name="artist_name" value="">
        <input type="hidden" name="track_name" value="">
        <input type="hidden" name="album_name" value="">
        <input type="hidden" name="album_artist_name" value="">
        <input type="hidden" name="timestamp" value="">
        <button type="submit" class="mimic-link dropdown-menu-clickable-item more-item--edit-old" data-analytics-action="EditScrobbleOpen">
            Edit scrobbles
        </button>
    </form>`;
const modalTemplate = document.createElement('template');
modalTemplate.innerHTML = `
    <div class="popup_background"
        style="opacity: 0.8; visibility: visible; background-color: rgb(0, 0, 0); position: fixed; top: 0px; right: 0px; bottom: 0px; left: 0px;">
    </div>
    <div class="popup_wrapper popup_wrapper_visible" style="opacity: 1; visibility: visible; position: fixed; overflow: auto; width: 100%; height: 100%; top: 0px; left: 0px; text-align: center;">
        <div class="modal-dialog popup_content" role="dialog" aria-labelledby="modal-label" data-popup-initialized="true" aria-hidden="false" style="opacity: 1; visibility: visible; pointer-events: auto; display: inline-block; outline: none; text-align: left; position: relative; vertical-align: middle;" tabindex="-1">
            <div class="modal-content">
                <div class="modal-body">
                    <h2 class="modal-title"></h2>
                </div>
            </div>
        </div>
        <div class="popup_align" style="display: inline-block; vertical-align: middle; height: 100%;"></div>
    </div>`;
if (authLink) {
    initialize();
}
function initialize() {
    appendStyle();
    appendEditScrobbleHeaderLinkAndMenuItems(document.body);
    displayAlbumName(document.body);
    enhanceAutomaticEditsPage(document.body);
    // use MutationObserver because Last.fm is a single-page application
    const observer = new MutationObserver((mutations) => {
        for (const mutation of mutations) {
            for (const node of mutation.addedNodes) {
                if (node instanceof Element) {
                    if (node.hasAttribute('data-processed')) {
                        continue;
                    }
                    node.setAttribute('data-processed', 'true');
                    appendEditScrobbleHeaderLinkAndMenuItems(node);
                    displayAlbumName(node);
                    enhanceAutomaticEditsPage(node);
                }
            }
        }
    });
    observer.observe(document.body, {
        childList: true,
        subtree: true,
    });
}
function appendStyle() {
    const style = document.createElement('style');
    style.innerHTML = `
        .${namespace}-abbr {
            cursor: help;
        }

        @media (pointer: coarse), (hover: none) {
            .${namespace}-abbr[title]:focus {
                position: relative;
                display: inline-flex;
                justify-content: center;
            }

            .${namespace}-abbr[title]:focus::after {
                content: attr(title);
                position: absolute;
                top: 100%;
                left: 0%;
                color: #fff;
                background-color: #2b2a32;
                border: 1px solid #fff;
                width: fit-content;
                padding: 4px 7px;
                font-size: small;
                line-height: normal;
                white-space: pre;
                z-index: 1;
            }
        }

        .${namespace}-ellipsis {
            display: block;
            overflow: hidden;
            text-overflow: ellipsis;
            white-space: nowrap;
        }

        .${namespace}-form-group-controls {
            margin-left: 0 !important;
        }

        .${namespace}-list {
            column-count: 2;
        }

        .${namespace}-loading {
            background: url("/static/images/loading_dark_light_64.gif") 50% 50% no-repeat;
            height: 64px;
            display: flex;
            justify-content: center;
            align-items: center;
        }

        .${namespace}-text-danger {
            color: #d92323;
        }

        .${namespace}-text-info {
            color: #2b65d9;
        }

        @media (min-width: 768px) {
            .${namespace}-chartlist-scrobbles .chartlist-name {
                margin-top: -2px;
                margin-bottom: 13px;
            }

            .${namespace}-chartlist-scrobbles .chartlist-album {
                margin-top: 13px;
                margin-bottom: -2px;
                position: absolute;
                left: 133.5px;
                width: 182.41px;
            }

            .${namespace}-chartlist-scrobbles .chartlist-album::before {
                width: 0 !important;
            }
        }

        @media (min-width: 1260px) {
            .${namespace}-chartlist-scrobbles .chartlist-album {
                width: 272.41px;
            }
        }`;
    document.head.appendChild(style);
}
function appendEditScrobbleHeaderLinkAndMenuItems(element) {
    if (!document.URL.startsWith(authLink.href)) {
        return; // current page is not the user's profile
    }
    appendEditScrobbleHeaderLink(element);
    appendEditScrobbleMenuItems(element);
}
function appendEditScrobbleHeaderLink(element) {
    var _a;
    const header = element.querySelector('.library-header');
    if (header === null) {
        return; // current page does not contain the header we're looking for
    }
    const form = getEditScrobbleForm(document.URL);
    const button = form.querySelector('button');
    // replace submit button with a link
    form.style.display = 'inline';
    button.style.display = 'none';
    const link = form.appendChild(document.createElement('a'));
    link.href = 'javascript:void(0)';
    link.textContent = 'Edit scrobbles';
    link.addEventListener('click', () => button.click());
    if (((_a = header.lastElementChild) === null || _a === void 0 ? void 0 : _a.tagName) === 'A') {
        header.insertAdjacentText('beforeend', ' · ');
    }
    header.insertAdjacentElement('beforeend', form);
}
function appendEditScrobbleMenuItems(element) {
    var _a;
    const rows = element instanceof HTMLTableRowElement ? [element] : [...element.querySelectorAll('tr')];
    for (const row of rows) {
        const link = row.querySelector('a.chartlist-count-bar-link,a.more-item--track[href^="/user/"]');
        if (!link) {
            continue; // this is not an artist, album or track
        }
        const form = getEditScrobbleForm(link.href, row);
        const editScrobbleMenuItem = document.createElement('li');
        editScrobbleMenuItem.appendChild(form);
        editScrobbleMenuItem.setAttribute('data-processed', 'true');
        // append new menu item to the DOM
        const menu = row.querySelector('.chartlist-more-menu');
        if ((_a = menu.firstElementChild) === null || _a === void 0 ? void 0 : _a.hasAttribute('data-processed')) {
            menu.removeChild(menu.firstElementChild);
        }
        menu.insertBefore(editScrobbleMenuItem, menu.firstElementChild);
    }
}
function getEditScrobbleForm(url, row) {
    const urlType = getUrlType(url);
    const form = editScrobbleFormTemplate.content.firstElementChild.cloneNode(true);
    const button = form.querySelector('button');
    let allScrobbleData;
    let scrobbleData;
    let submit = false;
    button.addEventListener('click', async (event) => {
        if (!document.querySelector('.header--user .label')) {
            alert('Last.fm pro subscription is required to edit scrobbles.');
        }
        if (!submit) {
            event.stopImmediatePropagation();
            return;
        }
        const loadingModal = createLoadingModal('Waiting for Last.fm...');
        await augmentEditScrobbleForm(urlType, scrobbleData);
        loadingModal.hide();
        submit = false;
    });
    form.addEventListener('submit', async (event) => {
        if (submit) {
            return;
        }
        event.preventDefault();
        event.stopImmediatePropagation();
        if (!allScrobbleData) {
            const loadingModal = createLoadingModal('Loading Scrobbles...', { display: 'percentage' });
            allScrobbleData = await fetchScrobbleData(url, loadingModal, loadingModal);
            loadingModal.hide();
        }
        scrobbleData = allScrobbleData;
        // use JSON strings as album keys to uniquely identify combinations of album + album artists
        // group scrobbles by album key
        let scrobbleDataGroups = [...groupBy(allScrobbleData, (s) => {
                var _a, _b;
                return JSON.stringify({
                    album_name: (_a = s.get('album_name')) !== null && _a !== void 0 ? _a : '',
                    album_artist_name: (_b = s.get('album_artist_name')) !== null && _b !== void 0 ? _b : '',
                });
            })];
        // sort groups by the amount of scrobbles
        scrobbleDataGroups = scrobbleDataGroups.sort(([_key1, values1], [_key2, values2]) => values2.length - values1.length);
        // when editing multiple albums album, show an album selection dialog first
        if (scrobbleDataGroups.length >= 2) {
            const noAlbumKey = JSON.stringify({ album_name: '', album_artist_name: '' });
            let currentAlbumKey = undefined;
            // put the "No Album" album first
            scrobbleDataGroups = scrobbleDataGroups.sort(([key1], [key2]) => {
                if (key1 === noAlbumKey)
                    return -1;
                if (key2 === noAlbumKey)
                    return +1;
                return 0;
            });
            // when the edit dialog was initiated from an album or album track, put that album first in the list
            if (urlType === 'album' || getUrlType(document.URL) === 'album') {
                // grab the current album name and artist name from the DOM
                const album_name = (urlType === 'album' && row
                    ? row.querySelector('.chartlist-name')
                    : document.querySelector('.library-header-title')).textContent.trim();
                const album_artist_name = (urlType === 'album' && row
                    ? row.querySelector('.chartlist-artist') || document.querySelector('.library-header-title, .library-header-crumb')
                    : document.querySelector('.text-colour-link')).textContent.trim();
                currentAlbumKey = JSON.stringify({ album_name, album_artist_name });
                // put the current album first
                scrobbleDataGroups = scrobbleDataGroups.sort(([key1], [key2]) => {
                    if (key1 === currentAlbumKey)
                        return -1;
                    if (key2 === currentAlbumKey)
                        return +1;
                    if (key1 === noAlbumKey)
                        return -1;
                    if (key2 === noAlbumKey)
                        return +1;
                    return 0;
                });
            }
            const body = document.createElement('div');
            body.innerHTML = `
                <div class="form-disclaimer">
                    <div class="alert alert-info">
                        Scrobbles from this ${urlType} are spread out across multiple albums.
                        Select which albums you would like to edit.
                        Deselect albums you would like to skip.
                    </div>
                </div>
                <div class="form-group">
                    <div class="form-group-controls ${namespace}-form-group-controls">
                        <button type="button" class="btn-secondary" id="${namespace}-select-all">Select all</button>
                        <button type="button" class="btn-secondary" id="${namespace}-deselect-all">Deselect all</button>
                    </div>
                </div>
                <ul class="${namespace}-list">
                    ${scrobbleDataGroups.map(([key, scrobbleData]) => {
                var _a;
                const firstScrobbleData = scrobbleData[0];
                const album_name = firstScrobbleData.get('album_name');
                const artist_name = ((_a = firstScrobbleData.get('album_artist_name')) !== null && _a !== void 0 ? _a : firstScrobbleData.get('artist_name'));
                return `
                            <li>
                                <div class="checkbox">
                                    <label>
                                        <input type="checkbox" name="key" value="${external_he_default().escape(key)}" ${currentAlbumKey === undefined || currentAlbumKey === key ? 'checked' : ''} />
                                        <strong title="${external_he_default().escape(album_name !== null && album_name !== void 0 ? album_name : '')}" class="${namespace}-ellipsis ${currentAlbumKey === key ? `${namespace}-text-info` : !album_name ? `${namespace}-text-danger` : ''}">
                                            ${album_name ? external_he_default().escape(album_name) : '<em>No Album</em>'}
                                        </strong>
                                        <div title="${external_he_default().escape(artist_name)}" class="${namespace}-ellipsis">
                                            ${external_he_default().escape(artist_name)}
                                        </div>
                                        <small>
                                            ${scrobbleData.length} scrobble${scrobbleData.length !== 1 ? 's' : ''}
                                        </small>
                                    </label>
                                </div>
                            </li>`;
            }).join('')}
                </ul>`;
            const checkboxes = body.querySelectorAll('input[type="checkbox"]');
            body.querySelector(`#${namespace}-select-all`).addEventListener('click', () => {
                for (const checkbox of checkboxes) {
                    checkbox.checked = true;
                }
            });
            body.querySelector(`#${namespace}-deselect-all`).addEventListener('click', () => {
                for (const checkbox of checkboxes) {
                    checkbox.checked = false;
                }
            });
            let formData;
            try {
                formData = await src_prompt('Select Albums To Edit', body);
            }
            catch (error) {
                return; // user canceled the album selection dialog
            }
            const selectedAlbumKeys = formData.getAll('key');
            scrobbleData = scrobbleDataGroups
                .filter(([key]) => selectedAlbumKeys.includes(key))
                .map(([_, values]) => values)
                .flat();
        }
        if (scrobbleData.length === 0) {
            alert(`Last.fm reports you haven't listened to this ${urlType}.`);
            return;
        }
        // use the first scrobble to trick Last.fm into fetching the Edit Scrobble modal
        applyFormData(form, scrobbleData[0]);
        submit = true;
        button.click();
    });
    return form;
}
// shows a form dialog and resolves its promise on submit
function src_prompt(title, body) {
    return new Promise((resolve, reject) => {
        const form = document.createElement('form');
        form.className = 'form-horizontal';
        if (body instanceof Element) {
            form.insertAdjacentElement('beforeend', body);
        }
        else {
            form.insertAdjacentHTML('beforeend', body);
        }
        form.insertAdjacentHTML('beforeend', `
            <div class="form-group form-group--submit">
                <div class="form-submit">
                    <button type="reset" class="btn-secondary">Cancel</button>
                    <button type="submit" class="btn-primary">
                        <span class="btn-inner">
                            OK
                        </span>
                    </button>
                </div>
            </div>`);
        const content = document.createElement('div');
        content.className = 'content-form';
        content.appendChild(form);
        const modal = new Modal(title, content, {
            dismissible: true,
            events: {
                hide: reject,
            },
        });
        form.addEventListener('reset', () => modal.hide());
        form.addEventListener('submit', (event) => {
            event.preventDefault();
            resolve(new FormData(form));
            modal.hide();
        });
        modal.show();
    });
}
class Modal {
    constructor(title, body, options) {
        this.addedClass = false;
        const fragment = modalTemplate.content.cloneNode(true);
        const modalTitle = fragment.querySelector('.modal-title');
        if (title instanceof Element) {
            modalTitle.insertAdjacentElement('beforeend', title);
        }
        else {
            modalTitle.insertAdjacentHTML('beforeend', title);
        }
        const modalBody = fragment.querySelector('.modal-body');
        if (body instanceof Element) {
            modalBody.insertAdjacentElement('beforeend', body);
        }
        else {
            modalBody.insertAdjacentHTML('beforeend', body);
        }
        this.element = document.createElement('div');
        if (options && options.dismissible) {
            // create X button that closes the modal
            const closeButton = document.createElement('button');
            closeButton.className = 'modal-dismiss sr-only';
            closeButton.textContent = 'Close';
            closeButton.addEventListener('click', () => this.hide());
            // create modal actions div
            const modalActions = document.createElement('div');
            modalActions.className = 'modal-actions';
            modalActions.appendChild(closeButton);
            // append modal actions to modal content
            const modalContent = fragment.querySelector('.modal-content');
            modalContent.insertBefore(modalActions, modalContent.firstElementChild);
            // close modal when user clicks outside modal
            const popupWrapper = fragment.querySelector('.popup_wrapper');
            popupWrapper.addEventListener('click', (event) => {
                if (event.target instanceof Node && !modalContent.contains(event.target)) {
                    this.hide();
                }
            });
        }
        this.element.appendChild(fragment);
    }
    show() {
        if (this.element.parentNode)
            return;
        document.body.appendChild(this.element);
        if (!document.documentElement.classList.contains('popup_visible')) {
            document.documentElement.classList.add('popup_visible');
            this.addedClass = true;
        }
    }
    hide() {
        if (!this.element.parentNode)
            return;
        this.element.parentNode.removeChild(this.element);
        if (this.addedClass) {
            document.documentElement.classList.remove('popup_visible');
            this.addedClass = false;
        }
        if (this.options && this.options.events && this.options.events.hide) {
            this.options.events.hide();
        }
    }
}
class LoadingModal extends Modal {
    constructor(title, options) {
        const body = `
            <div class="${namespace}-loading">
                <div class="${namespace}-progress"></div>
            </div>`;
        super(title, body, options);
        this.completed = false;
        this.steps = [];
        this.weight = 0;
        this.progress = this.element.querySelector(`.${namespace}-progress`);
    }
    refreshProgress() {
        switch (this.options && this.options.display) {
            case 'count':
                this.progress.textContent = `${this.steps.filter((s) => s.completed).length} / ${this.steps.length}`;
                break;
            case 'percentage':
                this.progress.textContent = Math.floor(getCompletionRatio(this.steps) * 100) + '%';
                break;
        }
    }
}
function createLoadingModal(title, options) {
    const modal = new LoadingModal(title, options);
    modal.show();
    return modal;
}
// calculates the completion ratio from a tree of steps with weights and child steps
function getCompletionRatio(steps) {
    const totalWeight = steps.map((s) => s.weight).reduce((a, b) => a + b, 0);
    if (totalWeight === 0)
        return 0;
    const completedWeight = steps.map((s) => s.weight * (s.completed ? 1 : getCompletionRatio(s.steps))).reduce((a, b) => a + b, 0);
    return completedWeight / totalWeight;
}
// this is a recursive function that browses pages of artists, albums and tracks to gather scrobbles
async function fetchScrobbleData(url, loadingModal, parentStep) {
    // remove "?date_preset=LAST_365_DAYS", etc.
    const indexOfQuery = url.indexOf('?');
    if (indexOfQuery !== -1) {
        url = url.substring(0, indexOfQuery);
    }
    if (getUrlType(url) === 'artist' && !url.endsWith('/+tracks')) {
        url += '/+tracks'; // skip artist overview and go straight to the tracks
    }
    const documentsToFetch = [fetchHTMLDocument(url)];
    const firstDocument = await documentsToFetch[0];
    const paginationList = firstDocument.querySelector('.pagination-list');
    if (paginationList) {
        const pageCount = parseInt(paginationList.children[paginationList.children.length - 2].textContent.trim(), 10);
        const pageNumbersToFetch = [...Array(pageCount - 1).keys()].map((i) => i + 2);
        documentsToFetch.push(...pageNumbersToFetch.map((n) => fetchHTMLDocument(`${url}?page=${n}`)));
    }
    const scrobbleData = await forEachParallel(loadingModal, parentStep, documentsToFetch, async (documentToFetch, step) => {
        const fetchedDocument = await documentToFetch;
        const table = fetchedDocument.querySelector('table.chartlist:not(.chartlist__placeholder)');
        if (!table) {
            // sometimes a missing chartlist is expected, other times it indicates a failure
            if (fetchedDocument.body.textContent.includes('There was a problem loading your')) {
                abort();
            }
            return [];
        }
        const rows = [...table.tBodies[0].rows];
        // to display accurate loading percentages, tracks with more scrobbles will have more weight
        const weightFunc = (row) => {
            const barValue = row.querySelector('.chartlist-count-bar-value');
            if (barValue === null)
                return 1;
            const scrobbleCount = parseInt(barValue.firstChild.textContent.trim().replace(/,/g, ''), 10);
            return Math.ceil(scrobbleCount / 50); // 50 = items per page on Last.fm
        };
        const scrobbleData = await forEachParallel(loadingModal, step, rows, async (row, step) => {
            const link = row.querySelector('.chartlist-count-bar-link');
            if (link) {
                // recursive call to the current function
                return await fetchScrobbleData(link.href, loadingModal, step);
            }
            // no link indicates we're at the scrobble overview
            const form = row.querySelector('form[data-edit-scrobble]');
            return [new FormData(form)];
        }, weightFunc);
        return scrobbleData.flat();
    });
    return scrobbleData.flat();
}
function getUrlType(url) {
    if (albumRegExp.test(url)) {
        return 'album';
    }
    else if (artistRegExp.test(url)) {
        if (url.endsWith('/+albums')) {
            return 'album artist';
        }
        else {
            return 'artist';
        }
    }
    else {
        return 'track';
    }
}
async function fetchHTMLDocument(url) {
    // retry 5 times with exponential timeout
    for (let i = 0; i < 5; i++) {
        if (i !== 0) {
            // wait 2 seconds, then 4 seconds, then 8, finally 16 (30 seconds total)
            await new Promise((resolve) => setTimeout(resolve, Math.pow(2, i)));
        }
        const response = await fetch(url);
        if (response.ok) {
            const html = await response.text();
            const doc = src_domParser.parseFromString(html, 'text/html');
            if (doc.querySelector('table.chartlist:not(.chartlist__placeholder)') || i === 4) {
                return doc;
            }
        }
    }
    abort();
    throw 'There was a problem loading your scrobbles, please try again later.';
}
let aborting = false;
function abort() {
    if (aborting)
        return;
    aborting = true;
    alert('There was a problem loading your scrobbles, please try again later.');
    window.location.reload();
}
// series for loop that updates the loading percentage
async function forEach(loadingModal, parentStep, array, callback, weightFunc) {
    const tuples = array.map((item) => ({ item, step: { completed: false, steps: [], weight: weightFunc ? weightFunc(item) : 1 } }));
    parentStep.steps.push(...tuples.map((tuple) => tuple.step));
    loadingModal.refreshProgress();
    const result = [];
    for (const tuple of tuples) {
        result.push(await callback(tuple.item, tuple.step));
        tuple.step.completed = true;
        loadingModal.refreshProgress();
    }
    return result.flat();
}
// parallel for loop that updates the loading percentage
function forEachParallel(loadingModal, parentStep, array, callback, weightFunc) {
    const tuples = array.map((item) => ({ item, step: { completed: false, steps: [], weight: weightFunc ? weightFunc(item) : 1 } }));
    parentStep.steps.push(...tuples.map((tuple) => tuple.step));
    loadingModal.refreshProgress();
    return Promise.all(tuples.map(async (tuple) => {
        const result = await callback(tuple.item, tuple.step);
        tuple.step.completed = true;
        loadingModal.refreshProgress();
        return result;
    }));
}
function applyFormData(form, formData) {
    for (const [name, value] of formData) {
        const input = form.querySelector(`input[name="${name}"]`);
        input.value = value;
    }
}
// augments the default Edit Scrobble form to include new features
async function augmentEditScrobbleForm(urlType, scrobbleData) {
    const wrapper = await observeChildList(document.body, '.popup_wrapper');
    // wait 1 frame
    await new Promise((resolve) => setTimeout(() => { resolve(); }));
    const popup = wrapper.querySelector('.popup_content');
    const title = popup.querySelector('.modal-title');
    const form = popup.querySelector('form[action$="/library/edit?edited-variation=library-track-scrobble"]');
    const elements = form.elements;
    title.textContent = `Edit ${urlType} Scrobbles`;
    title.style.textTransform = 'capitalize';
    // remove traces of the first scrobble that was used to initialize the form
    form.removeChild(form.querySelector('.form-group--timestamp'));
    form.removeChild(elements.track_name_original);
    form.removeChild(elements.artist_name_original);
    form.removeChild(elements.album_name_original);
    form.removeChild(elements.album_artist_name_original);
    const track_name_input = elements.track_name;
    const artist_name_input = elements.artist_name;
    const album_name_input = elements.album_name;
    const album_artist_name_input = elements.album_artist_name;
    const tracks = augmentInput(scrobbleData, popup, track_name_input, 'tracks');
    augmentInput(scrobbleData, popup, artist_name_input, 'artists');
    augmentInput(scrobbleData, popup, album_name_input, 'albums', album_artist_name_input);
    augmentInput(scrobbleData, popup, album_artist_name_input, 'album artists', album_name_input);
    // add information alert about album artists being kept in sync
    if (album_artist_name_input.placeholder === 'Mixed' && scrobbleData.some((s) => s.get('album_artist_name') === artist_name_input.value)) {
        const messageTemplate = document.createElement('template');
        messageTemplate.innerHTML = `
            <div class="form-group-success">
                <div class="alert alert-info">
                    <p>Matching album artists will be kept in sync.</p>
                </div>
            </div>`;
        const message = messageTemplate.content.firstElementChild.cloneNode(true);
        album_artist_name_input.parentNode.insertBefore(message, album_artist_name_input.nextElementSibling);
        const removeMessage = () => {
            message.parentNode.removeChild(message);
            album_artist_name_input.removeEventListener('input', removeMessage);
            album_artist_name_input.removeEventListener('keydown', removeMessage);
        };
        album_artist_name_input.addEventListener('input', removeMessage);
        album_artist_name_input.addEventListener('keydown', removeMessage);
    }
    // keep album artist name in sync
    let previousValue = artist_name_input.value;
    artist_name_input.addEventListener('input', () => {
        if (album_artist_name_input.value === previousValue && album_artist_name_input.placeholder !== 'Mixed') {
            album_artist_name_input.value = artist_name_input.value;
            album_artist_name_input.dispatchEvent(new Event('input'));
        }
        previousValue = artist_name_input.value;
    });
    // update the "Automatic edit" checkbox label
    const automaticEditFormGroup = form.querySelector('.form-group--create_automatic_edit_rule');
    if (automaticEditFormGroup && urlType !== 'track') {
        const label = automaticEditFormGroup.querySelector('.checkbox label').lastChild;
        label.textContent = label.textContent.replace('of this track', `for ${tracks} track${tracks !== 1 ? 's' : ''} of this ${urlType}`);
    }
    // replace the "Bulk edit" checkbox with one that cannot be disabled
    let bulkEditFormGroup = form.querySelector('.form-group--edit_all');
    if (bulkEditFormGroup)
        form.removeChild(bulkEditFormGroup);
    const types = ['artist', 'track', 'album', 'album artist'];
    types.splice(types.indexOf(urlType), 1);
    const summary = `${types[0]}, ${types[1]} and ${types[2]}`;
    const bulkEditFormGroupTemplate = document.createElement('template');
    bulkEditFormGroupTemplate.innerHTML = `
        <div class="form-group form-group--edit_all js-form-group">
            <label for="id_edit_all" class="control-label">Bulk edit</label>
            <div class="js-form-group-controls form-group-controls">
                <div class="checkbox">
                    <label for="id_edit_all">
                        <input id="id_edit_all" type="checkbox" checked disabled>
                        <input name="edit_all" type="hidden" value="true">
                        Edit all
                        <span class="abbr ${namespace}-abbr" tabindex="-1" title="You have scrobbled any combination of ${summary} ${scrobbleData.length} times">
                            ${scrobbleData.length} scrobbles
                        </span>
                        of this ${urlType}
                    </label>
                </div>
            </div>
        </div>`;
    bulkEditFormGroup = bulkEditFormGroupTemplate.content.firstElementChild.cloneNode(true);
    form.insertBefore(bulkEditFormGroup, automaticEditFormGroup !== null && automaticEditFormGroup !== void 0 ? automaticEditFormGroup : form.lastElementChild);
    // each exact track, artist, album and album artist combination is considered a distinct scrobble
    const distinctGroups = groupBy(scrobbleData, (s) => {
        var _a, _b;
        return JSON.stringify({
            track_name: s.get('track_name'),
            artist_name: s.get('artist_name'),
            album_name: (_a = s.get('album_name')) !== null && _a !== void 0 ? _a : '',
            album_artist_name: (_b = s.get('album_artist_name')) !== null && _b !== void 0 ? _b : '',
        });
    });
    const distinctScrobbleData = [...distinctGroups].map(([_name, values]) => values[0]);
    // disable the submit button when the form has validation errors
    const submitButton = form.querySelector('button[type="submit"]');
    form.addEventListener('input', () => {
        submitButton.disabled = form.querySelector('.has-error') !== null;
    });
    // set up the form submit event listener
    submitButton.addEventListener('click', async (event) => {
        var _a, _b;
        event.preventDefault();
        for (const element of form.elements) {
            if (element instanceof HTMLInputElement && element.dataset['confirm'] && element.placeholder !== 'Mixed') {
                if (confirm(element.dataset['confirm'])) {
                    delete element.dataset['confirm']; // don't confirm again when resubmitting
                }
                else {
                    return; // stop submit
                }
            }
        }
        const formData = new FormData(form);
        const formDataToSubmit = [];
        const track_name = getMixedInputValue(track_name_input);
        const artist_name = getMixedInputValue(artist_name_input);
        const album_name = getMixedInputValue(album_name_input);
        const album_artist_name = getMixedInputValue(album_artist_name_input);
        for (const originalData of distinctScrobbleData) {
            const track_name_original = originalData.get('track_name');
            const artist_name_original = originalData.get('artist_name');
            const album_name_original = (_a = originalData.get('album_name')) !== null && _a !== void 0 ? _a : '';
            const album_artist_name_original = (_b = originalData.get('album_artist_name')) !== null && _b !== void 0 ? _b : '';
            // if the album artist field is Mixed, use the old and new artist names to keep the album artist in sync
            const album_artist_name_sync = album_artist_name_input.placeholder === 'Mixed' && distinctScrobbleData.some((s) => s.get('artist_name') === album_artist_name_original)
                ? artist_name
                : album_artist_name;
            // check if anything changed compared to the original track, artist, album and album artist combination
            if (track_name !== null && track_name !== track_name_original ||
                artist_name !== null && artist_name !== artist_name_original ||
                album_name !== null && album_name !== album_name_original ||
                album_artist_name_sync !== null && album_artist_name_sync !== album_artist_name_original) {
                const clonedFormData = cloneFormData(formData);
                // Last.fm expects a timestamp
                clonedFormData.set('timestamp', originalData.get('timestamp'));
                // populate the *_original fields to instruct Last.fm which scrobbles need to be edited
                clonedFormData.set('track_name_original', track_name_original);
                if (track_name === null) {
                    clonedFormData.set('track_name', track_name_original);
                }
                clonedFormData.set('artist_name_original', artist_name_original);
                if (artist_name === null) {
                    clonedFormData.set('artist_name', artist_name_original);
                }
                clonedFormData.set('album_name_original', album_name_original);
                if (album_name === null) {
                    clonedFormData.set('album_name', album_name_original);
                }
                clonedFormData.set('album_artist_name_original', album_artist_name_original);
                if (album_artist_name_sync === null) {
                    clonedFormData.set('album_artist_name', album_artist_name_original);
                }
                else {
                    clonedFormData.set('album_artist_name', album_artist_name_sync);
                }
                formDataToSubmit.push(clonedFormData);
            }
        }
        if (formDataToSubmit.length === 0) {
            alert('Your edit doesn\'t contain any real changes.'); // TODO: pretty validation messages
            return;
        }
        // hide the Edit Scrobble form
        const cancelButton = form.querySelector('button.js-close');
        cancelButton.click();
        const loadingModal = createLoadingModal('Saving Edits...', { display: 'count' });
        const parentStep = loadingModal;
        // run edits in series, inconsistencies will arise if you use a parallel loop
        await forEach(loadingModal, parentStep, formDataToSubmit, async (formData) => {
            // Edge does not support passing formData into URLSearchParams() constructor
            const body = new URLSearchParams();
            for (const [name, value] of formData) {
                body.append(name, value);
            }
            const response = await fetch(form.action, { method: 'POST', body: body });
            const html = await response.text();
            // use DOMParser to check the response for alerts
            const placeholder = src_domParser.parseFromString(html, 'text/html');
            for (const message of placeholder.querySelectorAll('.alert-danger')) {
                alert(message.textContent.trim()); // TODO: pretty validation messages
            }
        });
        // Last.fm sometimes displays old data when reloading too fast, so wait 1 second
        setTimeout(() => { window.location.reload(); }, 1000);
    });
}
// helper function that completes when a matching element gets appended
function observeChildList(target, selector) {
    return new Promise((resolve) => {
        const observer = new MutationObserver((mutations) => {
            for (const mutation of mutations) {
                for (const node of mutation.addedNodes) {
                    if (node instanceof Element && node.matches(selector)) {
                        observer.disconnect();
                        resolve(node);
                        return;
                    }
                }
            }
        });
        observer.observe(target, { childList: true });
    });
}
// turns a normal input into an input that supports the "Mixed" state
function augmentInput(scrobbleData, popup, input, plural, otherInput) {
    var _a;
    const groups = [...groupBy(scrobbleData, (s) => s.get(input.name))].sort((a, b) => b[1].length - a[1].length);
    if (groups.length >= 2) {
        // display the "Mixed" placeholder when there are two or more possible values
        input.value = '';
        input.placeholder = 'Mixed';
        const tab = '\xa0'.repeat(8); // 8 non-breaking spaces
        const abbr = document.createElement('span');
        abbr.className = `abbr ${namespace}-abbr`;
        abbr.tabIndex = -1;
        abbr.textContent = `${groups.length} ${plural}`;
        abbr.title = groups.map(([key, values]) => `${values.length}x${tab}${key !== null && key !== void 0 ? key : ''}`).join('\n');
        input.parentNode.insertBefore(abbr, input.nextElementSibling);
        input.dataset['confirm'] = `You are about to merge scrobbles for ${groups.length} ${plural}. This cannot be undone. Would you like to continue?`;
        // datalist: a native HTML5 autocomplete feature
        const datalist = document.createElement('datalist');
        datalist.id = `${namespace}-${popup.id}-${input.name}-datalist`;
        for (const [value] of groups) {
            const option = document.createElement('option');
            option.value = (_a = value) !== null && _a !== void 0 ? _a : '';
            datalist.appendChild(option);
        }
        input.autocomplete = 'off';
        input.setAttribute('list', datalist.id);
        input.parentNode.insertBefore(datalist, input.nextElementSibling);
    }
    // display green color when field was edited, red if it's not allowed to be empty
    const formGroup = input.closest('.form-group');
    const defaultValue = input.value;
    input.addEventListener('input', () => {
        input.placeholder = ''; // removes "Mixed" state
        refreshFormGroupState();
    });
    input.addEventListener('keydown', (event) => {
        if (event.keyCode === 8 || event.keyCode === 46) { // backspace or delete
            input.placeholder = ''; // removes "Mixed" state
            refreshFormGroupState();
        }
    });
    otherInput === null || otherInput === void 0 ? void 0 : otherInput.addEventListener('input', () => {
        refreshFormGroupState();
    });
    function refreshFormGroupState() {
        formGroup.classList.remove('has-error');
        formGroup.classList.remove('has-success');
        if (input.value !== defaultValue || groups.length >= 2 && input.placeholder === '') {
            if (input.value === '' && (input.name === 'track_name'
                || input.name === 'artist_name'
                || input.name === 'album_name' && (!!otherInput.value || otherInput.placeholder === 'Mixed')
                || input.name === 'album_artist_name' && !!otherInput.value || otherInput.placeholder === 'Mixed')) {
                formGroup.classList.add('has-error');
            }
            else {
                formGroup.classList.add('has-success');
            }
        }
    }
    return groups.length;
}
function groupBy(array, keyFunc) {
    const map = new Map();
    for (const item of array) {
        const key = keyFunc(item);
        const value = map.get(key);
        if (!value) {
            map.set(key, [item]);
        }
        else {
            value.push(item);
        }
    }
    return map;
}
function getMixedInputValue(input) {
    return input.placeholder !== 'Mixed' ? input.value : null;
}
function cloneFormData(formData) {
    const clonedFormData = new FormData();
    for (const [name, value] of formData) {
        clonedFormData.append(name, value);
    }
    return clonedFormData;
}

})();

/******/ })()
;