// ==UserScript==
// @name WeTrakr - Quick Link
// @version 1.2.0
// @description Floating button linking the current title/person page to its WeTrakr page
// @author Journey Over
// @license MIT
// @match *://imdb.com/*
// @match *://*.imdb.com/*
// @match *://thetvdb.com/*
// @match *://*.thetvdb.com/*
// @match *://themoviedb.org/*
// @match *://*.themoviedb.org/*
// @match *://letterboxd.com/*
// @match *://*.letterboxd.com/*
// @grant none
// @icon https://www.google.com/s2/favicons?sz=64&domain=wetrakr.com
// @homepageURL https://github.com/StylusThemes/Userscripts
// @downloadURL https://github.com/StylusThemes/Userscripts/raw/main/userscripts/wetrakr-quicklink.user.js
// @updateURL https://github.com/StylusThemes/Userscripts/raw/main/userscripts/wetrakr-quicklink.user.js
// ==/UserScript==
(function() {
'use strict';
const WETRAKR_PURPLE = '#4937E9';
const WETRAKR_ICON_URL = 'https://wetrakr.com/assets/images/we_small.png';
// ---------------------------------------------------------------------------
// URL builders
// ---------------------------------------------------------------------------
const wetrakrUrl = (path) => `https://wetrakr.com/${path}`;
const searchUrl = (kind, query) => wetrakrUrl(`search/${kind}?q=${encodeURIComponent(query)}`);
// ---------------------------------------------------------------------------
// Extraction helpers (small, focused, site-specific)
// ---------------------------------------------------------------------------
// IMDb episode pages use a tt-id just like title pages, but the
tag
// always contains "(TV Episode )" — episode titles vary wildly
// ("Episode #1.6", "Tuklo", "Episode dated 31 December 2024") but the
// parenthesised marker is consistent.
const isImdbEpisode = () => /\(TV Episode\b/i.test(document.title || '');
const imdbId = (path) => {
const match = path.match(/^\/(title|name)\/(tt\d+|nm\d+)\/?$/);
return match ? { kind: match[1], id: match[2] } : null;
};
// TVDB "Played By" actor name lives in a with a label; the
// actor's name is in the first or child — not the page ,
// which is the *character* name.
const tvdbPlayedByActor = () => {
for (const li of document.querySelectorAll('li')) {
const label = li.querySelector('strong');
if (label && /Played By/i.test(label.textContent)) {
const name = (li.querySelector('a') || li.querySelector('span'));
if (name && name.textContent.trim()) return name.textContent.trim();
}
}
return null;
};
const tvdbPageTitle = () => {
const h1 = document.querySelector('h1');
if (h1 && h1.textContent.trim()) return h1.textContent.trim();
return (document.title || '').replace(/\s*-\s*TheTVDB\.com\s*$/i, '').trim();
};
// TVDB series numeric ID: two strategies — (1) the "TheTVDB.com Series ID"
// label followed by digits in a
, (2) the /series//edit link.
const tvdbSeriesId = () => {
for (const li of document.querySelectorAll('li')) {
const label = li.querySelector('strong');
if (label && /TheTVDB\.com Series ID/i.test(label.textContent)) {
const id = li.textContent.replace(label.textContent, '').match(/\d+/);
if (id) return id[0];
}
}
const editLink = document.querySelector('a[href*="/series/"][href$="/edit"]');
if (editLink) {
const id = editLink.getAttribute('href').match(/\/series\/(\d+)\/edit/);
if (id) return id[1];
}
return null;
};
const tmdbMatch = (path) => {
if (/\/(season|episode)\//i.test(path)) return null;
return path.match(/^\/(movie|tv|person)\/(\d+)(?:-[^/]*)?(?:\/.*)?$/);
};
const letterboxdSlug = (path) => {
const match = path.match(/^\/film\/([^/]+)\/?/);
return match ? match[1] : null;
};
// ---------------------------------------------------------------------------
// Resolvers — one per hostname group
// ---------------------------------------------------------------------------
const TMDB_KIND = { movie: 'movie', tv: 'show', person: 'person' };
function resolveImdb() {
const info = imdbId(location.pathname);
if (!info) return null;
if (info.kind === 'title' && isImdbEpisode()) return null;
return wetrakrUrl(`imdb/${info.id}${info.kind === 'name' ? '/' : ''}`);
}
function resolveTvdb() {
const path = location.pathname;
// Cast-credit: /series|movies//people/
if (/^\/(series|movies)\/[^/]+\/people\/\d+\/?$/.test(path)) {
const actor = tvdbPlayedByActor();
return actor ? searchUrl('people', actor) : null;
}
// Top-level series: find the numeric TheTVDB ID
if (/^\/series\/[^/]+\/?$/.test(path)) {
const id = tvdbSeriesId();
return id ? wetrakrUrl(`tvdb/${id}`) : null;
}
// Movies: search fallback by title
if (/^\/movies\/[^/]+\/?$/.test(path) && path !== '/movies/create') {
const title = tvdbPageTitle();
return title ? searchUrl('movies', title) : null;
}
// Person: search fallback by title
if (/^\/people\/\d+(?:-[^/]*)?\/?$/.test(path)) {
const title = tvdbPageTitle();
return title ? searchUrl('people', title) : null;
}
return null;
}
function resolveTmdb() {
const match = tmdbMatch(location.pathname);
return match ? wetrakrUrl(`tmdb/${TMDB_KIND[match[1]]}/${match[2]}`) : null;
}
function resolveLetterboxd() {
const slug = letterboxdSlug(location.pathname);
return slug ? wetrakrUrl(`letterboxd/${slug}`) : null;
}
// ---------------------------------------------------------------------------
// Dispatch table: hostname → resolver
// ---------------------------------------------------------------------------
const RESOLVERS = {
'www.imdb.com': resolveImdb,
'thetvdb.com': resolveTvdb,
'www.thetvdb.com': resolveTvdb,
'www.themoviedb.org': resolveTmdb,
'letterboxd.com': resolveLetterboxd
};
function resolveWetrakrUrl() {
const resolver = RESOLVERS[location.hostname];
return resolver ? resolver() : null;
}
// ---------------------------------------------------------------------------
// UI — styles + button
// ---------------------------------------------------------------------------
function injectStyles() {
const style = document.createElement('style');
style.textContent = `
@keyframes wetrakr-pop { 0% { transform: scale(0); opacity: 0; } 60% { transform: scale(1.08); } 100% { transform: scale(1); opacity: 1; } }
#wetrakr-quicklink { position: fixed; bottom:24px; right:28px; z-index: 99999; }
#wetrakr-quicklink a { position: relative; display: flex; align-items: center; justify-content: center; width: 48px; height: 48px; border-radius: 14px; background: linear-gradient(145deg, #6c5ce7, ${WETRAKR_PURPLE}); border: 1px solid rgba(255,255,255,0.15); box-shadow: 0 2px 8px rgba(73,55,233,0.25), 0 1px 2px rgba(0,0,0,0.1); outline: none; text-decoration: none; animation: wetrakr-pop 0.4s cubic-bezier(0.34,1.56,0.64,1) both; transition: transform 0.2s cubic-bezier(0.4,0,0.2,1), box-shadow 0.2s ease; }
#wetrakr-quicklink a::after { content: attr(aria-label); position: absolute; left: 50%; bottom: calc(100% + 8px); transform: translateX(-50%) translateY(4px); z-index: 1; white-space: nowrap; padding: 5px 10px; border-radius: 6px; background: rgba(30,30,30,0.92); color: #fff; font: 600 12px/1.3 system-ui, -apple-system, sans-serif; opacity: 0; pointer-events: none; transition: opacity 0.15s ease, transform 0.15s ease; }
#wetrakr-quicklink a:hover { transform: translateY(-1px) scale(1.05); box-shadow: 0 6px 20px rgba(73,55,233,0.35), 0 2px 4px rgba(0,0,0,0.1); }
#wetrakr-quicklink a:hover::after { opacity: 1; transform: translateX(-50%) translateY(0); }
#wetrakr-quicklink a:active { transform: translateY(1px) scale(0.95); box-shadow: 0 1px 3px rgba(73,55,233,0.3); transition-duration: 0.08s; }
#wetrakr-quicklink a:focus-visible { outline: 2px solid rgba(255,255,255,0.9); outline-offset: 3px; }
#wetrakr-quicklink img { width: 26px; height: 26px; pointer-events: none; }
`.trim();
document.head.appendChild(style);
}
function createButton(url) {
const isSearch = /\/search\//.test(url);
const label = isSearch ? 'Search on WeTrakr' : 'Open on WeTrakr';
const wrap = document.createElement('div');
wrap.id = 'wetrakr-quicklink';
const link = document.createElement('a');
link.href = url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.setAttribute('aria-label', label);
const img = document.createElement('img');
img.src = WETRAKR_ICON_URL;
img.alt = '';
link.appendChild(img);
wrap.appendChild(link);
document.body.appendChild(wrap);
}
// ---------------------------------------------------------------------------
// Boot
// ---------------------------------------------------------------------------
const url = resolveWetrakrUrl();
if (url) {
injectStyles();
createButton(url);
}
})();