Cinematheqular

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

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

// ==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;

})();