Cinematheqular

Adds movie plots and ratings to the Ville de Luxembourg Cinematheque website

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

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

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

// ==UserScript==
// @name         Cinematheqular
// @namespace    https://github.com/harristom
// @version      2025-06-01
// @description  Adds movie plots and ratings to the Ville de Luxembourg Cinematheque website
// @author       https://github.com/harristom
// @match        https://www.vdl.lu/*
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_listValues
// @grant        GM_deleteValue
// ==/UserScript==

(function () {
    'use strict';

    function formatRtRating(rating) {
        const FRESH_THRESHOLD = 60;
        return (parseInt(rating) >= FRESH_THRESHOLD ? '🍅 ' : '🍏 ') + rating;
    }

    function cleanTitle(title) {
        // Remove ending brackets from the title (often used to indicate the showing is part of a season)
        title = title.trim();
        return encodeURIComponent(title.replaceAll(/ \(.*$/gi, ''));
    }

    function agendaPage(omdbApiKey) {
        const RATINGS_CLASS_NAME = 'cinematheqular-ratings';

        GM_addStyle(`
            /* Hide the categories as it will be "Cinema" on all and the language tags are always wrong */
            .media-inner .media-category {
                display: none;
            }

            .${RATINGS_CLASS_NAME} {
                color: #4A4A4A;
                font-weight: 400;
            }

            .media-image figure[data-content] {
                position: relative;
                &::after {
                    position: absolute;
                    inset: 0px;
                    color: white;
                    background: linear-gradient(black, transparent);
                    padding: 3px;
                    content: attr(data-content);
                    font-size: 0.85em;
                    line-height: 1.2;
                    transition: 0.2s;
                    opacity: 0;
                    mask: linear-gradient(black 80%, transparent);
                }
                .media-link:hover &::after {
                    opacity: 1;
                }
            }
        `);

        const movieEls = document.querySelectorAll('.media-inner');

        for (const movieEl of movieEls) {
            const titleEl = movieEl.querySelector('.media-title');
            if (!titleEl) continue;
            let title = titleEl.textContent;
            title = cleanTitle(title);
            fetch(`https://www.omdbapi.com/?apikey=${omdbApiKey}&type=movie&t=${title}`)
                .then(response => response.json())
                .then(result => {
                    if (result.Error) return;
                    const tooltip = `${result.Title} (${result.Year}) ${result.Director}`;
                    // Add plot
                    const imgEl = movieEl.querySelector('.media-image figure');
                    if (imgEl) {
                        imgEl.dataset.content = result.Plot;
                        imgEl.title = tooltip;
                    }
                    // Add ratings
                    const rtRating = result.Ratings?.find(r => r.Source == 'Rotten Tomatoes')?.Value;
                    if (rtRating) {
                        const ratingEl = document.createElement('p');
                        ratingEl.className = RATINGS_CLASS_NAME;
                        ratingEl.textContent = formatRtRating(rtRating);
                        ratingEl.title = tooltip;
                        titleEl.append(ratingEl);
                    }
                })
                .catch(error => console.log('error', error));
        }
    }

    function detailPage(omdbApiKey) {
        const DETAILS_CLASS_NAME = 'cinematheqular-details';

        GM_addStyle(`
            .${DETAILS_CLASS_NAME} {
                border-radius: 7px;
                border: 2px solid #E4E4E4;
                padding: 10px 20px 20px 20px;
                margin-bottom: 20px;
            }

            .${DETAILS_CLASS_NAME}__disclaimer {
                text-transform: uppercase;
                margin-block: 0px 10px;
                font-size: 0.8rem;
                opacity: 0.8;
            }

            .${DETAILS_CLASS_NAME}__wrapper {
                display: flex;
                justify-content: center;
                align-items: start;
                flex-wrap: wrap;
                gap: 20px;
            }
        
            .${DETAILS_CLASS_NAME}__poster {
                display: block;
                object-fit: contain;
                overflow: hidden;
                flex: 1 100px;
                max-width: 200px;
                border-radius: 3px;
            }
        
            .${DETAILS_CLASS_NAME}__data {
                flex: 3 300px;
            }

            .${DETAILS_CLASS_NAME}__title {
                font-size: 1.8em;
                margin-bottom: 5px;
            }

            .${DETAILS_CLASS_NAME}__director {
                margin-top: 0px;
                margin-bottom: 5px;
                color: #b6b6b6;
                font-weight: 600;
            }

            .${DETAILS_CLASS_NAME}__ratings {
                margin-top: 0px;
                margin-bottom: 10px;
            }

            .${DETAILS_CLASS_NAME}__plot {
                margin: 0px;
            }
        `);

        let titleEl = document.querySelector('.block-page-title');
        if (!titleEl) return;
        const title = cleanTitle(titleEl.textContent);
        fetch(`https://www.omdbapi.com/?apikey=${omdbApiKey}&type=movie&plot=full&t=${title}`)
            .then(response => response.json())
            .then(result => {
                if (result.Error) return;
                const detailsEl = document.createElement('div');
                detailsEl.className = DETAILS_CLASS_NAME;
                detailsEl.innerHTML = `
                    <p class="${DETAILS_CLASS_NAME}__disclaimer"><small>Added by <a href="https://github.com/harristom/cinematheqular">Cinematheqular</a> &mdash; please check the rest of the listing to ensure the information is correct</small></p>
                    <div class="${DETAILS_CLASS_NAME}__wrapper">
                        <img src="" alt="" class="${DETAILS_CLASS_NAME}__poster">
                        <div class="${DETAILS_CLASS_NAME}__data">
                            <h2 class="${DETAILS_CLASS_NAME}__title"></h2>
                            <p class="${DETAILS_CLASS_NAME}__director"></p>
                            <p class="${DETAILS_CLASS_NAME}__ratings"></p>
                            <p class="${DETAILS_CLASS_NAME}__plot"></p>
                        </div>
                    </div>
                `;
                detailsEl.querySelector(`.${DETAILS_CLASS_NAME}__poster`).src = result.Poster;
                detailsEl.querySelector(`.${DETAILS_CLASS_NAME}__title`).textContent = `${result.Title} (${result.Year})`;
                const rtRating = result.Ratings.find(r => r.Source == 'Rotten Tomatoes')?.Value;
                if (rtRating) detailsEl.querySelector(`.${DETAILS_CLASS_NAME}__ratings`).textContent = formatRtRating(rtRating);
                detailsEl.querySelector(`.${DETAILS_CLASS_NAME}__plot`).textContent = result.Plot;
                detailsEl.querySelector(`.${DETAILS_CLASS_NAME}__director`).textContent = result.Director;
                document.querySelector('.event-full .node-content .container .content')?.prepend(detailsEl);
            })
            .catch(error => console.log('error', error));
    }

    async function getOmdbApiKey() {
        const key = GM_getValue('key', null);
        if (key) {
            return fetch('https://www.omdbapi.com/?apikey=' + key).then(r => r.ok && key);
        }
    }

    function showApiKeyPrompt() {
        const KEY_POPUP_CLASS_NAME = 'cinematheqular-popup';
        GM_addStyle(`
            .${KEY_POPUP_CLASS_NAME} {
                position: fixed;
                bottom: 15px;
                width: 100%;
            }
        
            .${KEY_POPUP_CLASS_NAME}__wrapper {
                display: block;
                margin: 0 auto;
                background-color: white;
                width: fit-content;
                min-width: 250px;
                padding: 10px;
                border-radius: 30px;
                box-shadow: 0px 2px 8px rgba(0,0,0,0.15);
            }

            .${KEY_POPUP_CLASS_NAME}__form {
                display: flex;
                align-items: center;
                background: #F3F3F3;
                border-radius: 20px;
                height: 30px;
                padding: 0px 5px 0px 0px;
            }

            .${KEY_POPUP_CLASS_NAME}__input {
                flex-grow: 1;
                height: 100%;
                background: transparent;
                padding: 0px 3px 0px 20px;
                border-radius: 20px 0px 0px 20px;
                &:focus {
                    outline: none !important;
                }
                .${KEY_POPUP_CLASS_NAME}__form:has(&:focus) {
                    outline: solid 1px;
                }
            }

            .${KEY_POPUP_CLASS_NAME}__save {
                cursor: pointer;
                background: #34B47D;
                color: #ffffff;
                border-radius: 50%;
                height: 20px;
                width: 20px;
                display: grid;
                place-content: center;
            }

        `);
        const popupEl = document.createElement('div');
        popupEl.className = KEY_POPUP_CLASS_NAME;
        popupEl.innerHTML = `
            <div class="${KEY_POPUP_CLASS_NAME}__wrapper">
                <form class="${KEY_POPUP_CLASS_NAME}__form">
                    <input type="text" name="key" placeholder="Enter your OMDB API Key" class="${KEY_POPUP_CLASS_NAME}__input">
                    <button class="${KEY_POPUP_CLASS_NAME}__save">+</button>
                </form>
            </div>
        `;
        document.body.append(popupEl);
        popupEl.querySelector(`.${KEY_POPUP_CLASS_NAME}__form`).addEventListener('submit', e => {
            e.preventDefault();
            const key = e.currentTarget.querySelector('[name=key]').value.trim();
            GM_setValue('key', key);
            location.reload();
        });
    }

    function isAgendaPage() {
        return /\/cinematheque\/(?:film-programme|programm|agenda)$/.test(location.href);
    }

    function isDetailPage() {
        return true &&
            // Matches the URL pattern for an event page
            /^https:\/\/www\.vdl\.lu\/.*?\/(?:kalender|agenda|whats-on)\//.test(location.href) &&
            // Event location is Cinematheque
            document.querySelector('.infos-inner .place strong')?.textContent.trim().startsWith('Cinémathèque');
    }

    function isMainPage() {
        return /\/cinematheque$/.test(location.href);
    }

    async function auth(callback) {
        const key = await getOmdbApiKey();
        if (key) {
            if (typeof callback == 'function') callback(key);
        } else {
            showApiKeyPrompt();
        }
    }

    function route() {
        if (isAgendaPage()) {
            console.log('agenda');
            auth(agendaPage);
        } else if (isDetailPage()) {
            console.log('detail');
            auth(detailPage);
        } else if (isMainPage()) {
            console.log('main');
            auth();
        }
    }

    route();

    // FOR DEBUGGING ONLY
    
    function GM_clearValues() {
        for (const value of GM_listValues()) {
            GM_deleteValue(value);
            location.reload();
        }
    }

    unsafeWindow.GM_clearValues ??= GM_clearValues;

})();