NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==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 });
})();