nascent / PTP Movie Filter

// ==UserScript==
// @name         PTP Movie Filter
// @namespace    https://passthepopcorn.me
// @version      1.0
// @description  Adds seen/unseen, minimum rating, and title filtering to PassThePopcorn (PTP), as well as a mark-as-seen button to cover views
// @author       nascent
// @match        https://passthepopcorn.me/*
// @license      GPL-3.0-or-later
// @icon         https://www.google.com/s2/favicons?domain=passthepopcorn.me
// @updateURL    https://openuserjs.org/meta/nascent/PTP_Movie_Filter.meta.js
// @downloadURL  https://openuserjs.org/install/nascent/PTP_Movie_Filter.user.js
// @grant        GM_addStyle
// @grant        GM.xmlHttpRequest
// @grant        GM.setValue
// @grant        GM.getValue
// @grant        GM.registerMenuCommand
// ==/UserScript==

(function () {
    'use strict';

    // ─── SELECTORS ───────────────────────────────────────────────────────────
    // Each selector matches both the cover view (artist/people pages) and the
    // table/list view (torrents.php browse page) via comma-separated alternatives.
    const MOVIE_SEL  = 'div.cover-movie-list__movie, table.basic-movie-list tbody';
    const RATING_SEL = 'a.cover-movie-list__movie__rating, span.basic-movie-list__movie__rating__rating';
    const SEEN_CLASS = 'cover-movie-list__movie__cover-link--seen';  // cover view only
    const SEEN_SEL   = 'a.cover-movie-list__movie__cover-link--seen, a.basic-movie-list__movie__cover-link--seen';
    const TAG_SEL    = 'a.cover-movie-list__movie__tag, a.basic-movie-list__movie__tag';
    const COVER_SEL  = 'a.cover-movie-list__movie__cover-link, a.basic-movie-list__movie__cover-link';
    const TITLE_SEL  = '.cover-movie-list__movie__title, a.basic-movie-list__movie__title';
    const CLEAR_SEL  = 'div.cover-movie-list > div[style*="clear"]';
    const SEARCH_BAR = '.search-bar';

    // ─── STATE ───────────────────────────────────────────────────────────────
    let unwantedTags     = [];
    let showSeenFilter   = true;
    let showUnseenFilter = true;
    let titleFilter      = '';
    let ratingFilter     = 0;
    let hideUnwanted     = false;
    let barIsFloating    = false;

    // ─── STYLES ──────────────────────────────────────────────────────────────
    GM_addStyle(`
        .ptp-hidden { display: none !important; }

        div.cover-movie-list {
            display: flex !important;
            flex-wrap: wrap !important;
            align-items: flex-start !important;
        }
        div.cover-movie-list__movie { position: relative; }

        /* ── filter bar (below search bar / floating) ── */
        #ptp-filter-bar {
            display: flex;
            align-items: center;
            justify-content: center;
            gap: 8px;
            padding: 6px 12px;
            background: #1a1a1a;
            border: 1px solid #333;
            border-radius: 4px;
            flex-wrap: nowrap;
            white-space: nowrap;
            box-shadow: 0 2px 8px rgba(0,0,0,0.4);
            transition: all 0.3s ease;
        }
        #ptp-filter-bar.floating {
            position: fixed;
            top: 0;
            left: 50%;
            transform: translateX(-50%);
            z-index: 9998;
            width: auto;
            max-width: 95vw;
            border-radius: 0;
            box-shadow: 0 2px 12px rgba(0,0,0,0.6);
        }
        #ptp-filter-bar input[type=text] {
            background: #2a2a2a;
            border: 1px solid #444;
            color: #eee;
            padding: 3px 8px;
            border-radius: 3px;
            font-size: 12px;
            width: 150px;
        }
        #ptp-filter-bar button {
            background: #2a2a2a;
            border: 1px solid #555;
            color: #ccc;
            padding: 3px 9px;
            border-radius: 3px;
            cursor: pointer;
            font-size: 12px;
        }
        #ptp-filter-bar button:hover { background: #3a3a3a; }
        #ptp-filter-bar button.active { background: #4a90d9; border-color: #4a90d9; color: #fff; }
        #ptp-filter-bar .ptp-divider {
            width: 1px;
            height: 18px;
            background: #444;
            margin: 0 2px;
        }
        #ptp-filter-bar input[type=range] {
            width: 80px;
            cursor: pointer;
            accent-color: #4a90d9;
            vertical-align: middle;
        }
        #ptp-rating-label { color: #aaa; font-size: 11px; min-width: 28px; }
        #ptp-filter-count { color: #aaa; font-size: 11px; }

        /* ── mark-as-seen corner button ── */
        .ptp-seen-btn {
            position: absolute;
            top: 0;
            right: 0;
            width: 0;
            height: 0;
            border-style: solid;
            border-width: 0 38px 38px 0;
            border-color: transparent rgba(0,0,0,0.55) transparent transparent;
            cursor: pointer;
            opacity: 0;
            transition: opacity 0.2s;
            z-index: 10;
        }
        .ptp-seen-btn::after {
            content: '+';
            position: absolute;
            top: 3px;
            right: -34px;
            color: #fff;
            font-size: 17px;
            font-weight: bold;
            line-height: 1;
            pointer-events: none;
        }
        .cover-movie-list__movie:hover .ptp-seen-btn:not(.done) { opacity: 1; }
        table.basic-movie-list tbody:hover .ptp-seen-btn:not(.done) { opacity: 1; }
        .ptp-seen-btn:hover {
            border-color: transparent rgba(74,144,217,0.85) transparent transparent;
        }
        .ptp-seen-btn.marking {
            border-color: transparent rgba(74,144,217,0.7) transparent transparent;
            opacity: 1 !important;
        }
        .ptp-seen-btn.marking::after {
            content: '…'; font-size: 13px; top: 5px; right: -32px;
        }
        .ptp-seen-btn.done {
            border-color: transparent rgba(40,167,69,0.85) transparent transparent;
            opacity: 1 !important;
            pointer-events: none;
        }
        .ptp-seen-btn.done::after {
            content: '✓'; font-size: 15px; top: 4px; right: -32px;
        }
        .ptp-seen-btn.error {
            border-color: transparent rgba(220,53,69,0.85) transparent transparent;
            opacity: 1 !important;
        }
        .ptp-seen-btn.error::after {
            content: '✕'; font-size: 13px; top: 5px; right: -32px;
        }

        /* ── Genre dialog ── */
        #ptp-genre-overlay {
            display: none;
            position: fixed;
            inset: 0;
            background: rgba(0,0,0,0.55);
            z-index: 20000;
            align-items: center;
            justify-content: center;
        }
        #ptp-genre-overlay.ptp-open { display: flex; }
        #ptp-genre-dialog {
            background: #1e1e2e;
            color: #cdd6f4;
            border-radius: 10px;
            padding: 24px 28px;
            width: 380px;
            max-width: 95vw;
            box-shadow: 0 8px 32px rgba(0,0,0,0.6);
            font-family: sans-serif;
            font-size: 14px;
        }
        #ptp-genre-dialog h3 {
            margin: 0 0 16px;
            font-size: 16px;
            color: #cba6f7;
            display: flex;
            align-items: center;
            justify-content: space-between;
        }
        #ptp-genre-dialog h3 button {
            background: none; border: none; color: #6c7086;
            font-size: 20px; cursor: pointer; line-height: 1; padding: 0;
        }
        #ptp-genre-dialog h3 button:hover { color: #f38ba8; }
        #ptp-genre-input-row { display: flex; gap: 8px; margin-bottom: 14px; }
        #ptp-genre-input {
            flex: 1; padding: 7px 10px; border-radius: 6px;
            border: 1px solid #45475a; background: #313244;
            color: #cdd6f4; font-size: 14px; outline: none;
        }
        #ptp-genre-input:focus { border-color: #cba6f7; }
        #ptp-genre-add-btn {
            padding: 7px 14px; border-radius: 6px; border: none;
            background: #cba6f7; color: #1e1e2e;
            font-weight: bold; cursor: pointer; font-size: 14px;
        }
        #ptp-genre-add-btn:hover { background: #d0bcff; }
        #ptp-genre-list {
            list-style: none; margin: 0; padding: 0;
            max-height: 240px; overflow-y: auto;
        }
        #ptp-genre-list li {
            display: flex; align-items: center;
            justify-content: space-between;
            padding: 6px 10px; border-radius: 6px;
            margin-bottom: 4px; background: #313244;
        }
        #ptp-genre-list li:hover { background: #383850; }
        #ptp-genre-list li span { font-family: monospace; font-size: 13px; }
        #ptp-genre-list li button {
            background: none; border: none; color: #f38ba8;
            cursor: pointer; font-size: 16px; line-height: 1; padding: 0 2px;
        }
        #ptp-genre-list li button:hover { color: #ff5555; }
        #ptp-genre-empty {
            color: #6c7086; font-style: italic;
            text-align: center; padding: 12px 0;
        }
        #ptp-genre-hint {
            margin-top: 14px; color: #6c7086;
            font-size: 12px; line-height: 1.5;
        }
    `);

    // ─── HELPERS ─────────────────────────────────────────────────────────────
    function getAntiCsrfToken() {
        return document.body.dataset.anticsrftoken || '';
    }

    function getRating(movieDiv) {
        const el = movieDiv.querySelector(RATING_SEL);
        if (!el) return null;
        const val = parseFloat(el.textContent.trim());
        return isNaN(val) ? null : val;
    }

    function isSeen(movieDiv) {
        return (
            movieDiv.querySelector(SEEN_SEL) !== null ||
            movieDiv.querySelector('.ptp-seen-hidden') !== null
        );
    }

    function hasUnwantedTag(movieDiv) {
        const tags = movieDiv.querySelectorAll(TAG_SEL);
        return Array.from(tags).some(tag =>
            unwantedTags.includes(tag.textContent.trim().toLowerCase())
        );
    }

    function getGroupId(movieDiv) {
        const coverLink = movieDiv.querySelector(COVER_SEL);
        if (coverLink) {
            const m = (coverLink.getAttribute('href') || '').match(/[?&]id=(\d+)/);
            if (m) return m[1];
        }
        const rateLink = movieDiv.querySelector('a[onclick*="ShowMovieTooltipVoteWindow"]');
        if (rateLink) {
            const m = (rateLink.getAttribute('onclick') || '').match(/\d+/);
            if (m) return m[0];
        }
        return null;
    }

    // ─── FILTER LOGIC ────────────────────────────────────────────────────────
    function shouldHide(movieDiv) {
        const seen   = isSeen(movieDiv);
        const rating = getRating(movieDiv);

        // Seen/unseen bar filters
        if (seen  && !showSeenFilter)   return true;
        if (!seen && !showUnseenFilter) return true;

        // Title search
        if (titleFilter) {
            const titleEl = movieDiv.querySelector(TITLE_SEL);
            const title   = titleEl ? titleEl.textContent.toLowerCase() : '';
            if (!title.includes(titleFilter.toLowerCase())) return true;
        }

        // Rating filter
        if (ratingFilter > 0 && (rating === null || rating < ratingFilter)) return true;

        // Genre filter
        if (hideUnwanted && hasUnwantedTag(movieDiv)) return true;

        return false;
    }

    function applyFilters() {
        const movies = document.querySelectorAll(MOVIE_SEL);
        let visible = 0;

        movies.forEach(div => {
            const hide = shouldHide(div);
            div.classList.toggle('ptp-hidden', hide);
            if (!hide) visible++;
        });

        const countEl = document.getElementById('ptp-filter-count');
        if (countEl) countEl.textContent = `${visible} / ${movies.length}`;
    }

    // ─── MARK AS SEEN ────────────────────────────────────────────────────────
    function markAsSeen(groupId) {
        return new Promise((resolve, reject) => {
            GM.xmlHttpRequest({
                method: 'POST',
                url: 'https://passthepopcorn.me/torrents.php',
                headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
                data: `action=vote_torrent&groupid=${groupId}&vote=Seen&AntiCsrfToken=${getAntiCsrfToken()}`,
                onload:  r => r.status >= 200 && r.status < 300 ? resolve(r) : reject(new Error(`HTTP ${r.status}`)),
                onerror: reject
            });
        });
    }

    // ─── SEEN BUTTON ─────────────────────────────────────────────────────────
    function addSeenButton(movieDiv) {
        if (movieDiv.querySelector('.ptp-seen-btn')) return;

        // For list view (tbody), attach button to the cover image cell
        const isListView = movieDiv.tagName === 'TBODY';
        let container = movieDiv;
        let seenClass = SEEN_CLASS;
        if (isListView) {
            const detailsRow = movieDiv.querySelector('tr.basic-movie-list__details-row');
            if (!detailsRow) return;
            const coverTd = detailsRow.querySelector('td:first-child');
            if (!coverTd) return;
            coverTd.style.position = 'relative';
            container = coverTd;
            seenClass = 'basic-movie-list__movie__cover-link--seen';
        }

        const btn = document.createElement('div');
        btn.className = 'ptp-seen-btn';
        btn.title = 'Mark as Seen';
        if (isSeen(movieDiv)) btn.classList.add('done');

        btn.addEventListener('click', async e => {
            e.preventDefault();
            e.stopPropagation();
            if (btn.classList.contains('done') || btn.classList.contains('marking')) return;

            const groupId = getGroupId(movieDiv);
            if (!groupId) return;

            btn.classList.add('marking');
            btn.title = 'Marking…';

            try {
                await markAsSeen(groupId);
                btn.classList.remove('marking');
                btn.classList.add('done');
                btn.title = 'Seen ✓';

                const coverLink = movieDiv.querySelector(COVER_SEL);
                if (coverLink) coverLink.classList.add(seenClass);

                const sentinel = document.createElement('span');
                sentinel.className = 'ptp-seen-hidden';
                sentinel.style.display = 'none';
                movieDiv.appendChild(sentinel);

                applyFilters();
            } catch (err) {
                console.error('PTP Filter: markAsSeen failed', err);
                btn.classList.remove('marking');
                btn.classList.add('error');
                btn.title = 'Error – click to retry';
                setTimeout(() => { btn.classList.remove('error'); btn.title = 'Mark as Seen'; }, 3000);
            }
        });

        container.appendChild(btn);
    }

    function addSeenButtons() {
        document.querySelectorAll(MOVIE_SEL).forEach(addSeenButton);
    }

    // ─── GENRE DIALOG SETUP ──────────────────────────────────────────────────
    const overlay = document.createElement('div');
    overlay.id = 'ptp-genre-overlay';
    // Set layout-critical styles inline so PTP's stylesheet can't interfere
    Object.assign(overlay.style, {
        display: 'none', position: 'fixed',
        top: '0', right: '0', bottom: '0', left: '0',
        background: 'rgba(0,0,0,0.55)', zIndex: '2147483647',
        alignItems: 'center', justifyContent: 'center',
    });
    overlay.innerHTML = `
        <div id="ptp-genre-dialog">
            <h3>Genre Filters <button id="ptp-genre-close" title="Close">✕</button></h3>
            <div id="ptp-genre-input-row">
                <input id="ptp-genre-input" type="text" placeholder="e.g. documentary" spellcheck="false"/>
                <button id="ptp-genre-add-btn">Add</button>
            </div>
            <ul id="ptp-genre-list"></ul>
            <p id="ptp-genre-hint">
                Movies matching <em>any</em> listed genre will be hidden when the
                <strong>Genre</strong> filter is active.
            </p>
        </div>`;
    document.body.appendChild(overlay);

    const genreInput = document.getElementById('ptp-genre-input');
    const genreList  = document.getElementById('ptp-genre-list');

    function renderGenreList() {
        genreList.innerHTML = '';
        if (unwantedTags.length === 0) {
            genreList.innerHTML = '<li id="ptp-genre-empty"><span>No genres added yet.</span></li>';
            return;
        }
        unwantedTags.forEach(tag => {
            const li  = document.createElement('li');
            const sp  = document.createElement('span');
            sp.textContent = tag;
            const rb  = document.createElement('button');
            rb.textContent = '✕';
            rb.title = `Remove "${tag}"`;
            rb.addEventListener('click', async () => {
                unwantedTags = unwantedTags.filter(t => t !== tag);
                await GM.setValue('unwantedTags', unwantedTags);
                renderGenreList();
                applyFilters();
            });
            li.append(sp, rb);
            genreList.appendChild(li);
        });
    }

    async function addGenre() {
        const val = genreInput.value.trim().toLowerCase();
        if (!val || unwantedTags.includes(val)) { genreInput.focus(); return; }
        unwantedTags.push(val);
        unwantedTags.sort();
        await GM.setValue('unwantedTags', unwantedTags);
        genreInput.value = '';
        genreInput.focus();
        renderGenreList();
        applyFilters();
    }

    function openGenreDialog() {
        if (!document.body.contains(overlay)) document.body.appendChild(overlay);
        overlay.style.display = 'flex';
        renderGenreList();
        genreInput.focus();
    }
    function closeGenreDialog() {
        overlay.style.display = 'none';
    }

    document.getElementById('ptp-genre-close').addEventListener('click', closeGenreDialog);
    document.getElementById('ptp-genre-add-btn').addEventListener('click', addGenre);
    genreInput.addEventListener('keydown', e => { if (e.key === 'Enter') addGenre(); });
    overlay.addEventListener('click', e => { if (e.target === overlay) closeGenreDialog(); });

    GM.registerMenuCommand('Manage Genre Filters', openGenreDialog);

    // ─── TOOLBAR ─────────────────────────────────────────────────────────────
    let filterBar = null;
    let barContainer = null;

    function buildToolbar() {
        if (filterBar) return;
        if (!document.querySelector(MOVIE_SEL)) return;

        barContainer = document.createElement('div');
        barContainer.style.marginTop = '8px';
        barContainer.style.marginBottom = '12px';

        filterBar = document.createElement('div');
        filterBar.id = 'ptp-filter-bar';

        const seenBtn = document.createElement('button');
        seenBtn.textContent = 'Seen';
        seenBtn.classList.toggle('active', !showSeenFilter);
        seenBtn.addEventListener('click', () => {
            showSeenFilter = !showSeenFilter;
            seenBtn.classList.toggle('active', !showSeenFilter);
            GM.setValue('showSeenFilter', showSeenFilter);
            applyFilters();
        });

        const unseenBtn = document.createElement('button');
        unseenBtn.textContent = 'Unseen';
        unseenBtn.classList.toggle('active', !showUnseenFilter);
        unseenBtn.addEventListener('click', () => {
            showUnseenFilter = !showUnseenFilter;
            unseenBtn.classList.toggle('active', !showUnseenFilter);
            GM.setValue('showUnseenFilter', showUnseenFilter);
            applyFilters();
        });

        const searchInput = document.createElement('input');
        searchInput.type = 'text';
        searchInput.placeholder = 'Filter by title…';
        searchInput.addEventListener('input', () => {
            titleFilter = searchInput.value;
            applyFilters();
        });

        const ratingSlider = document.createElement('input');
        ratingSlider.type = 'range';
        ratingSlider.min = '0';
        ratingSlider.max = '10';
        ratingSlider.step = '0.5';
        ratingSlider.value = String(ratingFilter);
        ratingSlider.title = 'Minimum rating (0 = no filter)';

        const ratingLabel = document.createElement('span');
        ratingLabel.id = 'ptp-rating-label';
        ratingLabel.textContent = ratingFilter === 0 ? 'off' : `≥${ratingFilter.toFixed(1)}`;

        ratingSlider.addEventListener('input', () => {
            ratingFilter = parseFloat(ratingSlider.value);
            ratingLabel.textContent = ratingFilter === 0 ? 'off' : `≥${ratingFilter.toFixed(1)}`;
            GM.setValue('ratingFilter', ratingFilter);
            applyFilters();
        });

        const genreBarBtn = document.createElement('button');
        genreBarBtn.textContent = 'Genre';
        genreBarBtn.classList.toggle('active', hideUnwanted);
        genreBarBtn.addEventListener('click', () => {
            hideUnwanted = !hideUnwanted;
            GM.setValue('hideUnwanted', hideUnwanted);
            genreBarBtn.classList.toggle('active', hideUnwanted);
            applyFilters();
        });

        const manageGenreBtn = document.createElement('button');
        manageGenreBtn.textContent = '✎';
        manageGenreBtn.title = 'Manage genre filters';
        manageGenreBtn.addEventListener('click', openGenreDialog);

        const resetBtn = document.createElement('button');
        resetBtn.textContent = 'Reset';
        resetBtn.addEventListener('click', () => {
            showSeenFilter = true; showUnseenFilter = true; titleFilter = '';
            ratingFilter = 0; hideUnwanted = false;
            GM.setValue('showSeenFilter', true);
            GM.setValue('showUnseenFilter', true);
            GM.setValue('ratingFilter', 0);
            GM.setValue('hideUnwanted', false);
            searchInput.value = '';
            ratingSlider.value = '0';
            ratingLabel.textContent = 'off';
            seenBtn.classList.remove('active');
            unseenBtn.classList.remove('active');
            genreBarBtn.classList.remove('active');
            applyFilters();
        });

        const divider = document.createElement('div');
        divider.className = 'ptp-divider';

        const countEl = document.createElement('span');
        countEl.id = 'ptp-filter-count';

        filterBar.append(
            seenBtn, unseenBtn, divider.cloneNode(),
            searchInput, divider.cloneNode(),
            ratingSlider, ratingLabel, divider.cloneNode(),
            genreBarBtn, manageGenreBtn, divider.cloneNode(),
            resetBtn, divider.cloneNode(),
            countEl
        );

        barContainer.appendChild(filterBar);

        // Insert right after the search bar
        const searchBar = document.querySelector(SEARCH_BAR);
        if (searchBar && searchBar.parentElement) {
            searchBar.parentElement.insertBefore(barContainer, searchBar.nextSibling);
        } else {
            document.body.insertBefore(barContainer, document.body.firstChild);
        }
    }

    // ─── SCROLL LISTENER FOR FLOATING BAR ────────────────────────────────────
    function updateBarFloating() {
        if (!filterBar || !barContainer) return;

        const rect = barContainer.getBoundingClientRect();
        const shouldFloat = rect.top < 0;

        if (shouldFloat && !barIsFloating) {
            barIsFloating = true;
            filterBar.classList.add('floating');
        } else if (!shouldFloat && barIsFloating) {
            barIsFloating = false;
            filterBar.classList.remove('floating');
        }
    }

    window.addEventListener('scroll', updateBarFloating, { passive: true });

    // ─── SPACERS ─────────────────────────────────────────────────────────────
    function removeClearSpacers() {
        document.querySelectorAll(CLEAR_SEL).forEach(el => el.remove());
    }

    // ─── INIT ────────────────────────────────────────────────────────────────
    async function initialize() {
        hideUnwanted     = await GM.getValue('hideUnwanted', false);
        unwantedTags     = await GM.getValue('unwantedTags', []);
        ratingFilter     = await GM.getValue('ratingFilter', 0);
        showSeenFilter   = await GM.getValue('showSeenFilter', true);
        showUnseenFilter = await GM.getValue('showUnseenFilter', true);

        removeClearSpacers();
        buildToolbar();
        addSeenButtons();
        applyFilters();
    }

    initialize();

    // ─── MUTATION OBSERVER ───────────────────────────────────────────────────
    let debounce;
    const observer = new MutationObserver(mutations => {
        if (mutations.some(m => m.addedNodes.length > 0 && !overlay.contains(m.target))) {
            clearTimeout(debounce);
            debounce = setTimeout(() => {
                removeClearSpacers();
                buildToolbar();
                addSeenButtons();
                applyFilters();
            }, 200);
        }
    });

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

})();