Last active
March 22, 2026 14:53
-
-
Save Xorboo/4bb4b9a33fd3379ccf8cdc57ad2fa6c5 to your computer and use it in GitHub Desktop.
Trakt.tv torrent icon
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Trakt.tv quick torrent search | |
| // @namespace http://tampermonkey.net/ | |
| // @version 2.3 | |
| // @description Quickly open torrent search for the episode/movie. Works on Progress, Watchlist, and Discover pages. | |
| // @author Xorboo | |
| // @match https://trakt.tv/* | |
| // @match https://www.trakt.tv/* | |
| // @match https://app.trakt.tv/* | |
| // @grant none | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| // --- URL builder --- | |
| function buildSearchURL(title, season, episode) { | |
| const cleanTitle = title | |
| .toLowerCase() | |
| .replace(/[^a-z0-9\s]/g, '') | |
| .replace(/\s+/g, '+') | |
| .trim(); | |
| if (season != null && episode != null) { | |
| const s = season.toString().padStart(2, '0'); | |
| const e = episode.toString().padStart(2, '0'); | |
| return `https://1337x.to/search/${cleanTitle}+s${s}e${e}/1/`; | |
| } | |
| return `https://1337x.to/search/${cleanTitle}/1/`; | |
| } | |
| // --- Info extraction --- | |
| function extractCardInfo(card) { | |
| // Primary: footer title element (progress/watchlist cards) | |
| let title = card.querySelector('.trakt-card-title')?.textContent.trim(); | |
| // Fallback: popup menu button label attr (discover cards have no footer title) | |
| // label="Pop up menu for "Jarhead"" → extract the quoted name | |
| if (!title) { | |
| const menuBtn = card.querySelector('.trakt-popup-menu-button'); | |
| if (menuBtn) { | |
| const label = menuBtn.getAttribute('label') || ''; | |
| const match = label.match(/^Pop up menu for "(.+)"$/i); | |
| if (match) title = match[1].trim(); | |
| } | |
| } | |
| if (!title) return null; | |
| // Episode info from subtitle (e.g. "S1 • E8 - Episode Title") | |
| const subtitleEl = card.querySelector('.trakt-card-subtitle'); | |
| let season = null, episode = null; | |
| if (subtitleEl) { | |
| const match = subtitleEl.textContent.match(/S(\d+)\s*[•·]\s*E(\d+)/i); | |
| if (match) { | |
| season = parseInt(match[1]); | |
| episode = parseInt(match[2]); | |
| } | |
| } | |
| return { title, season, episode }; | |
| } | |
| // --- Button factory --- | |
| function createButton(url) { | |
| const btn = document.createElement('a'); | |
| btn.className = 'trakt-torrent-search-btn'; | |
| btn.href = url; | |
| btn.target = '_blank'; | |
| btn.rel = 'noopener noreferrer'; | |
| btn.title = 'Search torrent'; | |
| btn.style.cssText = ` | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| width: 32px; | |
| height: 32px; | |
| cursor: pointer; | |
| color: #aa3333; | |
| text-decoration: none; | |
| flex-shrink: 0; | |
| border-radius: 4px; | |
| transition: background 0.2s ease, color 0.2s ease; | |
| `; | |
| btn.innerHTML = ` | |
| <svg width="20" height="20" viewBox="0 0 24 24" fill="none" | |
| xmlns="http://www.w3.org/2000/svg" style="pointer-events:none"> | |
| <path d="M12 3v13M6 11l6 6 6-6" stroke="currentColor" stroke-width="2" | |
| stroke-linecap="round" stroke-linejoin="round"/> | |
| <path d="M4 20h16" stroke="currentColor" stroke-width="2" | |
| stroke-linecap="round"/> | |
| </svg> | |
| `; | |
| btn.addEventListener('mouseenter', () => { | |
| btn.style.background = '#aa3333'; | |
| btn.style.color = '#ffffff'; | |
| }); | |
| btn.addEventListener('mouseleave', () => { | |
| btn.style.background = 'transparent'; | |
| btn.style.color = '#aa3333'; | |
| }); | |
| return btn; | |
| } | |
| // --- Card handlers --- | |
| function handleFooterActionCard(card) { | |
| const info = extractCardInfo(card); | |
| if (!info) return false; | |
| const footerAction = card.querySelector('.trakt-card-footer-action'); | |
| if (!footerAction) return false; | |
| const url = buildSearchURL(info.title, info.season, info.episode); | |
| const btn = createButton(url); | |
| footerAction.style.cssText = 'display:flex; align-items:center; gap:4px;'; | |
| footerAction.insertBefore(btn, footerAction.firstChild); | |
| return true; | |
| } | |
| function handleDiscoverCard(card) { | |
| const info = extractCardInfo(card); | |
| if (!info) return false; | |
| const tagBar = card.querySelector('.trakt-card-footer-tag .trakt-tag-bar'); | |
| if (!tagBar) return false; | |
| const url = buildSearchURL(info.title, info.season, info.episode); | |
| tagBar.appendChild(createButton(url)); | |
| return true; | |
| } | |
| function handleSummaryCard(card) { | |
| const info = extractCardInfo(card); | |
| if (!info) return false; | |
| const bottomBar = card.querySelector('.trakt-summary-card-bottom-bar'); | |
| if (!bottomBar) return false; | |
| const url = buildSearchURL(info.title, info.season, info.episode); | |
| const btn = createButton(url); | |
| btn.style.marginLeft = '8px'; | |
| bottomBar.appendChild(btn); | |
| return true; | |
| } | |
| // --- Card type detection & dispatch --- | |
| function processCard(card) { | |
| if (card.dataset.traktTorrentDone) return; | |
| if (card.querySelector('.trakt-torrent-search-btn')) { | |
| card.dataset.traktTorrentDone = '1'; | |
| return; | |
| } | |
| let success = false; | |
| if (card.classList.contains('trakt-summary-card')) { | |
| success = handleSummaryCard(card); | |
| } else if (card.querySelector('.trakt-card-footer-tag .trakt-tag-bar')) { | |
| success = handleDiscoverCard(card); | |
| } else if (card.querySelector('.trakt-card-footer-action')) { | |
| success = handleFooterActionCard(card); | |
| } | |
| if (success) { | |
| card.dataset.traktTorrentDone = '1'; | |
| } | |
| } | |
| function processAllCards() { | |
| document.querySelectorAll('.trakt-card').forEach(processCard); | |
| } | |
| // --- SPA navigation detection --- | |
| // SvelteKit uses history.pushState for navigation — no page reload fires. | |
| // We patch pushState/replaceState and listen to popstate to detect URL changes, | |
| // then wait briefly for Svelte to render the new page content before scanning. | |
| let navigationTimer = null; | |
| function onNavigate() { | |
| // Debounce: Svelte may call pushState multiple times during a single navigation. | |
| // We also need to wait for the new DOM to render before scanning. | |
| clearTimeout(navigationTimer); | |
| navigationTimer = setTimeout(processAllCards, 300); | |
| } | |
| function patchHistoryMethod(method) { | |
| const original = history[method]; | |
| history[method] = function (...args) { | |
| original.apply(this, args); | |
| onNavigate(); | |
| }; | |
| } | |
| patchHistoryMethod('pushState'); | |
| patchHistoryMethod('replaceState'); | |
| window.addEventListener('popstate', onNavigate); | |
| // --- MutationObserver --- | |
| // Catches cards added dynamically (infinite scroll, lazy sections, etc.) | |
| // Debounced scan — batches rapid mutations into a single processAllCards call | |
| let scanTimer = null; | |
| function scheduleScan() { | |
| clearTimeout(scanTimer); | |
| scanTimer = setTimeout(processAllCards, 100); | |
| } | |
| function initObserver() { | |
| const observer = new MutationObserver(scheduleScan); | |
| observer.observe(document.body, { childList: true, subtree: true }); | |
| } | |
| // --- Init --- | |
| function init() { | |
| processAllCards(); | |
| initObserver(); | |
| } | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', init); | |
| } else { | |
| init(); | |
| } | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment