From 1d6cef5ca0db0b12f6e1ac6978ff87795a2efd09 Mon Sep 17 00:00:00 2001 From: benjamin-elharrar Date: Mon, 27 Apr 2026 08:42:38 +0300 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20Trakt.tv=20integration=20=E2=80=94?= =?UTF-8?q?=20Device=20Code=20OAuth,=20watched=20history=20overlay=20(#70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: new router `app/routers/trakt.py` with 5 endpoints - POST /api/trakt/device/code — start device-code flow - POST /api/trakt/device/poll — poll for token, saves on success - POST /api/trakt/disconnect — revoke and clear stored tokens - GET /api/trakt/watched — TMDB IDs of watched movies (1h cache) - GET /api/trakt/status — connection state for config UI - Auto-refreshes access token on 401 via stored refresh token - Config UI: Trakt section with device-code connect flow - Shows live user code + countdown during authorisation - Switches to "Connected as @username + Disconnect" on success - TRAKT_HIDE_WATCHED toggle to filter watched movies from all grids - Cards: watched badge and dim overlay (👁 Watched) on watched movies - Filters: applyFilters() respects TRAKT_HIDE_WATCHED; wishlist tab exempt - 26 unit tests + E2E tests covering connect flow, badge, hide-watched Co-Authored-By: Claude Sonnet 4.6 --- app/config.py | 11 ++ app/routers/trakt.py | 274 ++++++++++++++++++++++++++++ app/web.py | 3 +- e2e/tests/trakt.spec.js | 316 ++++++++++++++++++++++++++++++++ static/index.html | 11 ++ static/js/app.js | 1 + static/js/cards.js | 16 +- static/js/config.js | 165 +++++++++++++++++ static/js/filters.js | 13 +- static/js/mutations.js | 15 ++ static/js/render.js | 2 +- tests/test_trakt.py | 387 ++++++++++++++++++++++++++++++++++++++++ 12 files changed, 1206 insertions(+), 8 deletions(-) create mode 100644 app/routers/trakt.py create mode 100644 e2e/tests/trakt.spec.js create mode 100644 tests/test_trakt.py 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..fb92517 --- /dev/null +++ b/app/routers/trakt.py @@ -0,0 +1,274 @@ +""" +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 time +import logging + +import requests +from fastapi import APIRouter, Body + +from app.config import load_config, save_config + +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.""" + cfg = load_config() + trakt = cfg.get("TRAKT", {}) + trakt.update({ + "TRAKT_ENABLED": False, + "TRAKT_ACCESS_TOKEN": "", + "TRAKT_REFRESH_TOKEN": "", + "TRAKT_USERNAME": "", + }) + cfg["TRAKT"] = trakt + save_config(cfg) + _watched_cache["ts"] = 0.0 + 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.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..039d67e 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -369,6 +369,7 @@ async function boot(){ if (CONFIGURED) { await loadResults() _fetchRadarrLibrary() // background fetch — no await, updates cards on next render + _fetchTraktWatched() // background fetch — no await, populates watched overlay } 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 + ? `
👁 Watched
` : "" + const watchedDim = _watched ? " pc-watched" : "" + const _inRadarr = _radarrLibTmdbIds?.has(tmdb) const radarrBtn = CONFIG?.RADARR?.RADARR_ENABLED ? (_inRadarr @@ -53,7 +58,8 @@ function posterCard(m, extraTag = "") { .replace(/'/g, "\\'") return ` -
+
+ ${watchedBadge} 👁 Watched
` : "" + const watchedDim2 = _watched2 ? " pc-watched" : "" + const scoreBadge = score ? `
+ ${watchedBadge2} ${scoreBadge} { const isSecret = type === "secret" @@ -544,6 +545,32 @@ function renderConfig(){ ${hint("e.g. http://flaresolverr:8191 — used to bypass Cloudflare when fetching Letterboxd lists.")}
+
+ ${sec('Trakt (optional)', svcBadge('TRAKT','#ED2224'))} + ${trkt.TRAKT_ACCESS_TOKEN + ? ` +
+
+ ✓ Connected as @${escHtml(trkt.TRAKT_USERNAME||"")} +
+ +
` + : ` +
+ ${field("cfg_trakt_id", "Client ID", trkt.TRAKT_CLIENT_ID ||"")} + ${field("cfg_trakt_secret", "Client Secret", trkt.TRAKT_CLIENT_SECRET||"", "secret")} + +
`} + + ${check("cfg_trakt_hide", "Hide watched movies from all grids", trkt.TRAKT_HIDE_WATCHED||false)} + ${hint("When enabled, movies you have marked as watched in Trakt are hidden from all recommendation grids (except Wishlist).")} +
+
@@ -670,6 +697,11 @@ async function saveConfig(){ STREAMING:{ STREAMING_COUNTRY: v("cfg_streaming_country").toUpperCase()||"US", }, + TRAKT:{ + TRAKT_CLIENT_ID: v("cfg_trakt_id"), + TRAKT_CLIENT_SECRET: v("cfg_trakt_secret"), + TRAKT_HIDE_WATCHED: vc("cfg_trakt_hide"), + }, } const res = await api("/api/config","POST",payload) @@ -706,3 +738,136 @@ async function triggerWatchtowerUpdate() { toast("Watchtower request failed", "error") } } + +// --------------------------------------------------------------------------- +// Trakt device-code OAuth helpers +// --------------------------------------------------------------------------- + +let _traktPollTimer = null + +async function traktConnect() { + const clientId = document.getElementById("cfg_trakt_id")?.value?.trim() + const clientSecret = document.getElementById("cfg_trakt_secret")?.value?.trim() + if (!clientId || !clientSecret) { + toast("Enter Client ID and Client Secret first", "error"); return + } + + const box = document.getElementById("traktDeviceBox") + if (box) { box.style.display = "block"; box.innerHTML = "Connecting…" } + + const res = await api("/api/trakt/device/code", "POST", { client_id: clientId, client_secret: clientSecret }) + if (!res.ok) { + if (box) box.innerHTML = `✗ ${escHtml(res.error||"Failed")}` + return + } + + const { device_code, user_code, verification_url, expires_in, interval } = res + const expiresAt = Date.now() + expires_in * 1000 + + if (box) { + box.innerHTML = ` +
+ Go to ${escHtml(verification_url)} and enter: +
+
${escHtml(user_code)}
+
` + } + + // Start polling + if (_traktPollTimer) clearInterval(_traktPollTimer) + _traktPollTimer = setInterval(async () => { + const remaining = Math.max(0, Math.round((expiresAt - Date.now()) / 1000)) + const cd = document.getElementById("traktCountdown") + if (cd) cd.textContent = `Waiting for authorisation… (${remaining}s remaining)` + + if (Date.now() > expiresAt) { + clearInterval(_traktPollTimer); _traktPollTimer = null + if (box) box.innerHTML = `✗ Code expired — try again` + return + } + + const poll = await api("/api/trakt/device/poll", "POST", { + client_id: clientId, client_secret: clientSecret, device_code, + }) + + if (poll.status === "pending") return // keep waiting + + clearInterval(_traktPollTimer); _traktPollTimer = null + + if (poll.status === "success") { + if (box) box.style.display = "none" + const cbBox = document.getElementById("traktConnectedBox") + const ctBox = document.getElementById("traktConnectBox") + if (cbBox) { + cbBox.style.display = "block" + cbBox.innerHTML = ` +
+ ✓ Connected as @${escHtml(poll.username||"")} +
+ ` + } + if (ctBox) ctBox.style.display = "none" + // Update global config + if (CONFIG?.TRAKT) { + CONFIG.TRAKT.TRAKT_ACCESS_TOKEN = "set" + CONFIG.TRAKT.TRAKT_USERNAME = poll.username || "" + CONFIG.TRAKT.TRAKT_ENABLED = true + } + toast(`Trakt connected as @${poll.username||""}`, "success") + // Refresh watched list + _fetchTraktWatched?.() + } else if (poll.status === "denied") { + if (box) box.innerHTML = `✗ Access denied by user` + } else if (poll.status === "expired") { + if (box) box.innerHTML = `✗ Code expired — try again` + } else { + if (box) box.innerHTML = `✗ Error — try again` + } + }, (interval || 5) * 1000) +} + +async function traktDisconnect() { + if (!confirm("Disconnect Trakt? Your watch history overlay will be removed.")) return + const res = await api("/api/trakt/disconnect", "POST") + if (!res.ok) { toast("Disconnect failed", "error"); return } + + const cbBox = document.getElementById("traktConnectedBox") + const ctBox = document.getElementById("traktConnectBox") + const box = document.getElementById("traktDeviceBox") + if (cbBox) cbBox.style.display = "none" + if (box) box.style.display = "none" + if (ctBox) { + ctBox.style.display = "block" + ctBox.innerHTML = ` +
+ +
+ +
+
+
+ +
+ + +
+
+ ` + } + if (CONFIG?.TRAKT) { + CONFIG.TRAKT.TRAKT_ACCESS_TOKEN = "" + CONFIG.TRAKT.TRAKT_USERNAME = "" + CONFIG.TRAKT.TRAKT_ENABLED = false + } + // Clear watched set + if (typeof _traktWatchedIds !== "undefined") _traktWatchedIds = null + toast("Trakt disconnected") +} diff --git a/static/js/filters.js b/static/js/filters.js index 35ea077..d7fe6ea 100644 --- a/static/js/filters.js +++ b/static/js/filters.js @@ -130,16 +130,21 @@ function _initGroupFilter(groups){ } function getSort(){ return document.getElementById("sort")?.value || "popularity" } -function applyFilters(list){ - const search = (document.getElementById("search")?.value||"").toLowerCase().trim() - const year = document.getElementById("yearFilter")?.value || "" - const sort = getSort() +function applyFilters(list, { skipWatchedFilter = false } = {}){ + const search = (document.getElementById("search")?.value||"").toLowerCase().trim() + const year = document.getElementById("yearFilter")?.value || "" + const sort = getSort() + const hideWatched = !skipWatchedFilter + && CONFIG?.TRAKT?.TRAKT_ENABLED + && CONFIG?.TRAKT?.TRAKT_HIDE_WATCHED + && _traktWatchedIds != null let out = list.filter(m => { if (search && !(m.title||"").toLowerCase().includes(search)) return false if (year && yearBucket(m.year) !== year) return false if (_activeGenreFilter && !(m.genre_ids||[]).includes(parseInt(_activeGenreFilter))) return false if (_activeRatingFilter > 0 && (m.rating||0) < _activeRatingFilter) return false + if (hideWatched && _traktWatchedIds.has(m.tmdb)) return false return true }) diff --git a/static/js/mutations.js b/static/js/mutations.js index 4e8e2cc..093e224 100644 --- a/static/js/mutations.js +++ b/static/js/mutations.js @@ -247,6 +247,21 @@ async function searchInRadarr(tmdb, title, btn) { } } +/* ── Trakt watched state ────────────────────────────────────── */ + +/** Set of TMDB IDs the user has watched — null while loading, empty Set when disabled. */ +let _traktWatchedIds = null + +async function _fetchTraktWatched() { + if (!CONFIG?.TRAKT?.TRAKT_ENABLED || !CONFIG?.TRAKT?.TRAKT_ACCESS_TOKEN) { + _traktWatchedIds = new Set(); return + } + try { + const res = await api("/api/trakt/watched") + _traktWatchedIds = res.ok ? new Set(res.tmdb_ids) : new Set() + } catch { _traktWatchedIds = new Set() } +} + /* ── In-memory DATA helpers ─────────────────────────────────── */ /** diff --git a/static/js/render.js b/static/js/render.js index 35a2904..6e239a0 100644 --- a/static/js/render.js +++ b/static/js/render.js @@ -643,7 +643,7 @@ function renderSuggestions(){ async function renderWishlist(){ const c = document.getElementById("content") - const list = applyFilters(DATA.wishlist||[]) + const list = applyFilters(DATA.wishlist||[], { skipWatchedFilter: true }) if (!list.length){ c.innerHTML = emptyStateHTML("Wishlist is empty") diff --git a/tests/test_trakt.py b/tests/test_trakt.py new file mode 100644 index 0000000..35f94d0 --- /dev/null +++ b/tests/test_trakt.py @@ -0,0 +1,387 @@ +""" +Tests for Trakt.tv integration (app/routers/trakt.py). + +Covers: + - _trakt_headers() + - _refresh_access_token() + - _fetch_watched() + - POST /api/trakt/device/code + - POST /api/trakt/device/poll + - POST /api/trakt/disconnect + - GET /api/trakt/watched (cache, token refresh) + - GET /api/trakt/status +""" +import os +import sys +import tempfile +import time +from unittest.mock import patch, MagicMock, call + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _cfg(enabled=True, access="access123", refresh="refresh456", + client_id="cid", client_secret="csec", username="testuser"): + return { + "TRAKT": { + "TRAKT_ENABLED": enabled, + "TRAKT_CLIENT_ID": client_id, + "TRAKT_CLIENT_SECRET": client_secret, + "TRAKT_ACCESS_TOKEN": access, + "TRAKT_REFRESH_TOKEN": refresh, + "TRAKT_USERNAME": username, + "TRAKT_HIDE_WATCHED": False, + } + } + + +def _make_response(status_code, json_data=None): + r = MagicMock() + r.status_code = status_code + r.json.return_value = json_data or {} + return r + + +# --------------------------------------------------------------------------- +# Unit: _trakt_headers +# --------------------------------------------------------------------------- + +class TestTraktHeaders: + def _call(self, client_id, access_token=""): + from app.routers.trakt import _trakt_headers + return _trakt_headers(client_id, access_token) + + def test_base_headers(self): + h = self._call("my-client-id") + assert h["trakt-api-key"] == "my-client-id" + assert h["trakt-api-version"] == "2" + assert "Authorization" not in h + + def test_includes_bearer_when_token_set(self): + h = self._call("cid", "tok123") + assert h["Authorization"] == "Bearer tok123" + + def test_no_bearer_when_empty_token(self): + h = self._call("cid", "") + assert "Authorization" not in h + + +# --------------------------------------------------------------------------- +# Unit: _refresh_access_token +# --------------------------------------------------------------------------- + +class TestRefreshAccessToken: + def _call(self, cfg): + from app.routers.trakt import _refresh_access_token + return _refresh_access_token(cfg) + + def test_returns_none_when_missing_fields(self): + assert self._call({"TRAKT": {}}) is None + + def test_returns_none_on_http_error(self): + cfg = _cfg() + with patch("app.routers.trakt.requests.post") as mock_post: + mock_post.return_value = _make_response(401) + result = self._call(cfg) + assert result is None + + def test_updates_tokens_on_success(self, tmp_path): + cfg = _cfg() + new_tokens = {"access_token": "new_access", "refresh_token": "new_refresh"} + with patch("app.routers.trakt.requests.post") as mock_post, \ + patch("app.routers.trakt.save_config") as mock_save: + mock_post.return_value = _make_response(200, new_tokens) + result = self._call(cfg) + + assert result is not None + assert result["TRAKT_ACCESS_TOKEN"] == "new_access" + assert result["TRAKT_REFRESH_TOKEN"] == "new_refresh" + mock_save.assert_called_once() + + def test_returns_none_on_network_error(self): + import requests as req + cfg = _cfg() + with patch("app.routers.trakt.requests.post") as mock_post: + mock_post.side_effect = req.exceptions.RequestException("timeout") + result = self._call(cfg) + assert result is None + + +# --------------------------------------------------------------------------- +# Unit: _fetch_watched +# --------------------------------------------------------------------------- + +class TestFetchWatched: + def _call(self, client_id, access_token): + from app.routers.trakt import _fetch_watched + return _fetch_watched(client_id, access_token) + + def test_returns_tmdb_ids_on_success(self): + payload = [ + {"movie": {"ids": {"tmdb": 550}}}, + {"movie": {"ids": {"tmdb": 278}}}, + {"movie": {"ids": {"tmdb": None}}}, # no tmdb id → skipped + ] + with patch("app.routers.trakt.requests.get") as mock_get: + mock_get.return_value = _make_response(200, payload) + result = self._call("cid", "tok") + assert result == [550, 278] + + def test_returns_none_on_401(self): + with patch("app.routers.trakt.requests.get") as mock_get: + mock_get.return_value = _make_response(401) + result = self._call("cid", "tok") + assert result is None + + def test_returns_empty_list_on_network_error(self): + import requests as req + with patch("app.routers.trakt.requests.get") as mock_get: + mock_get.side_effect = req.exceptions.RequestException("err") + result = self._call("cid", "tok") + assert result == [] + + +# --------------------------------------------------------------------------- +# FastAPI route tests +# --------------------------------------------------------------------------- + +from fastapi.testclient import TestClient +from fastapi import FastAPI + + +def _make_app(): + from app.routers import trakt + # Reset the watched cache before each test + trakt._watched_cache["data"] = None + trakt._watched_cache["ts"] = 0.0 + app = FastAPI() + app.include_router(trakt.router) + return app + + +# --------------------------------------------------------------------------- +# POST /api/trakt/device/code +# --------------------------------------------------------------------------- + +class TestDeviceCode: + def setup_method(self): + self.client = TestClient(_make_app()) + + def test_returns_device_info_on_success(self): + payload = { + "device_code": "dev123", + "user_code": "ABC-DEF", + "verification_url": "https://trakt.tv/activate", + "expires_in": 600, + "interval": 5, + } + with patch("app.routers.trakt.requests.post") as mock_post: + mock_post.return_value = _make_response(200, payload) + res = self.client.post("/api/trakt/device/code", + json={"client_id": "cid", "client_secret": "csec"}) + assert res.status_code == 200 + data = res.json() + assert data["ok"] is True + assert data["user_code"] == "ABC-DEF" + assert data["device_code"] == "dev123" + assert data["interval"] == 5 + + def test_returns_error_when_client_id_missing(self): + res = self.client.post("/api/trakt/device/code", json={}) + assert res.json()["ok"] is False + + def test_returns_error_on_http_failure(self): + with patch("app.routers.trakt.requests.post") as mock_post: + mock_post.return_value = _make_response(400) + res = self.client.post("/api/trakt/device/code", + json={"client_id": "cid"}) + assert res.json()["ok"] is False + + +# --------------------------------------------------------------------------- +# POST /api/trakt/device/poll +# --------------------------------------------------------------------------- + +class TestDevicePoll: + def setup_method(self): + self.client = TestClient(_make_app()) + + def _post(self, **kw): + body = {"client_id": "cid", "client_secret": "csec", "device_code": "dev123", **kw} + return self.client.post("/api/trakt/device/poll", json=body) + + def test_returns_pending_on_400(self): + with patch("app.routers.trakt.requests.post") as mock_post: + mock_post.return_value = _make_response(400) + res = self._post() + assert res.json()["status"] == "pending" + + def test_returns_expired_on_410(self): + with patch("app.routers.trakt.requests.post") as mock_post: + mock_post.return_value = _make_response(410) + res = self._post() + assert res.json()["status"] == "expired" + + def test_returns_denied_on_418(self): + with patch("app.routers.trakt.requests.post") as mock_post: + mock_post.return_value = _make_response(418) + res = self._post() + assert res.json()["status"] == "denied" + + def test_success_saves_config_and_returns_username(self): + token_resp = { + "access_token": "acc123", + "refresh_token": "ref456", + } + user_resp = {"username": "johndoe"} + + with patch("app.routers.trakt.requests.post") as mock_post, \ + patch("app.routers.trakt.requests.get") as mock_get, \ + patch("app.routers.trakt.load_config") as mock_load, \ + patch("app.routers.trakt.save_config") as mock_save: + + mock_post.return_value = _make_response(200, token_resp) + mock_get.return_value = _make_response(200, user_resp) + mock_load.return_value = {"TRAKT": {}} + + res = self._post() + + data = res.json() + assert data["ok"] is True + assert data["status"] == "success" + assert data["username"] == "johndoe" + mock_save.assert_called_once() + + def test_returns_error_when_fields_missing(self): + res = self.client.post("/api/trakt/device/poll", json={"client_id": "cid"}) + assert res.json()["ok"] is False + + def test_returns_pending_on_429(self): + with patch("app.routers.trakt.requests.post") as mock_post: + mock_post.return_value = _make_response(429) + res = self._post() + assert res.json()["status"] == "pending" + + +# --------------------------------------------------------------------------- +# POST /api/trakt/disconnect +# --------------------------------------------------------------------------- + +class TestDisconnect: + def setup_method(self): + self.client = TestClient(_make_app()) + + def test_clears_tokens_and_returns_ok(self): + with patch("app.routers.trakt.load_config") as mock_load, \ + patch("app.routers.trakt.save_config") as mock_save: + mock_load.return_value = _cfg() + res = self.client.post("/api/trakt/disconnect") + + assert res.json()["ok"] is True + saved = mock_save.call_args[0][0] + trakt = saved["TRAKT"] + assert trakt["TRAKT_ENABLED"] is False + assert trakt["TRAKT_ACCESS_TOKEN"] == "" + assert trakt["TRAKT_REFRESH_TOKEN"] == "" + assert trakt["TRAKT_USERNAME"] == "" + + +# --------------------------------------------------------------------------- +# GET /api/trakt/watched +# --------------------------------------------------------------------------- + +class TestWatched: + def setup_method(self): + self.client = TestClient(_make_app()) + + def test_returns_empty_when_disabled(self): + with patch("app.routers.trakt.load_config") as mock_load: + mock_load.return_value = _cfg(enabled=False, access="") + res = self.client.get("/api/trakt/watched") + assert res.json() == {"ok": True, "tmdb_ids": []} + + def test_returns_tmdb_ids_when_connected(self): + watch_payload = [ + {"movie": {"ids": {"tmdb": 550}}}, + {"movie": {"ids": {"tmdb": 278}}}, + ] + with patch("app.routers.trakt.load_config") as mock_load, \ + patch("app.routers.trakt.requests.get") as mock_get: + mock_load.return_value = _cfg() + mock_get.return_value = _make_response(200, watch_payload) + res = self.client.get("/api/trakt/watched") + + data = res.json() + assert data["ok"] is True + assert set(data["tmdb_ids"]) == {550, 278} + + def test_refreshes_token_on_401(self): + """On 401 from watched endpoint, router should call _refresh_access_token.""" + watch_payload = [{"movie": {"ids": {"tmdb": 999}}}] + refresh_resp = { + "TRAKT_ACCESS_TOKEN": "new_access", + "TRAKT_REFRESH_TOKEN": "new_refresh", + } + + def _get_side_effect(url, **kw): + if "watched" in url: + if _get_side_effect._calls == 0: + _get_side_effect._calls += 1 + return _make_response(401) + return _make_response(200, watch_payload) + return _make_response(200, {}) + _get_side_effect._calls = 0 + + with patch("app.routers.trakt.load_config") as mock_load, \ + patch("app.routers.trakt.requests.get", side_effect=_get_side_effect), \ + patch("app.routers.trakt._refresh_access_token", return_value=refresh_resp): + mock_load.return_value = _cfg() + res = self.client.get("/api/trakt/watched") + + data = res.json() + assert data["ok"] is True + assert 999 in data["tmdb_ids"] + + def test_returns_cached_data(self): + from app.routers import trakt as trakt_mod + trakt_mod._watched_cache["data"] = {"ok": True, "tmdb_ids": [42, 43]} + trakt_mod._watched_cache["ts"] = time.time() + + with patch("app.routers.trakt.load_config") as mock_load: + mock_load.return_value = _cfg() + res = self.client.get("/api/trakt/watched") + + assert res.json()["tmdb_ids"] == [42, 43] + mock_load.assert_not_called() # served from cache without hitting config + + +# --------------------------------------------------------------------------- +# GET /api/trakt/status +# --------------------------------------------------------------------------- + +class TestStatus: + def setup_method(self): + self.client = TestClient(_make_app()) + + def test_connected_state(self): + with patch("app.routers.trakt.load_config") as mock_load: + mock_load.return_value = _cfg() + res = self.client.get("/api/trakt/status") + data = res.json() + assert data["ok"] is True + assert data["connected"] is True + assert data["username"] == "testuser" + assert data["enabled"] is True + + def test_disconnected_state(self): + with patch("app.routers.trakt.load_config") as mock_load: + mock_load.return_value = _cfg(access="", username="") + res = self.client.get("/api/trakt/status") + data = res.json() + assert data["connected"] is False + assert data["username"] == "" From 325d187bfd59a7ca936a8a23ae704b039d461474 Mon Sep 17 00:00:00 2001 From: benjamin-elharrar Date: Mon, 27 Apr 2026 18:36:25 +0300 Subject: [PATCH 2/4] fix: three Trakt bugs found in QA (#70) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Save config wiped OAuth tokens — saveConfig() now preserves TRAKT_ACCESS_TOKEN / REFRESH_TOKEN / USERNAME / ENABLED from in-memory CONFIG; falls back to CONFIG values when connect form fields are hidden (connected state) 2. Watched badges missing after browser refresh — _fetchTraktWatched() now triggers a render() on completion so the initial tab reflects the watched overlay without requiring a sidebar navigation 3. Hide-watched had no effect on Franchises / Directors / Actors — the grouped renderer bypasses applyFilters(); added the same _traktWatchedIds guard inline after genre/rating filters Co-Authored-By: Claude Sonnet 4.6 --- static/js/app.js | 5 ++++- static/js/config.js | 12 ++++++++++-- static/js/render.js | 6 ++++++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/static/js/app.js b/static/js/app.js index 039d67e..368a00d 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -369,7 +369,10 @@ async function boot(){ if (CONFIGURED) { await loadResults() _fetchRadarrLibrary() // background fetch — no await, updates cards on next render - _fetchTraktWatched() // background fetch — no await, populates watched overlay + // 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/config.js b/static/js/config.js index 1fefa81..a68f3d9 100644 --- a/static/js/config.js +++ b/static/js/config.js @@ -698,9 +698,17 @@ async function saveConfig(){ STREAMING_COUNTRY: v("cfg_streaming_country").toUpperCase()||"US", }, TRAKT:{ - TRAKT_CLIENT_ID: v("cfg_trakt_id"), - TRAKT_CLIENT_SECRET: v("cfg_trakt_secret"), + // Fields may not exist in DOM when connected (form is hidden) — fall back to in-memory CONFIG + TRAKT_CLIENT_ID: document.getElementById("cfg_trakt_id")?.value?.trim() + ?? CONFIG?.TRAKT?.TRAKT_CLIENT_ID ?? "", + TRAKT_CLIENT_SECRET: document.getElementById("cfg_trakt_secret")?.value?.trim() + ?? CONFIG?.TRAKT?.TRAKT_CLIENT_SECRET ?? "", TRAKT_HIDE_WATCHED: vc("cfg_trakt_hide"), + // OAuth tokens managed by device flow only — always preserve from in-memory config + TRAKT_ENABLED: CONFIG?.TRAKT?.TRAKT_ENABLED ?? false, + TRAKT_ACCESS_TOKEN: CONFIG?.TRAKT?.TRAKT_ACCESS_TOKEN || "", + TRAKT_REFRESH_TOKEN: CONFIG?.TRAKT?.TRAKT_REFRESH_TOKEN || "", + TRAKT_USERNAME: CONFIG?.TRAKT?.TRAKT_USERNAME || "", }, } diff --git a/static/js/render.js b/static/js/render.js index 6e239a0..6b73f40 100644 --- a/static/js/render.js +++ b/static/js/render.js @@ -436,6 +436,12 @@ function renderGroupedList({ groups, nameKey, nameIcon, ignoreHandler, emptyMsg, sorted = sorted.filter(m => (m.rating||0) >= ratingMin) } + // Hide watched — same rule as applyFilters() but applied to the group's missing list + if (CONFIG?.TRAKT?.TRAKT_ENABLED && CONFIG?.TRAKT?.TRAKT_HIDE_WATCHED + && _traktWatchedIds != null) { + sorted = sorted.filter(m => !_traktWatchedIds.has(m.tmdb)) + } + if (!sorted.length) return const groupTab = `${ACTIVE_TAB}-${name}` From ea2427c1a2749518c61dc3ba1dd10b4044ba4e42 Mon Sep 17 00:00:00 2001 From: benjamin-elharrar Date: Mon, 27 Apr 2026 18:39:42 +0300 Subject: [PATCH 3/4] feat: add manual Trakt watched history refresh button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: POST /api/trakt/watched/refresh — zeros the 1-hour cache timestamp so the next GET re-fetches live from Trakt - Config UI: "⟳ Refresh history" button next to Disconnect in the connected state (both initial render and after device-code connect) - Shows "✓ N watched movies" confirmation for 4s after refresh - Re-renders the current tab so badges/hide-watched update immediately Co-Authored-By: Claude Sonnet 4.6 --- app/routers/trakt.py | 7 +++++++ static/js/config.js | 41 +++++++++++++++++++++++++++++++++++++---- tests/test_trakt.py | 21 +++++++++++++++++++++ 3 files changed, 65 insertions(+), 4 deletions(-) diff --git a/app/routers/trakt.py b/app/routers/trakt.py index fb92517..0f0502d 100644 --- a/app/routers/trakt.py +++ b/app/routers/trakt.py @@ -260,6 +260,13 @@ def trakt_watched(): 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.""" diff --git a/static/js/config.js b/static/js/config.js index a68f3d9..4fe7916 100644 --- a/static/js/config.js +++ b/static/js/config.js @@ -553,8 +553,14 @@ function renderConfig(){
✓ Connected as @${escHtml(trkt.TRAKT_USERNAME||"")}
- +
+ + + +
` : `
@@ -814,8 +820,14 @@ async function traktConnect() {
✓ Connected as @${escHtml(poll.username||"")}
- ` +
+ + + +
` } if (ctBox) ctBox.style.display = "none" // Update global config @@ -879,3 +891,24 @@ async function traktDisconnect() { if (typeof _traktWatchedIds !== "undefined") _traktWatchedIds = null toast("Trakt disconnected") } + +async function traktRefreshWatched(btn) { + const statusEl = document.getElementById("traktRefreshStatus") + btn.disabled = true + if (statusEl) { statusEl.textContent = "Refreshing…"; statusEl.style.color = "var(--text3)" } + + // Bust backend cache then re-fetch + await api("/api/trakt/watched/refresh", "POST") + await _fetchTraktWatched() + + btn.disabled = false + if (statusEl) { + const n = _traktWatchedIds?.size ?? 0 + statusEl.textContent = `✓ ${n} watched movies` + statusEl.style.color = "var(--green)" + setTimeout(() => { if (statusEl) statusEl.textContent = "" }, 4000) + } + toast(`Trakt: watched history refreshed`, "success") + // Update cards on the current tab + if (typeof render !== "undefined" && !["config","logs"].includes(ACTIVE_TAB)) render() +} diff --git a/tests/test_trakt.py b/tests/test_trakt.py index 35f94d0..a847588 100644 --- a/tests/test_trakt.py +++ b/tests/test_trakt.py @@ -291,6 +291,27 @@ def test_clears_tokens_and_returns_ok(self): assert trakt["TRAKT_USERNAME"] == "" +# --------------------------------------------------------------------------- +# POST /api/trakt/watched/refresh +# --------------------------------------------------------------------------- + +class TestWatchedRefresh: + def setup_method(self): + self.client = TestClient(_make_app()) + + def test_busts_cache_and_returns_ok(self): + from app.routers import trakt as trakt_mod + # Prime the cache with a recent timestamp + trakt_mod._watched_cache["data"] = {"ok": True, "tmdb_ids": [1, 2]} + trakt_mod._watched_cache["ts"] = time.time() + + res = self.client.post("/api/trakt/watched/refresh") + + assert res.json()["ok"] is True + # Cache timestamp should now be 0 (expired) + assert trakt_mod._watched_cache["ts"] == 0.0 + + # --------------------------------------------------------------------------- # GET /api/trakt/watched # --------------------------------------------------------------------------- From af3b81e08c2a11739cefd25a02ed4ede625bd746 Mon Sep 17 00:00:00 2001 From: benjamin-elharrar Date: Mon, 27 Apr 2026 19:51:36 +0300 Subject: [PATCH 4/4] fix: disconnect now directly patches YAML so tokens reliably clear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous disconnect path called save_config(full_config) which goes through the _deep_merge pipeline. Replacing it with a direct YAML read→patch→write guarantees the four token fields are wiped in the file regardless of in-memory config state. - Backend: trakt_disconnect reads raw YAML, patches TRAKT section, writes back — bypasses save_config merge entirely - Backend: also clears _watched_cache data (not just ts) on disconnect - Frontend: traktDisconnect() now clears TRAKT_REFRESH_TOKEN from in-memory CONFIG too (was previously missed) - Test: updated to use tmp_path and verify the YAML file is actually patched on disk, not just that save_config was called Co-Authored-By: Claude Sonnet 4.6 --- app/routers/trakt.py | 35 ++++++++++++++++++++++++++++------- static/js/config.js | 7 ++++--- tests/test_trakt.py | 30 ++++++++++++++++++++++++------ 3 files changed, 56 insertions(+), 16 deletions(-) diff --git a/app/routers/trakt.py b/app/routers/trakt.py index 0f0502d..0772734 100644 --- a/app/routers/trakt.py +++ b/app/routers/trakt.py @@ -7,13 +7,15 @@ 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 +from app.config import load_config, save_config, CONFIG_FILE, ensure_config_dir router = APIRouter() log = logging.getLogger("cineplete") @@ -207,18 +209,37 @@ def trakt_device_poll(payload: dict = Body(...)): @router.post("/api/trakt/disconnect") def trakt_disconnect(): - """Clear stored Trakt tokens from config.""" - cfg = load_config() - trakt = cfg.get("TRAKT", {}) + """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": "", }) - cfg["TRAKT"] = trakt - save_config(cfg) - _watched_cache["ts"] = 0.0 + 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} diff --git a/static/js/config.js b/static/js/config.js index 4fe7916..f1b5619 100644 --- a/static/js/config.js +++ b/static/js/config.js @@ -883,9 +883,10 @@ async function traktDisconnect() { onclick="traktConnect()">🔗 Connect via Trakt` } if (CONFIG?.TRAKT) { - CONFIG.TRAKT.TRAKT_ACCESS_TOKEN = "" - CONFIG.TRAKT.TRAKT_USERNAME = "" - CONFIG.TRAKT.TRAKT_ENABLED = false + CONFIG.TRAKT.TRAKT_ACCESS_TOKEN = "" + CONFIG.TRAKT.TRAKT_REFRESH_TOKEN = "" + CONFIG.TRAKT.TRAKT_USERNAME = "" + CONFIG.TRAKT.TRAKT_ENABLED = false } // Clear watched set if (typeof _traktWatchedIds !== "undefined") _traktWatchedIds = null diff --git a/tests/test_trakt.py b/tests/test_trakt.py index a847588..e21f725 100644 --- a/tests/test_trakt.py +++ b/tests/test_trakt.py @@ -276,19 +276,37 @@ class TestDisconnect: def setup_method(self): self.client = TestClient(_make_app()) - def test_clears_tokens_and_returns_ok(self): - with patch("app.routers.trakt.load_config") as mock_load, \ - patch("app.routers.trakt.save_config") as mock_save: - mock_load.return_value = _cfg() + def test_clears_tokens_and_returns_ok(self, tmp_path): + import yaml as _yaml + cfg_file = tmp_path / "config.yml" + _yaml.safe_dump({ + "TRAKT": { + "TRAKT_ENABLED": True, + "TRAKT_CLIENT_ID": "cid", + "TRAKT_CLIENT_SECRET": "csec", + "TRAKT_ACCESS_TOKEN": "tok_abc", + "TRAKT_REFRESH_TOKEN": "ref_abc", + "TRAKT_USERNAME": "filmlover", + "TRAKT_HIDE_WATCHED": False, + } + }, open(cfg_file, "w")) + + with patch("app.routers.trakt.CONFIG_FILE", str(cfg_file)), \ + patch("app.routers.trakt.ensure_config_dir"): res = self.client.post("/api/trakt/disconnect") assert res.json()["ok"] is True - saved = mock_save.call_args[0][0] - trakt = saved["TRAKT"] + + # Verify the YAML file was actually patched + loaded = _yaml.safe_load(open(cfg_file)) + trakt = loaded["TRAKT"] assert trakt["TRAKT_ENABLED"] is False assert trakt["TRAKT_ACCESS_TOKEN"] == "" assert trakt["TRAKT_REFRESH_TOKEN"] == "" assert trakt["TRAKT_USERNAME"] == "" + # Client ID/Secret and hide-watched should be preserved + assert trakt["TRAKT_CLIENT_ID"] == "cid" + assert trakt["TRAKT_HIDE_WATCHED"] is False # ---------------------------------------------------------------------------