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 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,28 @@ async def library_stats(request: Request):
|
|||||||
await db.close()
|
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")
|
@router.post("/sync")
|
||||||
async def trigger_sync(request: Request):
|
async def trigger_sync(request: Request):
|
||||||
await get_current_user(request)
|
await get_current_user(request)
|
||||||
|
|||||||
@@ -175,6 +175,29 @@ async def get_search_history(request: Request):
|
|||||||
await db.close()
|
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}")
|
@router.delete("/history/{entry_id}")
|
||||||
async def delete_history_entry(entry_id: int, request: Request):
|
async def delete_history_entry(entry_id: int, request: Request):
|
||||||
user = await get_current_user(request)
|
user = await get_current_user(request)
|
||||||
|
|||||||
+64
-1
@@ -217,6 +217,7 @@ function renderResults(data, append = false) {
|
|||||||
data.recommendations.forEach(movie => {
|
data.recommendations.forEach(movie => {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'movie-card bg-dark-200 rounded-xl overflow-hidden border border-gray-800';
|
card.className = 'movie-card bg-dark-200 rounded-xl overflow-hidden border border-gray-800';
|
||||||
|
card.dataset.jellyfinId = movie.jellyfin_id;
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="aspect-[2/3] bg-dark-300 relative">
|
<div class="aspect-[2/3] bg-dark-300 relative">
|
||||||
<img src="${movie.poster_url}" alt="${movie.title}"
|
<img src="${movie.poster_url}" alt="${movie.title}"
|
||||||
@@ -315,11 +316,13 @@ async function loadHistory() {
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
function showHistoryResults(entry) {
|
async function showHistoryResults(entry) {
|
||||||
document.getElementById('mood-input').value = entry.mood;
|
document.getElementById('mood-input').value = entry.mood;
|
||||||
const data = { recommendations: entry.results, meta: entry.meta };
|
const data = { recommendations: entry.results, meta: entry.meta };
|
||||||
renderResults(data);
|
renderResults(data);
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
// Check watch state and dim watched movies
|
||||||
|
await markWatchedCards(entry.results.map(r => r.jellyfin_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleHistoryVisibility(show) {
|
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 ---
|
// --- Library Stats ---
|
||||||
|
|
||||||
async function loadStats() {
|
async function loadStats() {
|
||||||
@@ -418,6 +466,20 @@ document.getElementById('reroll-btn').addEventListener('click', () => {
|
|||||||
findMovies(shownMovieIds);
|
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
|
// Reset
|
||||||
document.getElementById('reset-btn').addEventListener('click', () => {
|
document.getElementById('reset-btn').addEventListener('click', () => {
|
||||||
// Clear mood input and state
|
// Clear mood input and state
|
||||||
@@ -445,3 +507,4 @@ document.getElementById('reset-btn').addEventListener('click', () => {
|
|||||||
|
|
||||||
// --- Init ---
|
// --- Init ---
|
||||||
checkAuth();
|
checkAuth();
|
||||||
|
loadSharedSearch();
|
||||||
|
|||||||
@@ -109,10 +109,13 @@
|
|||||||
<div id="results" class="hidden">
|
<div id="results" class="hidden">
|
||||||
<div id="results-grid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6"></div>
|
<div id="results-grid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6"></div>
|
||||||
<div id="results-meta" class="mt-6 text-center text-gray-500 text-sm"></div>
|
<div id="results-meta" class="mt-6 text-center text-gray-500 text-sm"></div>
|
||||||
<div class="mt-4 text-center">
|
<div class="mt-4 flex items-center justify-center gap-3">
|
||||||
<button id="reroll-btn" class="px-6 py-2 border border-indigo-600/50 text-indigo-400 hover:bg-indigo-600/20 rounded-lg font-semibold transition-colors text-sm">
|
<button id="reroll-btn" class="px-6 py-2 border border-indigo-600/50 text-indigo-400 hover:bg-indigo-600/20 rounded-lg font-semibold transition-colors text-sm">
|
||||||
Show Me More
|
Show Me More
|
||||||
</button>
|
</button>
|
||||||
|
<button id="share-btn" class="px-4 py-2 border border-gray-700 text-gray-500 hover:text-gray-300 hover:border-gray-500 rounded-lg transition-colors text-sm">
|
||||||
|
Copy Link
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -50,3 +50,23 @@
|
|||||||
border-color: #22c55e;
|
border-color: #22c55e;
|
||||||
color: #86efac;
|
color: #86efac;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.movie-card.watched {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
.movie-card.watched::after {
|
||||||
|
content: 'Watched';
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
background: rgba(34, 197, 94, 0.8);
|
||||||
|
color: white;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.movie-card.watched {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user