diff --git a/app/config.py b/app/config.py index 1f7698e..b7dbc5c 100644 --- a/app/config.py +++ b/app/config.py @@ -117,6 +117,17 @@ # ISO 3166-1 alpha-2 country code used to look up JustWatch availability "STREAMING_COUNTRY": "US", }, + "TRAKT": { + "TRAKT_ENABLED": False, + "TRAKT_CLIENT_ID": "", + "TRAKT_CLIENT_SECRET": "", + # Managed by the device-code OAuth flow — not user-editable + "TRAKT_ACCESS_TOKEN": "", + "TRAKT_REFRESH_TOKEN": "", + "TRAKT_USERNAME": "", + # true = hide watched from grids; false = show with a badge + "TRAKT_HIDE_WATCHED": False, + }, "AUTH": { # "None" | "Forms" | "DisabledForLocalAddresses" "AUTH_METHOD": "None", diff --git a/app/routers/trakt.py b/app/routers/trakt.py new file mode 100644 index 0000000..0772734 --- /dev/null +++ b/app/routers/trakt.py @@ -0,0 +1,302 @@ +""" +Trakt.tv integration — device-code OAuth + watched-movie history. + + POST /api/trakt/device/code — start device-code flow + POST /api/trakt/device/poll — poll for token (frontend calls every 5 s) + POST /api/trakt/disconnect — revoke / clear stored tokens + GET /api/trakt/watched — return TMDB IDs of watched movies (cached 1 h) + GET /api/trakt/status — connection state for the config UI +""" +import os +import time +import logging + +import yaml +import requests +from fastapi import APIRouter, Body + +from app.config import load_config, save_config, CONFIG_FILE, ensure_config_dir + +router = APIRouter() +log = logging.getLogger("cineplete") + +_TRAKT_BASE = "https://api.trakt.tv" +_CACHE_TTL = 3600 # 1 hour + +_watched_cache: dict = {"data": None, "ts": 0.0} + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +def _trakt_headers(client_id: str, access_token: str = "") -> dict: + h = { + "Content-Type": "application/json", + "trakt-api-version": "2", + "trakt-api-key": client_id, + } + if access_token: + h["Authorization"] = f"Bearer {access_token}" + return h + + +def _refresh_access_token(cfg: dict) -> dict | None: + """Use the stored refresh token to get a new access token. + Returns updated TRAKT config dict on success, None on failure.""" + trakt = cfg.get("TRAKT", {}) + client_id = trakt.get("TRAKT_CLIENT_ID", "").strip() + client_secret = trakt.get("TRAKT_CLIENT_SECRET", "").strip() + refresh_token = trakt.get("TRAKT_REFRESH_TOKEN", "").strip() + if not all([client_id, client_secret, refresh_token]): + return None + try: + r = requests.post( + f"{_TRAKT_BASE}/oauth/token", + json={ + "refresh_token": refresh_token, + "client_id": client_id, + "client_secret": client_secret, + "redirect_uri": "urn:ietf:wg:oauth:2.0:oob", + "grant_type": "refresh_token", + }, + timeout=15, + ) + if r.status_code == 200: + data = r.json() + trakt["TRAKT_ACCESS_TOKEN"] = data["access_token"] + trakt["TRAKT_REFRESH_TOKEN"] = data["refresh_token"] + cfg["TRAKT"] = trakt + save_config(cfg) + log.info("Trakt: access token refreshed") + return trakt + except requests.exceptions.RequestException as e: + log.warning(f"Trakt token refresh failed: {e}") + return None + + +def _fetch_watched(client_id: str, access_token: str) -> list[int]: + """Return list of TMDB IDs from the authenticated user's watched movies.""" + try: + r = requests.get( + f"{_TRAKT_BASE}/users/me/watched/movies", + headers=_trakt_headers(client_id, access_token), + timeout=30, + ) + if r.status_code == 401: + return None # signal: token needs refresh + if r.status_code == 200: + tmdb_ids = [] + for entry in r.json(): + tmdb_id = entry.get("movie", {}).get("ids", {}).get("tmdb") + if tmdb_id: + tmdb_ids.append(int(tmdb_id)) + return tmdb_ids + except requests.exceptions.RequestException as e: + log.warning(f"Trakt watched fetch failed: {e}") + return [] + + +# --------------------------------------------------------------------------- +# Routes +# --------------------------------------------------------------------------- + +@router.post("/api/trakt/device/code") +def trakt_device_code(payload: dict = Body(...)): + """ + Start the Trakt device-code flow. + Accepts client_id (and optionally client_secret) in the request body so + the user does not have to save config before connecting. + """ + client_id = str(payload.get("client_id", "")).strip() + client_secret = str(payload.get("client_secret", "")).strip() + + if not client_id: + return {"ok": False, "error": "Client ID is required"} + + try: + r = requests.post( + f"{_TRAKT_BASE}/oauth/device/code", + json={"client_id": client_id}, + headers={"Content-Type": "application/json"}, + timeout=15, + ) + except requests.exceptions.RequestException as e: + return {"ok": False, "error": str(e)} + + if r.status_code != 200: + return {"ok": False, "error": f"Trakt returned HTTP {r.status_code}"} + + data = r.json() + return { + "ok": True, + "device_code": data["device_code"], + "user_code": data["user_code"], + "verification_url": data.get("verification_url", "https://trakt.tv/activate"), + "expires_in": data.get("expires_in", 600), + "interval": data.get("interval", 5), + } + + +@router.post("/api/trakt/device/poll") +def trakt_device_poll(payload: dict = Body(...)): + """ + Poll Trakt to see if the user has approved the device code. + On success: saves tokens + username to config, clears watched cache. + Returns { ok, status } where status is one of: + "pending" | "success" | "denied" | "expired" | "error" + """ + client_id = str(payload.get("client_id", "")).strip() + client_secret = str(payload.get("client_secret", "")).strip() + device_code = str(payload.get("device_code", "")).strip() + + if not all([client_id, client_secret, device_code]): + return {"ok": False, "status": "error", "error": "Missing required fields"} + + try: + r = requests.post( + f"{_TRAKT_BASE}/oauth/device/token", + json={ + "code": device_code, + "client_id": client_id, + "client_secret": client_secret, + }, + headers={"Content-Type": "application/json"}, + timeout=15, + ) + except requests.exceptions.RequestException as e: + return {"ok": False, "status": "error", "error": str(e)} + + if r.status_code == 200: + data = r.json() + access_token = data["access_token"] + + # Fetch username + username = "" + try: + u = requests.get( + f"{_TRAKT_BASE}/users/me", + headers=_trakt_headers(client_id, access_token), + timeout=10, + ) + if u.status_code == 200: + username = u.json().get("username", "") + except requests.exceptions.RequestException: + pass + + # Persist to config + cfg = load_config() + trakt = cfg.get("TRAKT", {}) + trakt.update({ + "TRAKT_ENABLED": True, + "TRAKT_CLIENT_ID": client_id, + "TRAKT_CLIENT_SECRET": client_secret, + "TRAKT_ACCESS_TOKEN": access_token, + "TRAKT_REFRESH_TOKEN": data["refresh_token"], + "TRAKT_USERNAME": username, + }) + cfg["TRAKT"] = trakt + save_config(cfg) + _watched_cache["ts"] = 0.0 # bust cache + log.info(f"Trakt: connected as @{username}") + return {"ok": True, "status": "success", "username": username} + + status_map = {400: "pending", 404: "error", 409: "error", + 410: "expired", 418: "denied", 429: "pending"} + status = status_map.get(r.status_code, "error") + return {"ok": False, "status": status} + + +@router.post("/api/trakt/disconnect") +def trakt_disconnect(): + """Clear stored Trakt tokens from config. + + Patches the YAML file directly rather than going through save_config's + full-config merge, guaranteeing the tokens are wiped even if other + sections of the config are missing from memory. + """ + ensure_config_dir() + try: + if os.path.exists(CONFIG_FILE): + with open(CONFIG_FILE, "r", encoding="utf-8") as f: + raw = yaml.safe_load(f) or {} + else: + raw = {} + except (OSError, yaml.YAMLError): + raw = {} + + trakt = raw.get("TRAKT", {}) + trakt.update({ + "TRAKT_ENABLED": False, + "TRAKT_ACCESS_TOKEN": "", + "TRAKT_REFRESH_TOKEN": "", + "TRAKT_USERNAME": "", + }) + raw["TRAKT"] = trakt + + with open(CONFIG_FILE, "w", encoding="utf-8") as f: + yaml.safe_dump(raw, f, sort_keys=False, allow_unicode=True) + + _watched_cache["data"] = None + _watched_cache["ts"] = 0.0 + log.info("Trakt: disconnected, tokens cleared") + return {"ok": True} + + +@router.get("/api/trakt/watched") +def trakt_watched(): + """ + Return TMDB IDs of all movies in the authenticated user's Trakt watch history. + Cached for 1 hour. Silently returns [] when Trakt is disabled / not connected. + """ + now = time.time() + if _watched_cache["data"] is not None and now - _watched_cache["ts"] < _CACHE_TTL: + return _watched_cache["data"] + + cfg = load_config() + trakt = cfg.get("TRAKT", {}) + + if not trakt.get("TRAKT_ENABLED") or not trakt.get("TRAKT_ACCESS_TOKEN"): + result = {"ok": True, "tmdb_ids": []} + _watched_cache.update({"data": result, "ts": now}) + return result + + client_id = trakt.get("TRAKT_CLIENT_ID", "").strip() + access_token = trakt.get("TRAKT_ACCESS_TOKEN", "").strip() + + tmdb_ids = _fetch_watched(client_id, access_token) + + if tmdb_ids is None: + # 401 — try a token refresh + refreshed = _refresh_access_token(cfg) + if refreshed: + access_token = refreshed.get("TRAKT_ACCESS_TOKEN", "") + tmdb_ids = _fetch_watched(client_id, access_token) or [] + else: + tmdb_ids = [] + + result = {"ok": True, "tmdb_ids": tmdb_ids} + _watched_cache.update({"data": result, "ts": now}) + log.info(f"Trakt: {len(tmdb_ids)} watched movies cached") + return result + + +@router.post("/api/trakt/watched/refresh") +def trakt_watched_refresh(): + """Bust the watched cache so the next GET re-fetches from Trakt.""" + _watched_cache["ts"] = 0.0 + return {"ok": True} + + +@router.get("/api/trakt/status") +def trakt_status(): + """Return connection state for the config UI.""" + cfg = load_config() + trakt = cfg.get("TRAKT", {}) + connected = bool(trakt.get("TRAKT_ACCESS_TOKEN")) + return { + "ok": True, + "connected": connected, + "username": trakt.get("TRAKT_USERNAME", "") if connected else "", + "enabled": trakt.get("TRAKT_ENABLED", False), + } diff --git a/app/web.py b/app/web.py index 726af69..8b52886 100644 --- a/app/web.py +++ b/app/web.py @@ -17,7 +17,7 @@ from app.auth import COOKIE_NAME, get_client_ip, is_local_address, verify_token from app import scheduler -from app.routers import auth, config, scan, overrides, letterboxd, integrations, cache, theaters, streaming, quality +from app.routers import auth, config, scan, overrides, letterboxd, integrations, cache, theaters, streaming, quality, trakt _BASE_DIR = Path(__file__).resolve().parent.parent STATIC_DIR = os.getenv("STATIC_DIR", str(_BASE_DIR / "static")) @@ -93,3 +93,4 @@ async def dispatch(self, request: Request, call_next): app.include_router(theaters.router) app.include_router(streaming.router) app.include_router(quality.router) +app.include_router(trakt.router) diff --git a/e2e/tests/trakt.spec.js b/e2e/tests/trakt.spec.js new file mode 100644 index 0000000..87d624f --- /dev/null +++ b/e2e/tests/trakt.spec.js @@ -0,0 +1,316 @@ +// @ts-check +const { test, expect } = require('@playwright/test') + +/** + * E2E tests for Trakt.tv integration. + * + * All backend calls are mocked — no live Trakt/TMDB access needed. + * + * Covers: + * - Config UI: Trakt section visible with connect form when not connected + * - Config UI: shows username + Disconnect button when connected + * - Config UI: device-code flow (Connect button → code display) + * - Cards: watched badge shown when movie is in Trakt watch history + * - Cards: hide-watched hides movies from grids (not wishlist) + */ + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const TMDB_WATCHED = 550 // Fight Club — "watched" +const TMDB_UNWATCHED = 278 // Shawshank — not watched + +const MOVIE_WATCHED = { tmdb: TMDB_WATCHED, title: 'Fight Club', year: '1999', poster: null, rating: 8.8, wishlist: false } +const MOVIE_UNWATCHED = { tmdb: TMDB_UNWATCHED, title: 'The Shawshank Redemption', year: '1994', poster: null, rating: 9.3, wishlist: false } + +const RESULTS_STUB = { + ok: true, + configured: true, + scanning: false, + sections: {}, + classics: [MOVIE_WATCHED, MOVIE_UNWATCHED], + suggestions:[], + franchises: [], + directors: [], + actors: [], + wishlist: [MOVIE_WATCHED], // also in wishlist to test skipWatchedFilter +} + +const CONFIG_TRAKT_DISABLED = { + PLEX: {}, TMDB: { TMDB_API_KEY: 'test' }, + RADARR: { RADARR_ENABLED: false }, + RADARR_4K: { RADARR_4K_ENABLED: false }, + OVERSEERR: { OVERSEERR_ENABLED: false }, + JELLYSEERR: { JELLYSEERR_ENABLED: false }, + SEERR: { SEERR_ENABLED: false }, + STREAMING: { STREAMING_COUNTRY: 'US' }, + AUTH: { AUTH_METHOD: 'None' }, + TRAKT: { + TRAKT_ENABLED: false, + TRAKT_CLIENT_ID: '', + TRAKT_CLIENT_SECRET: '', + TRAKT_ACCESS_TOKEN: '', + TRAKT_REFRESH_TOKEN: '', + TRAKT_USERNAME: '', + TRAKT_HIDE_WATCHED: false, + }, +} + +const CONFIG_TRAKT_CONNECTED = { + ...CONFIG_TRAKT_DISABLED, + TRAKT: { + TRAKT_ENABLED: true, + TRAKT_CLIENT_ID: 'my-client-id', + TRAKT_CLIENT_SECRET: 'my-secret', + TRAKT_ACCESS_TOKEN: 'tok_abc', + TRAKT_REFRESH_TOKEN: 'ref_abc', + TRAKT_USERNAME: 'filmlover', + TRAKT_HIDE_WATCHED: false, + }, +} + +const CONFIG_TRAKT_HIDE = { + ...CONFIG_TRAKT_CONNECTED, + TRAKT: { ...CONFIG_TRAKT_CONNECTED.TRAKT, TRAKT_HIDE_WATCHED: true }, +} + +const WATCHED_STUB = { ok: true, tmdb_ids: [TMDB_WATCHED] } +const STATUS_DISCONNECTED = { ok: true, connected: false, username: '', enabled: false } +const STATUS_CONNECTED = { ok: true, connected: true, username: 'filmlover', enabled: true } + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async function setupMocks(page, { config = CONFIG_TRAKT_DISABLED, watched = WATCHED_STUB } = {}) { + await page.route('**/api/config/status', r => + r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ configured: true, issues: [] }) })) + await page.route('**/api/config', r => + r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(config) })) + await page.route('**/api/results', r => + r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(RESULTS_STUB) })) + await page.route('**/api/trakt/watched', r => + r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(watched) })) + // Catch-all for movie details + await page.route('**/api/movie/**', r => + r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ title: 'Test', cast: [] }) })) + await page.route('**/api/streaming/**', r => + r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, providers: [] }) })) + await page.route('**/api/radarr/library', r => + r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, tmdb_ids: [] }) })) +} + +// --------------------------------------------------------------------------- +// Config UI: Trakt section +// --------------------------------------------------------------------------- + +test.describe('Trakt config section', () => { + + test('shows connect form when not connected', async ({ page }) => { + await setupMocks(page) + await page.goto('/') + await page.locator('button.nav[data-tab="config"]').click() + await page.waitForSelector('#cfg_trakt_id', { timeout: 5000 }) + + // Client ID and Secret fields visible + await expect(page.locator('#cfg_trakt_id')).toBeVisible() + await expect(page.locator('#cfg_trakt_secret')).toBeVisible() + + // Connect button visible + await expect(page.locator('button:has-text("Connect via Trakt")')).toBeVisible() + + // Connected box hidden + await expect(page.locator('#traktConnectedBox')).not.toBeVisible() + }) + + test('shows username and disconnect button when connected', async ({ page }) => { + await setupMocks(page, { config: CONFIG_TRAKT_CONNECTED }) + await page.goto('/') + await page.locator('button.nav[data-tab="config"]').click() + await page.waitForSelector('#traktConnectedBox', { timeout: 5000 }) + + // Connected box shows username + await expect(page.locator('#traktConnectedBox')).toContainText('@filmlover') + + // Disconnect button present + await expect(page.locator('button:has-text("Disconnect")')).toBeVisible() + + // Connect form hidden + await expect(page.locator('#traktConnectBox')).not.toBeVisible() + }) + + test('TRAKT_HIDE_WATCHED checkbox reflects config', async ({ page }) => { + await setupMocks(page, { config: CONFIG_TRAKT_HIDE }) + await page.goto('/') + await page.locator('button.nav[data-tab="config"]').click() + await page.waitForSelector('#cfg_trakt_hide', { timeout: 5000 }) + + // Checkbox should be checked when TRAKT_HIDE_WATCHED is true + await expect(page.locator('#cfg_trakt_hide')).toBeChecked() + }) + + test('TRAKT_HIDE_WATCHED unchecked by default', async ({ page }) => { + await setupMocks(page, { config: CONFIG_TRAKT_CONNECTED }) + await page.goto('/') + await page.locator('button.nav[data-tab="config"]').click() + await page.waitForSelector('#cfg_trakt_hide', { timeout: 5000 }) + + await expect(page.locator('#cfg_trakt_hide')).not.toBeChecked() + }) + +}) + +// --------------------------------------------------------------------------- +// Config UI: device code connect flow +// --------------------------------------------------------------------------- + +test.describe('Trakt device code connect flow', () => { + + test('displays user code after clicking Connect', async ({ page }) => { + await setupMocks(page) + await page.goto('/') + await page.locator('button.nav[data-tab="config"]').click() + await page.waitForSelector('#cfg_trakt_id', { timeout: 5000 }) + + // Fill in credentials + await page.fill('#cfg_trakt_id', 'my-client-id') + await page.fill('#cfg_trakt_secret', 'my-secret') + + // Mock device code endpoint + await page.route('**/api/trakt/device/code', r => + r.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ok: true, + device_code: 'dev123', + user_code: 'HELLO-WORLD', + verification_url: 'https://trakt.tv/activate', + expires_in: 600, + interval: 5, + }), + })) + + await page.route('**/api/trakt/device/poll', r => + r.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ok: false, status: 'pending' }), + })) + + await page.click('button:has-text("Connect via Trakt")') + + // Device box should appear with the user code + const deviceBox = page.locator('#traktDeviceBox') + await expect(deviceBox).toBeVisible({ timeout: 3000 }) + await expect(deviceBox).toContainText('HELLO-WORLD') + await expect(deviceBox).toContainText('trakt.tv/activate') + }) + + test('shows error when client id missing', async ({ page }) => { + await setupMocks(page) + await page.goto('/') + await page.locator('button.nav[data-tab="config"]').click() + await page.waitForSelector('#cfg_trakt_id', { timeout: 5000 }) + + // Don't fill in any credentials + await page.click('button:has-text("Connect via Trakt")') + + // Should show a toast error, not a device box + await expect(page.locator('#traktDeviceBox')).not.toBeVisible() + }) + +}) + +// --------------------------------------------------------------------------- +// Cards: watched badge overlay +// --------------------------------------------------------------------------- + +test.describe('Trakt watched badge on cards', () => { + + test('watched badge appears on watched movie card', async ({ page }) => { + await setupMocks(page, { config: CONFIG_TRAKT_CONNECTED, watched: WATCHED_STUB }) + await page.goto('/') + + // Navigate to Classics where MOVIE_WATCHED appears + await page.locator('button.nav[data-tab="classics"]').click() + + // Wait for cards to render + await page.waitForSelector('.pc', { timeout: 5000 }) + + // Give _fetchTraktWatched a moment to complete and re-render + await page.waitForTimeout(300) + + // The watched card should have the badge — NOTE: badge appears after + // _fetchTraktWatched resolves, so re-render may be needed. + // We look for any .watched-badge in the grid. + // (In real usage JS re-renders on nav; here watched ids are loaded async) + // Navigate away and back to force a render with ids populated + await page.locator('button.nav[data-tab="dashboard"]').click() + await page.locator('button.nav[data-tab="classics"]').click() + await page.waitForSelector('.pc', { timeout: 5000 }) + + const watchedCard = page.locator(`.pc[data-tmdb="${TMDB_WATCHED}"]`) + await expect(watchedCard.locator('.watched-badge')).toBeVisible({ timeout: 3000 }) + }) + + test('no watched badge on unwatched movie card', async ({ page }) => { + await setupMocks(page, { config: CONFIG_TRAKT_CONNECTED, watched: WATCHED_STUB }) + await page.goto('/') + await page.locator('button.nav[data-tab="classics"]').click() + await page.waitForSelector('.pc', { timeout: 5000 }) + + const unwatchedCard = page.locator(`.pc[data-tmdb="${TMDB_UNWATCHED}"]`) + await expect(unwatchedCard).toBeVisible() + await expect(unwatchedCard.locator('.watched-badge')).not.toBeVisible() + }) + + test('no watched badge when Trakt is disabled', async ({ page }) => { + await setupMocks(page, { config: CONFIG_TRAKT_DISABLED, watched: { ok: true, tmdb_ids: [] } }) + await page.goto('/') + await page.locator('button.nav[data-tab="classics"]').click() + await page.waitForSelector('.pc', { timeout: 5000 }) + + // No watched badges anywhere + await expect(page.locator('.watched-badge')).toHaveCount(0) + }) + +}) + +// --------------------------------------------------------------------------- +// Cards: hide-watched filter +// --------------------------------------------------------------------------- + +test.describe('Trakt hide-watched filter', () => { + + test('watched movie hidden from Classics when TRAKT_HIDE_WATCHED=true', async ({ page }) => { + await setupMocks(page, { config: CONFIG_TRAKT_HIDE, watched: WATCHED_STUB }) + await page.goto('/') + + // Navigate away and back to trigger re-render after fetch + await page.locator('button.nav[data-tab="classics"]').click() + await page.waitForSelector('.pc', { timeout: 5000 }) + await page.locator('button.nav[data-tab="dashboard"]').click() + await page.locator('button.nav[data-tab="classics"]').click() + await page.waitForSelector('.pc', { timeout: 5000 }) + + // Watched movie should be hidden + await expect(page.locator(`.pc[data-tmdb="${TMDB_WATCHED}"]`)).toHaveCount(0) + + // Unwatched movie still visible + await expect(page.locator(`.pc[data-tmdb="${TMDB_UNWATCHED}"]`)).toBeVisible() + }) + + test('watched movie remains in Wishlist even with TRAKT_HIDE_WATCHED=true', async ({ page }) => { + await setupMocks(page, { config: CONFIG_TRAKT_HIDE, watched: WATCHED_STUB }) + await page.goto('/') + + await page.locator('button.nav[data-tab="wishlist"]').click() + await page.waitForSelector('.pc', { timeout: 5000 }) + + // MOVIE_WATCHED is also in wishlist — should NOT be hidden there + await expect(page.locator(`.pc[data-tmdb="${TMDB_WATCHED}"]`)).toBeVisible() + }) + +}) diff --git a/static/index.html b/static/index.html index dd2ef97..ebd6ce4 100644 --- a/static/index.html +++ b/static/index.html @@ -697,6 +697,17 @@ .pc:hover .pc-check, .pc-check:checked { opacity: 1; } .pc.selected { border-color: var(--gold); box-shadow: 0 0 0 2px var(--gold-glow); } + /* Trakt watched overlay */ + .watched-badge { + position: absolute; bottom: 2.6rem; left: 0; right: 0; z-index: 5; + background: rgba(237,34,36,.82); color: #fff; + font-size: .6rem; font-weight: 700; letter-spacing: .04em; + text-align: center; padding: 3px 0; + pointer-events: none; + } + .pc-watched { opacity: .55; } + .pc-watched:hover { opacity: 1; } + /* ── MOVIE DETAIL MODAL ───────────────────── */ #movieModal { position: fixed; inset: 0; z-index: 2000; diff --git a/static/js/app.js b/static/js/app.js index c124813..368a00d 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -369,6 +369,10 @@ async function boot(){ if (CONFIGURED) { await loadResults() _fetchRadarrLibrary() // background fetch — no await, updates cards on next render + // Re-render after watched ids arrive so badges show on the initial tab after refresh + _fetchTraktWatched().then(() => { + if (ACTIVE_TAB && !["config","logs"].includes(ACTIVE_TAB)) render() + }) } else { setStatus("Setup required"); render() } } diff --git a/static/js/cards.js b/static/js/cards.js index c2f7b0e..172dbd4 100644 --- a/static/js/cards.js +++ b/static/js/cards.js @@ -9,6 +9,11 @@ function posterCard(m, extraTag = "") { const tmdb = m.tmdb const safeName = (m.title || "").replace(/'/g, "\\'").replace(/"/g, """) + const _watched = _traktWatchedIds?.has(tmdb) + const watchedBadge = _watched + ? `