IMDb - List Helper

Makes creating IMDb lists more efficient and convenient

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name           IMDb - List Helper
// @description    Makes creating IMDb lists more efficient and convenient
// @namespace      imdb
// @author         themagician, monk-time
// @include        http://*imdb.com/list/*/edit
// @include        http://*imdb.com/list/*/edit?*
// @include        https://*imdb.com/list/*/edit
// @include        https://*imdb.com/list/*/edit?*
// @require        https://cdnjs.cloudflare.com/ajax/libs/d3-dsv/1.0.8/d3-dsv.min.js
// @icon           http://www.imdb.com/favicon.ico
// @version        3.1.1
// ==/UserScript==

//
// CHANGELOG
//
// 3.1.1
// fixed: https caused the script not to load
//
// 3.1
// fixed: 'Skip/retry' buttons failed to keep search results visible
// changed: The script searches for both IDs or titles by default again
// added: A more granular control for what is used for search
//
// 3.0.1
// fixed: New IMDb search results layout
// changed: Search only for regex matches by default
// added: A checkbox to toggle search mode (matches only vs. matches or full string)
//
// 3.0
// fixed: Search by movie title
// fixed: No longer requires jQuery; jquery-csv is replaced with d3-dsv
// fixed: Remove delay before auto-clicking on a search result
// changed: Criticker score conversion: 0..10 -> 1, 11..20 -> 2, 91..100 -> 10
// changed: Criticker importer requires .csv
// changed: If the regex fails, try searching for the whole string
//
// 2.4.1
// fixed: In some instances the script wasn't loaded (bad @include)
// changed: Limit number of setTimeOut calls
//
// 2.4.0
// fixed: IMDb changed layout
//
// 2.3.0
// fixed: importing ratings works again
//
// 2.2.0
// added: support for people
//
// 2.1.1
// added: only show import form if ratings is selected
//
// 2.1
// added: importers for imdb, rateyourmusic, criticker
//
// 2.0
// added: import ratings
// added: if regex doesn't match, skip entry
//
// 1.6.1.2
// added: input text suggestion as a placeholder
//
// 1.6.1.1
// fixed: some entries are skipped when adding imdb ids/urls
//

/* global d3 */

'use strict';

// milliseconds between each request
const REQUEST_DELAY = 1000;

// ----- DOM ELEMENTS: STYLING, CREATION AND TRACKING -----

document.head.insertAdjacentHTML('beforeend', `<style>
    #ilh-ui {
        margin: 0 5% 5% 5%;
        padding: 10px;
        border: 1px solid #e8e8e8;
    }

    #ilh-ui div {
        margin: 0.5em 0 0.75em
    }

    #ilh-ui label {
        font-weight: normal;
        margin-right: 6px;
    }

    #ilh-ui span,
    #ilh-ui label:first-child {
        font-weight: bold;
    }

    #ilh-ui textarea {
        width: 100%;
        background-color: lightyellow;
        overflow: auto;
    }

    #ilh-ui .ilh-block {
        display: flex;
    }

    #ilh-ui .ilh-block input[type=text] {
        font-family: monospace;
        flex-grow: 1;
    }
</style>`);

const searchModeHints = {
    auto:   'Input titles, IMDb URLs or IDs here and click Start. ' +
            'If a line has an IMDb ID, it\'ll be auto-added to the list. ' +
            'Otherwise the whole line will be searched for, ' +
            'and you\'ll have to select the correct title manually.',
    imdbid: 'Input text containing IMDb IDs here and click Start. ' +
            'IMDb IDs are extracted from lines (only one per line) and ' +
            'auto-added to the list, skipping the rest.',
    line:   'Input titles or IMDb IDs here and click Start. ' +
            'Whole lines are used for search, nothing is skipped.',
    regexp: 'Input text here and click Start. Only captured groups of regex matches ' +
            'are used for search.',
    rating: 'Use the controls below to load data from a file or input text here and click Start. ' +
            'Your rating and IMDb ID/title is extracted from each line with regex.',
};

const uiHTML = `
    <div id="ilh-ui">
        <div class="ilh-block">
            <label>Import mode:</label>
            <input type="radio" id="ilh-mode-list" name="mode" value="list" checked>
            <label for="ilh-mode-list">List</label>
            <input type="radio" id="ilh-mode-ratings" name="mode" value="ratings">
            <label for="ilh-mode-ratings">Ratings</label>
        </div>
        <textarea id="ilh-film-list" rows="7" placeholder="${searchModeHints.auto}"></textarea>
        <div>
            <input type="button" value="Start" id="ilh-start">
            <input type="button" value="Skip"  id="ilh-skip">
            <input type="button" value="Retry" id="ilh-retry">
            <span>Remaining: <span id="ilh-films-remaining">0</span></span>
        </div>
        <div class="ilh-block">
            <label for="ilh-current-film">Current:</label>
            <input type="text" id="ilh-current-film">
        </div>
        <div class="ilh-block" id="ilh-regexp-box" style="display: none">
            <label for="ilh-regexp">Regexp:</label>
            <input type="text" id="ilh-regexp">
        </div>
        <div id="ilh-search-mode-box">
            <label for="ilh-search-mode">Search mode:</label>
            <select name="searchmode" id="ilh-search-mode">
                <option value="auto" selected>Auto</option>
                <option value="imdbid">IMDb IDs</option>
                <option value="line">Line</option>
                <option value="regexp">Regexp</option>
            </select>
        </div>
        <div id="ilh-import" style="display: none">
            <label for="ilh-import-sel">Import .csv from:</label>
            <select name="import" id="ilh-import-sel">
                <option value="" selected disabled hidden>Select</option>
                <option value="imdb">IMDb</option>
                <option value="rym">RateYourMusic</option>
                <option value="criticker">Criticker</option>
            </select>
            <span>File:</span>
            <input type="file" id="ilh-file-import" disabled>
        </div>
    </div>`;

document.querySelector('div.lister-search').insertAdjacentHTML('afterend', uiHTML);

const innerIDs = [
    'mode-list',
    'mode-ratings',
    'film-list',
    'start',
    'skip',
    'retry',
    'films-remaining',
    'current-film',
    'search-mode-box',
    'search-mode',
    'regexp-box',
    'regexp',
    'import',
    'import-sel',
    'file-import',
];

const camelCase = s => s.replace(/-[a-z]/g, m => m[1].toUpperCase());

// Main object for interacting with the script's UI; keys match element ids
const ui = Object.assign(...innerIDs.map(id => ({
    [camelCase(id)]: document.getElementById(`ilh-${id}`),
})));

ui.freezables = [ui.modeList, ui.modeRatings, ui.filmList, ui.start, ui.regexp, ui.searchMode];
const elIMDbSearch = document.getElementById('add-to-list-search');
const elIMDbResults = document.getElementById('add-to-list-search-results');

// ----- HANDLERS AND ACTIONS -----

const convertRating = n => Math.ceil(n / 10) || 1; // 0..100 -> 1..10
// d3 skips a row if a row conversion function returns null
const joinOrSkip = (...fields) => (fields.includes(undefined) ? null : fields.join(','));
const rowConverters = {
    imdb: row => joinOrSkip(row['Your Rating'], row.Const),
    rym: row => joinOrSkip(row.Rating, row.Title),
    // .csv exported from Criticker have spaces between column names
    criticker: row => joinOrSkip(convertRating(+row.Score), row[' IMDB ID']),
};

const prepareImport = e => {
    const isLegalParser = Boolean(rowConverters[e.target.value]); // in case of html-js mismatch
    ui.fileImport.disabled = !isLegalParser;
};

const handleImport = e => {
    const format = ui.importSel.value;
    const reader = new FileReader();
    reader.onload = event => {
        const fileStr = event.target.result;
        ui.filmList.value = d3.csvParse(fileStr, rowConverters[format]).join('\n');
    };

    const [file] = e.target.files;
    reader.readAsText(file);
};

const RatingManager = {
    rating: 0,
    regex: /^([1-9]|10),(.*)$/i,
    match: (line, mode, regex) => regex.exec(line),
    processMatch: ([, rating, filmTitle], callback) => {
        RatingManager.rating = rating;
        callback(filmTitle);
    },
    afterClick: async (imdbID, callback) => {
        console.log(`RatingManager::afterClick: Rating ${imdbID}`);
        const moviePage = await fetch(
            `http://www.imdb.com/title/${imdbID}/`,
            { credentials: 'same-origin' },
        );
        const authHash = new DOMParser()
            .parseFromString(await moviePage.text(), 'text/html')
            .getElementById('star-rating-widget')
            .dataset.auth;

        const params = {
            tconst: imdbID,
            rating: RatingManager.rating,
            auth: authHash,
            tracking_tag: 'list',
        };

        const postResp = await fetch('http://www.imdb.com/ratings/_ajax/title', {
            method: 'POST',
            body: new URLSearchParams(params),
            credentials: 'same-origin',
        });

        if (postResp.ok) {
            callback();
        } else {
            alert(`Rating failed. Status code ${postResp.status}`);
        }
    },
};

const ListManager = {
    regex: /((?:tt|nm)\d+)/i, // IMDb IDs
    match: (line, mode, regex) => ({
        /* eslint-disable no-sparse-arrays */
        // 'auto' - search for an id (if a string has one) or for a full non-empty string
        auto:   s => ListManager.regex.exec(s) || s && [, s],
        imdbid: s => ListManager.regex.exec(s),
        line:   s => s && [, s],
        regexp: s => regex.exec(s),
        /* eslint-enable no-sparse-arrays */
    })[mode](line),
    processMatch: ([, filmTitle], callback) => callback(filmTitle),
    afterClick: (imdbID, callback) => callback(),
};

const App = {
    manager: ListManager,
    films: [],
    regexObj: null,
    run: () => {
        // Set the default value for the 'Regexp' mode
        ui.regexp.value = App.manager.regex.source;

        ui.importSel.addEventListener('change', prepareImport);
        ui.fileImport.addEventListener('change', handleImport);

        ui.searchMode.addEventListener('change', () => {
            ui.regexpBox.style.display = ui.searchMode.value === 'regexp' ? '' : 'none';
            ui.filmList.placeholder = searchModeHints[ui.searchMode.value];
        });

        ui.modeList.addEventListener('change', () => {
            App.manager = ListManager;
            ui.import.style.display = 'none';
            ui.regexp.value = App.manager.regex.source;
            ui.regexpBox.style.display = ui.searchMode.value === 'regexp' ? '' : 'none';
            ui.searchModeBox.style.display = '';
            ui.filmList.placeholder = searchModeHints[ui.searchMode.value];
        });

        ui.modeRatings.addEventListener('change', () => {
            App.manager = RatingManager;
            ui.import.style.display = '';
            ui.regexp.value = App.manager.regex.source;
            ui.regexpBox.style.display = '';
            ui.searchModeBox.style.display = 'none';
            ui.filmList.placeholder = searchModeHints.rating;
        });

        ui.start.addEventListener('click', () => {
            // This will be used only for ListManager's 'regexp' mode or RatingManager
            App.regexObj = new RegExp(ui.regexp.value, 'i');

            // Disable relevant UI elements
            ui.freezables.forEach(el => {
                el.disabled = true;
            });

            App.films = ui.filmList.value.trim().split('\n');
            App.handleNext();
        });

        // When the search popup loses focus, IMDb will hide it after 300 ms,
        // so all button clicks that want to keep it visible need to be delayed
        ui.skip.addEventListener('click', () =>
            setTimeout(() => App.handleNext(), 350));

        ui.retry.addEventListener('click', () =>
            setTimeout(() => elIMDbSearch.dispatchEvent(new Event('keydown')), 350));
    },
    handleNext: () => {
        if (App.films.length) {
            App.search(App.films.shift());
        } else { // if last film
            App.reset();
        }
    },
    reset: () => {
        App.films = [];
        App.regexObj = null;

        ui.freezables.forEach(el => {
            el.disabled = false;
        });

        ui.currentFilm.value = '';
        elIMDbSearch.value = '';
    },
    search: line => {
        line = line.trim();
        ui.currentFilm.value = line;
        ui.filmsRemaining.textContent = App.films.length;
        ui.filmList.value = App.films.join('\n');

        const result = App.manager.match(line, ui.searchMode.value, App.regexObj);
        if (result) {
            App.manager.processMatch(result, filmTitle => {
                // Set imdb search input field to film title and trigger search
                elIMDbSearch.value = filmTitle;
                elIMDbSearch.dispatchEvent(new Event('keydown'));
            });
        } else {
            App.handleNext();
        }
    },
};

// Handle clicks on search results by a user or the script
elIMDbResults.addEventListener('click', e => {
    const imdbID = e.target.closest('a').dataset.const;
    if (!imdbID || !imdbID.startsWith('tt')) return;
    App.manager.afterClick(imdbID, () => {
        setTimeout(() => App.handleNext(), REQUEST_DELAY);
    });
});

// Monitor for changes to the search result box.
// If the search was for IMDb URL/ID, the only result is clicked automatically
const mut = new MutationObserver(mutList => mutList.forEach(({ addedNodes }) => {
    if (!addedNodes.length || !/(nm|tt)\d{7}/i.test(ui.currentFilm.value)) return;
    addedNodes[0].click();
}));
mut.observe(elIMDbResults, { childList: true });

App.run();