Add search history with saved results
Saves each mood search and its recommendations to SQLite per user. Recent searches appear below the results area with mood text, top movie titles, and timestamp. Click any entry to reload those results. Entries can be deleted individually. History toggleable via show/hide. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -59,6 +59,15 @@ async def init_database():
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS search_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
mood TEXT NOT NULL,
|
||||
results TEXT NOT NULL,
|
||||
meta TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
""")
|
||||
await db.commit()
|
||||
finally:
|
||||
|
||||
+69
-10
@@ -1,11 +1,12 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_unwatched_movies
|
||||
from app.database import get_db, get_unwatched_movies
|
||||
from app.models import MoodRequest, MoodResponse, Recommendation
|
||||
from app.routers.auth import get_current_user
|
||||
from app.services.jellyfin import get_deep_link, get_poster_url
|
||||
@@ -89,12 +90,70 @@ async def get_mood_recommendations(request: Request, body: MoodRequest):
|
||||
|
||||
elapsed_ms = int((time.time() - start_time) * 1000)
|
||||
|
||||
return MoodResponse(
|
||||
recommendations=recommendations,
|
||||
meta={
|
||||
"total_unwatched": total_unwatched,
|
||||
"candidates_evaluated": len(candidates),
|
||||
"processing_time_ms": elapsed_ms,
|
||||
"mood": body.mood,
|
||||
},
|
||||
)
|
||||
meta = {
|
||||
"total_unwatched": total_unwatched,
|
||||
"candidates_evaluated": len(candidates),
|
||||
"processing_time_ms": elapsed_ms,
|
||||
"mood": body.mood,
|
||||
}
|
||||
|
||||
# Save to search history
|
||||
try:
|
||||
db = await get_db()
|
||||
try:
|
||||
await db.execute(
|
||||
"INSERT INTO search_history (user_id, mood, results, meta, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
(
|
||||
user["id"],
|
||||
body.mood,
|
||||
json.dumps([r.model_dump() for r in recommendations]),
|
||||
json.dumps(meta),
|
||||
datetime.now(timezone.utc).isoformat(),
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
finally:
|
||||
await db.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save search history: {e}")
|
||||
|
||||
return MoodResponse(recommendations=recommendations, meta=meta)
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
async def get_search_history(request: Request):
|
||||
user = await get_current_user(request)
|
||||
db = await get_db()
|
||||
try:
|
||||
cursor = await db.execute(
|
||||
"SELECT id, mood, results, meta, created_at FROM search_history WHERE user_id = ? ORDER BY created_at DESC LIMIT 20",
|
||||
(user["id"],),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
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"],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
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)
|
||||
db = await get_db()
|
||||
try:
|
||||
await db.execute(
|
||||
"DELETE FROM search_history WHERE id = ? AND user_id = ?",
|
||||
(entry_id, user["id"]),
|
||||
)
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
@@ -54,6 +54,7 @@ function showMainScreen() {
|
||||
selectedUserIds = [currentUser.id];
|
||||
loadUsers();
|
||||
loadStats();
|
||||
loadHistory();
|
||||
}
|
||||
|
||||
// --- Users ---
|
||||
@@ -113,6 +114,7 @@ async function findMovies() {
|
||||
|
||||
const data = await res.json();
|
||||
renderResults(data);
|
||||
loadHistory();
|
||||
} catch (err) {
|
||||
document.getElementById('loading').classList.add('hidden');
|
||||
document.getElementById('error-message').textContent = err.message;
|
||||
@@ -171,6 +173,86 @@ function renderResults(data) {
|
||||
document.getElementById('results').classList.remove('hidden');
|
||||
}
|
||||
|
||||
// --- Search History ---
|
||||
|
||||
async function loadHistory() {
|
||||
try {
|
||||
const res = await fetch(`${API}/api/history`);
|
||||
if (!res.ok) return;
|
||||
const history = await res.json();
|
||||
const list = document.getElementById('history-list');
|
||||
list.innerHTML = '';
|
||||
|
||||
if (history.length === 0) {
|
||||
document.getElementById('history-section').classList.add('hidden');
|
||||
document.getElementById('history-toggle').classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
history.forEach(entry => {
|
||||
const date = new Date(entry.created_at);
|
||||
const timeStr = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) +
|
||||
' ' + date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
||||
const movieTitles = entry.results.slice(0, 3).map(r => r.title).join(', ');
|
||||
const more = entry.results.length > 3 ? ` +${entry.results.length - 3} more` : '';
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.className = 'history-item bg-dark-200 border border-gray-800 rounded-lg p-3 cursor-pointer hover:border-gray-600 transition-colors';
|
||||
item.innerHTML = `
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-200 truncate">"${entry.mood}"</p>
|
||||
<p class="text-xs text-gray-500 mt-1 truncate">${movieTitles}${more}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<span class="text-xs text-gray-600">${timeStr}</span>
|
||||
<button class="history-delete text-gray-700 hover:text-red-400 transition-colors text-xs" data-id="${entry.id}">×</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
item.addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('history-delete')) return;
|
||||
showHistoryResults(entry);
|
||||
});
|
||||
list.appendChild(item);
|
||||
});
|
||||
|
||||
// Add delete handlers
|
||||
list.querySelectorAll('.history-delete').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const id = btn.dataset.id;
|
||||
await fetch(`${API}/api/history/${id}`, { method: 'DELETE' });
|
||||
loadHistory();
|
||||
});
|
||||
});
|
||||
|
||||
// Show the toggle button if history is currently hidden
|
||||
if (document.getElementById('history-section').classList.contains('hidden')) {
|
||||
document.getElementById('history-toggle').classList.remove('hidden');
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function showHistoryResults(entry) {
|
||||
document.getElementById('mood-input').value = entry.mood;
|
||||
const data = { recommendations: entry.results, meta: entry.meta };
|
||||
renderResults(data);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function toggleHistoryVisibility(show) {
|
||||
const section = document.getElementById('history-section');
|
||||
const toggle = document.getElementById('history-toggle');
|
||||
if (show) {
|
||||
section.classList.remove('hidden');
|
||||
toggle.classList.add('hidden');
|
||||
} else {
|
||||
section.classList.add('hidden');
|
||||
toggle.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Library Stats ---
|
||||
|
||||
async function loadStats() {
|
||||
@@ -217,6 +299,11 @@ document.getElementById('mood-input').addEventListener('keydown', (e) => {
|
||||
});
|
||||
document.getElementById('logout-btn').addEventListener('click', logout);
|
||||
document.getElementById('refresh-btn').addEventListener('click', refreshLibrary);
|
||||
document.getElementById('toggle-history-btn').addEventListener('click', () => toggleHistoryVisibility(false));
|
||||
document.getElementById('show-history-btn').addEventListener('click', () => {
|
||||
toggleHistoryVisibility(true);
|
||||
loadHistory();
|
||||
});
|
||||
|
||||
// --- Init ---
|
||||
checkAuth();
|
||||
|
||||
@@ -97,6 +97,19 @@
|
||||
class="mt-4 text-sm text-gray-400 hover:text-gray-200">Try again</button>
|
||||
</div>
|
||||
|
||||
<!-- Search History -->
|
||||
<div id="history-section" class="hidden mt-12">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-300">Recent Searches</h2>
|
||||
<button id="toggle-history-btn" class="text-sm text-indigo-400 hover:text-indigo-300 transition-colors">Hide</button>
|
||||
</div>
|
||||
<div id="history-list" class="space-y-3"></div>
|
||||
</div>
|
||||
|
||||
<div id="history-toggle" class="hidden mt-8 text-center">
|
||||
<button id="show-history-btn" class="text-sm text-gray-500 hover:text-gray-300 transition-colors">Show search history</button>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="mt-12 pt-6 border-t border-gray-800 flex items-center justify-between text-sm text-gray-600">
|
||||
<span id="library-stats"></span>
|
||||
|
||||
Reference in New Issue
Block a user