Last.fm Bulk Edit

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

目前為 2020-02-08 提交的版本,檢視 最新版本

// ==UserScript==
// @name        Last.fm Bulk Edit
// @namespace   https://github.com/RudeySH/lastfm-bulk-edit
// @version     0.1.5
// @author      Rudey
// @description Bulk edit your scrobbles for any artist or album on Last.fm at once.
// @license     GPL-3.0-or-later
// @homepageURL https://github.com/RudeySH/lastfm-bulk-edit
// @icon        https://www.last.fm/static/images/lastfm_avatar_twitter.png
// @supportURL  https://github.com/RudeySH/lastfm-bulk-edit/issues
// @match       https://www.last.fm/*
// ==/UserScript==

'use strict';

const namespace = 'lastfm-bulk-edit';

// use the top-right link to determine the current user
const authLink = document.querySelector('a.auth-link');

if (!authLink) {
    return; // not logged in
}

const libraryURL = `${authLink.href}/library`;

// https://regex101.com/r/KwEMRx/1
const albumRegExp  = new RegExp(`^${libraryURL}/music(\\+[^/]*)*(/[^+][^/]*){2}$`);
const artistRegExp = new RegExp(`^${libraryURL}/music(\\+[^/]*)*(/[^+][^/]*){1}$`);

const domParser = new DOMParser();

const editScrobbleMenuItemTemplate = document.createElement('template');
editScrobbleMenuItemTemplate.innerHTML = `
    <li>
        <form method="POST" action="${libraryURL}/edit?edited-variation=library-track-scrobble" data-edit-scrobble="">
            <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">
                Edit scrobbles
            </button>
        </form>
    </li>`;

const loadingModalTemplate = document.createElement('template');
loadingModalTemplate.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">
                <h2 class="modal-title">
                    Loading...
                </h2>
                <div class="modal-body">
                    <div class="${namespace}-loading">
                        <div class="${namespace}-progress">0%</div>
                    </div>
                </div>
            </div>
        </div>
        <div class="popup_align" style="display: inline-block; vertical-align: middle; height: 100%;"></div>
    </div>`;

initialize();

function initialize() {
    appendStyle();
    appendEditScrobbleMenuItems(document);

    // 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) {
                    appendEditScrobbleMenuItems(node);
                }
            }
        }
    });

    observer.observe(document.body, {
        childList: true,
        subtree: true
    });
}

function appendStyle() {
    const style = document.createElement('style');

    style.innerHTML = `
        .${namespace}-abbr {
            cursor: pointer;
        }

        .${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;
        }`;

    document.head.appendChild(style);
}

function appendEditScrobbleMenuItems(element) {
    if (!document.URL.startsWith(libraryURL)) {
        return; // current page is not the user's library
    }

    const tables = element.querySelectorAll('table.chartlist');

    for (const table of tables) {
        for (const row of table.tBodies[0].rows) {
            appendEditScrobbleMenuItem(row);
        }
    }
}

function appendEditScrobbleMenuItem(row) {
    const link = row.querySelector('a.chartlist-count-bar-link');

    if (!link) {
        return; // this is not an artist, album or track
    }

    const urlType = getUrlType(link.href);

    // re-use template from outer scope
    const editScrobbleMenuItem = editScrobbleMenuItemTemplate.content.cloneNode(true);

    const form = editScrobbleMenuItem.querySelector('form');
    const button = form.querySelector('button');

    let loadingModal;
    let scrobbleData;

    button.addEventListener('click', onFirstClick);

    async function onFirstClick(event) {
        event.preventDefault();

        loadingModal = createLoadingModal();
        scrobbleData = await fetchScrobbleData(link.href, loadingModal);

        if (scrobbleData.length === 0) {
            loadingModal.close();
            alert(`Last.fm reports you haven't listened to this ${urlType}.`);
            return;
        }

        // Last.fm expects form fields populated with a single scrobble
        applyFormData(form, scrobbleData[0]);

        // done loading, click to open the Edit Scrobble dialog
        this.removeEventListener('click', onFirstClick);
        this.addEventListener('click', onClick);
        this.click();
    }

    async function onClick() {
        await augmentEditScrobbleForm(urlType, scrobbleData);
        loadingModal.close();
    }

    // append new menu item to the DOM
    const menu = row.querySelector('.chartlist-more-menu');
    menu.insertBefore(editScrobbleMenuItem, menu.firstElementChild);
}

function createLoadingModal() {
    // re-use template from outer scope
    const loadingModal = loadingModalTemplate.content.cloneNode(true);

    const progress = loadingModal.querySelector(`.${namespace}-progress`);
    const steps = [];

    // append new loading modal to the DOM
    const container = document.createElement('div');
    container.appendChild(loadingModal);
    document.body.appendChild(container);

    // expose API for completing steps and closing the modal
    return {
        steps,
        completeStep: step => {
            step.completed = true;
            const completionRatio = getCompletionRatio(steps);
            progress.textContent = Math.floor(completionRatio * 100) + '%';
        },
        close: () => {
            if (container.parentNode) {
                container.parentNode.removeChild(container);
            }
        }
    }
}

// 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, parentDocument, parentURL) {
    if (!parentStep) parentStep = loadingModal;
    if (!parentDocument) parentDocument = document;
    if (!parentURL) parentURL = parentDocument.URL;

    // remove "?date_preset=LAST_365_DAYS", etc.
    const indexOfQuery = url.indexOf('?');
    if (indexOfQuery !== -1) {
        url = url.substr(0, indexOfQuery);
    }

    if (getUrlType(url) === 'artist') {
        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}`)));
    }

    let scrobbleData = await forEachParallel(loadingModal, parentStep, documentsToFetch, async (documentToFetch, step) => {
        const fetchedDocument = await documentToFetch;

        const table = fetchedDocument.querySelector('table.chartlist');
        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
        };

        return 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, fetchedDocument, url);
            }

            // no link indicates we're at the scrobble overview
            const form = row.querySelector('form[data-edit-scrobble]');
            return new FormData(form);
        }, weightFunc);
    });

    if (getUrlType(parentURL) === 'album') {
        // fetching scrobbles of an album yields scrobbles from other albums as well, so apply a filter
        const album_name = parentDocument.querySelector('.library-header-title').textContent.trim();
        scrobbleData = scrobbleData.filter(s => s.get('album_name') === album_name);
    }

    return scrobbleData;
}

function getUrlType(url) {
    // regular expressions are re-used from the outer scope
    if (albumRegExp.test(url)) {
        return 'album';
    } else if (artistRegExp.test(url)) {
        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 = domParser.parseFromString(html, 'text/html');

            if (doc.querySelector('table.chartlist') || i === 4) {
                return doc;
            }
        }
    }

    abort();
}

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: { weight: weightFunc ? weightFunc(item) : 1, steps: [] } }));
    parentStep.steps.push(...tuples.map(tuple => tuple.step));

    const result = [];
    for (const tuple of tuples) {
        result.push(await callback(tuple.item, tuple.step));
        loadingModal.completeStep(tuple.step);
    }

    return flatten(result);
}

// parallel for loop that updates the loading percentage
async function forEachParallel(loadingModal, parentStep, array, callback, weightFunc) {
    const tuples = array.map(item => ({ item, step: { weight: weightFunc ? weightFunc(item) : 1, steps: [] } }));
    parentStep.steps.push(...tuples.map(tuple => tuple.step));

    const result = await Promise.all(tuples.map(async tuple => {
        const result = await callback(tuple.item, tuple.step);
        loadingModal.completeStep(tuple.step);
        return result;
    }));

    return flatten(result);
}

// because Edge does not support Array.prototype.flat()
function flatten(array) {
    return array.reduce((flat, toFlatten) => {
        return flat.concat(Array.isArray(toFlatten) ? flatten(toFlatten) : toFlatten);
    }, []);
}

function applyFormData(form, formData) {
    for (const [name, value] of formData) {
        const input = form.elements[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');
    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"]');

    title.textContent = `Edit ${urlType[0].toUpperCase() + urlType.slice(1)} Scrobbles`;

    // remove traces of the first scrobble that was used to initialize the form
    form.removeChild(form.querySelector('.form-group--timestamp'));
    form.removeChild(form.elements['track_name_original']);
    form.removeChild(form.elements['artist_name_original']);
    form.removeChild(form.elements['album_name_original']);
    form.removeChild(form.elements['album_artist_name_original']);

    const track_name_input = form.elements['track_name'];
    const artist_name_input = form.elements['artist_name'];
    const album_name_input = form.elements['album_name'];
    const album_artist_name_input = form.elements['album_artist_name'];

    augmentInput(urlType, scrobbleData, popup, track_name_input, 'tracks');
    augmentInput(urlType, scrobbleData, popup, artist_name_input, 'artists');
    augmentInput(urlType, scrobbleData, popup, album_name_input, 'albums');
    augmentInput(urlType, scrobbleData, popup, album_artist_name_input, 'album artists');

    // keep album artist name in sync
    if (!album_artist_name_input.disabled) {
        let previousValue = artist_name_input.value;
        artist_name_input.addEventListener('input', () => {
            if (album_artist_name_input.value === previousValue) {
                album_artist_name_input.value = artist_name_input.value;
                album_artist_name_input.dispatchEvent(new Event('input'));
            }
            previousValue = artist_name_input.value;
        });
    } else {
        const template = document.createElement('template');
        template.innerHTML = `
            <div class="form-group-success">
                <div class="alert alert-info">
                    <p>Matching album artists will be kept in sync.</p>
                </div>
            </div>`
        artist_name_input.parentNode.insertBefore(template.content, artist_name_input.nextElementChild);
    }

    // replace the "Edit all" checkbox with one that cannot be disabled
    let editAllFormGroup = form.querySelector('.form-group--edit_all');
    if (editAllFormGroup) form.removeChild(editAllFormGroup);

    const summary = `${urlType !== 'artist' ? 'artist, ' : ''}${urlType !== 'track' ? 'track, ' : ''}${urlType !== 'album' ? 'album, ' : ''}and album artist`;
    const editAllFormGroupTemplate = document.createElement('template');
    editAllFormGroupTemplate.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" title="You have scrobbled any combination of ${summary} ${scrobbleData.length} times">
                            ${scrobbleData.length} scrobbles
                        </span>
                        of this ${urlType}
                    </label>
                </div>
            </div>
        </div>`

    editAllFormGroup = editAllFormGroupTemplate.content.cloneNode(true);
    form.insertBefore(editAllFormGroup, form.lastElementChild);

    // each exact track, artist, album and album artist combination is considered a distinct scrobble
    const scrobbleMap = groupBy(scrobbleData, s => JSON.stringify({
        track_name: s.get('track_name'),
        artist_name: s.get('artist_name'),
        album_name: s.get('album_name') || '',
        album_artist_name: s.get('album_artist_name') || ''
    }));

    const distinctScrobbleData = [...scrobbleMap].map(([name, values]) => values[0]);

    const submitButton = form.querySelector('button[type="submit"]');
    submitButton.addEventListener('click', async event => {
        event.preventDefault();

        const formData = new FormData(form);
        const formDataToSubmit = [];

        const track_name = getMixedInputValue(form.elements['track_name']);
        const artist_name = getMixedInputValue(form.elements['artist_name']);
        const album_name = getMixedInputValue(form.elements['album_name']);
        const album_artist_name = getMixedInputValue(form.elements['album_artist_name']);

        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 = originalData.get('album_name') || '';
            const album_artist_name_original = originalData.get('album_artist_name') || '';

            // if the album artist field is disabled, use the old and new artist names to keep the album artist in sync
            const album_artist_name_sync = album_artist_name_input.disabled && 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();
        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 = 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 3 seconds
        setTimeout(() => { window.location.reload(); }, 3000);
    });
}

// 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.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(urlType, scrobbleData, popup, input, plural) {
    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.textContent = `${groups.length} ${plural}`;
        abbr.title = groups.map(([key, values]) => `${values.length}x${tab}${key || ''}`).join('\n');
        input.parentNode.insertBefore(abbr, input.nextElementChild);

        switch (input.name) {
            case 'track_name':
                // disable track field when editing an artist's or album's scrobbles
                if (urlType !== 'track') {
                    input.disabled = true;
                    return;
                }
                break;

            case 'album_name':
            case 'album_artist_name':
                // disable album and album artist fields when editing an artist's scrobbles and there are two or more scrobbled albums
                if (urlType === 'artist' && new Set(scrobbleData.map(s => s.get('album_name')).filter(n => n !== null)).size >= 2) {    
                    input.disabled = true;
                    return;
                }
                break;
        }
    }

    // datalist: a native HTML5 autocomplete feature
    const datalist = document.createElement('datalist');
    datalist.id = `${namespace}-${popup.id}-${input.name}-datalist`;

    for (const [key] of groups) {
        const option = document.createElement('option');
        option.value = key || '';
        datalist.appendChild(option);
    }

    input.autocomplete = 'off';
    input.setAttribute('list', datalist.id);
    input.parentNode.insertBefore(datalist, input.nextElementChild);

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

    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')) {
                formGroup.classList.add('has-error');
            } else {
                formGroup.classList.add('has-success');
            }
        }
    }
}

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.disabled && 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;
}

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址