From 5c7b3feb1fa6dabe98655bf8f7a129724421dc1d Mon Sep 17 00:00:00 2001 From: Kenny Date: Sat, 14 Mar 2026 20:22:55 -0700 Subject: [PATCH] Add shareable search links and watched movie indicators Share: "Copy Link" button generates a URL with ?s={history_id} that loads the saved search results without auth. Recipients see the same movie picks. Watched: When viewing history results or shared searches, cards for movies the logged-in user has since watched are dimmed with a green "Watched" badge. Uses a new POST /api/library/watch-check endpoint. Co-Authored-By: Claude Opus 4.6 --- app/routers/library.py | 22 ++++++++++++++ app/routers/mood.py | 23 +++++++++++++++ app/static/app.js | 65 +++++++++++++++++++++++++++++++++++++++++- app/static/index.html | 5 +++- app/static/styles.css | 20 +++++++++++++ 5 files changed, 133 insertions(+), 2 deletions(-) diff --git a/app/routers/library.py b/app/routers/library.py index 99630a6..8b1cfac 100644 --- a/app/routers/library.py +++ b/app/routers/library.py @@ -33,6 +33,28 @@ async def library_stats(request: Request): await db.close() +@router.post("/watch-check") +async def watch_check(request: Request): + """Given a list of jellyfin_ids, return which ones the current user has watched.""" + user = await get_current_user(request) + body = await request.json() + ids = body.get("jellyfin_ids", []) + if not ids: + return {"watched": []} + + db = await get_db() + try: + placeholders = ",".join("?" for _ in ids) + cursor = await db.execute( + f"SELECT jellyfin_id FROM watch_state WHERE user_id = ? AND is_played = 1 AND jellyfin_id IN ({placeholders})", + [user["id"]] + ids, + ) + rows = await cursor.fetchall() + return {"watched": [row["jellyfin_id"] for row in rows]} + finally: + await db.close() + + @router.post("/sync") async def trigger_sync(request: Request): await get_current_user(request) diff --git a/app/routers/mood.py b/app/routers/mood.py index 9b18834..9f65a86 100644 --- a/app/routers/mood.py +++ b/app/routers/mood.py @@ -175,6 +175,29 @@ async def get_search_history(request: Request): await db.close() +@router.get("/history/shared/{entry_id}") +async def get_shared_history(entry_id: int): + """Public endpoint — returns a history entry by ID (no auth required).""" + db = await get_db() + try: + cursor = await db.execute( + "SELECT id, mood, results, meta, created_at FROM search_history WHERE id = ?", + (entry_id,), + ) + row = await cursor.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Search not found") + return { + "id": row["id"], + "mood": row["mood"], + "results": json.loads(row["results"]), + "meta": json.loads(row["meta"]) if row["meta"] else {}, + "created_at": row["created_at"], + } + finally: + await db.close() + + @router.delete("/history/{entry_id}") async def delete_history_entry(entry_id: int, request: Request): user = await get_current_user(request) diff --git a/app/static/app.js b/app/static/app.js index 55e068f..508c173 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -217,6 +217,7 @@ function renderResults(data, append = false) { data.recommendations.forEach(movie => { const card = document.createElement('div'); card.className = 'movie-card bg-dark-200 rounded-xl overflow-hidden border border-gray-800'; + card.dataset.jellyfinId = movie.jellyfin_id; card.innerHTML = `
${movie.title} r.jellyfin_id)); } function toggleHistoryVisibility(show) { @@ -334,6 +337,51 @@ function toggleHistoryVisibility(show) { } } +// --- Watch Check --- + +async function markWatchedCards(jellyfinIds) { + if (!currentUser || !jellyfinIds.length) return; + try { + const res = await fetch(`${API}/api/library/watch-check`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jellyfin_ids: jellyfinIds }) + }); + if (!res.ok) return; + const { watched } = await res.json(); + if (!watched.length) return; + const watchedSet = new Set(watched); + document.querySelectorAll('.movie-card').forEach(card => { + if (watchedSet.has(card.dataset.jellyfinId)) { + card.classList.add('watched'); + } + }); + } catch { /* ignore */ } +} + +// --- Shared Search --- + +async function loadSharedSearch() { + const params = new URLSearchParams(window.location.search); + const sharedId = params.get('s'); + if (!sharedId) return false; + try { + const res = await fetch(`${API}/api/history/shared/${sharedId}`); + if (!res.ok) return false; + const entry = await res.json(); + document.getElementById('mood-input').value = entry.mood; + const data = { recommendations: entry.results, meta: entry.meta }; + renderResults(data); + // If logged in, check watch state + if (currentUser) { + await markWatchedCards(entry.results.map(r => r.jellyfin_id)); + } + return true; + } catch { + return false; + } +} + // --- Library Stats --- async function loadStats() { @@ -418,6 +466,20 @@ document.getElementById('reroll-btn').addEventListener('click', () => { findMovies(shownMovieIds); }); +// Share / Copy Link +document.getElementById('share-btn').addEventListener('click', () => { + if (!currentHistoryId) return; + const url = `${window.location.origin}${window.location.pathname}?s=${currentHistoryId}`; + navigator.clipboard.writeText(url).then(() => { + const btn = document.getElementById('share-btn'); + btn.textContent = 'Copied!'; + setTimeout(() => { btn.textContent = 'Copy Link'; }, 2000); + }).catch(() => { + // Fallback for insecure contexts + prompt('Copy this link:', `${window.location.origin}${window.location.pathname}?s=${currentHistoryId}`); + }); +}); + // Reset document.getElementById('reset-btn').addEventListener('click', () => { // Clear mood input and state @@ -445,3 +507,4 @@ document.getElementById('reset-btn').addEventListener('click', () => { // --- Init --- checkAuth(); +loadSharedSearch(); diff --git a/app/static/index.html b/app/static/index.html index ee6c393..627cb71 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -109,10 +109,13 @@