diff --git a/app/api/session.py b/app/api/session.py index c5031b1..0178e38 100644 --- a/app/api/session.py +++ b/app/api/session.py @@ -27,6 +27,11 @@ class SelectedFilesReorderRequest(BaseModel): class SessionSettingsRequest(BaseModel): set_file_date_to_first_aired_date: bool | None = None default_media_root_path: str | None = None + remember_max_series: int | None = Field(default=None, ge=1, le=100) + + +class RememberedSeriesUpsertRequest(BaseModel): + item: dict = Field(default_factory=dict) def _normalize_session_id(session_id: str) -> str: @@ -160,6 +165,30 @@ def put_session_settings(payload: SessionSettingsRequest): return {"settings": settings} +@router.get("/remembered-series") +def get_remembered_series(): + service = SessionService() + items = service.list_remembered_series() + return {"items": items} + + +@router.post("/remembered-series") +def post_remembered_series(payload: RememberedSeriesUpsertRequest): + service = SessionService() + try: + items = service.remember_series(payload.item) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + return {"items": items} + + +@router.delete("/remembered-series") +def delete_remembered_series(): + service = SessionService() + service.clear_remembered_series() + return {"items": []} + + @router.get("/mapping-preview") def get_mapping_preview(session_id: str = Query("default", min_length=1)): service = SessionService() diff --git a/app/services/session_service.py b/app/services/session_service.py index e2f46de..07126cb 100644 --- a/app/services/session_service.py +++ b/app/services/session_service.py @@ -12,7 +12,9 @@ from app.config import APP_DATA_DIR class SessionService: FILE_DATE_SETTING_KEY = "set_file_date_to_first_aired_date" DEFAULT_ROOT_SETTING_KEY = "default_media_root_path" + REMEMBER_MAX_SERIES_KEY = "remember_max_series" MAX_FILENAME_LEN = 220 + DEFAULT_REMEMBER_MAX_SERIES = 10 def __init__(self) -> None: self._db_path = Path(APP_DATA_DIR) / "session_state.sqlite3" @@ -123,6 +125,21 @@ class SessionService: ) """ ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS remembered_series ( + series_id TEXT PRIMARY KEY, + payload_json TEXT NOT NULL, + last_selected_at TEXT NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_remembered_series_last_selected + ON remembered_series(last_selected_at DESC) + """ + ) def get_settings(self) -> dict: with self._connect() as conn: @@ -134,16 +151,27 @@ class SessionService: ).fetchall() values = {str(row["key"]): str(row["value"]) for row in rows} + remember_max_raw = values.get(self.REMEMBER_MAX_SERIES_KEY, str(self.DEFAULT_REMEMBER_MAX_SERIES)) + try: + remember_max = int(remember_max_raw) + except ValueError: + remember_max = self.DEFAULT_REMEMBER_MAX_SERIES + remember_max = max(1, min(100, remember_max)) return { self.FILE_DATE_SETTING_KEY: values.get(self.FILE_DATE_SETTING_KEY, "0") == "1", self.DEFAULT_ROOT_SETTING_KEY: values.get(self.DEFAULT_ROOT_SETTING_KEY) or None, + self.REMEMBER_MAX_SERIES_KEY: remember_max, } def update_settings(self, settings: dict) -> dict: updated_at = datetime.now(timezone.utc).isoformat() current = self.get_settings() - allowed_keys = {self.FILE_DATE_SETTING_KEY, self.DEFAULT_ROOT_SETTING_KEY} + allowed_keys = { + self.FILE_DATE_SETTING_KEY, + self.DEFAULT_ROOT_SETTING_KEY, + self.REMEMBER_MAX_SERIES_KEY, + } unknown_keys = [key for key in settings.keys() if key not in allowed_keys] if unknown_keys: raise ValueError(f"unknown setting key: {unknown_keys[0]}") @@ -160,6 +188,12 @@ class SessionService: raise ValueError(f"{self.DEFAULT_ROOT_SETTING_KEY} must be string or null") default_root_path = (default_root_path or "").strip() or None + remember_max_value = merged.get(self.REMEMBER_MAX_SERIES_KEY, self.DEFAULT_REMEMBER_MAX_SERIES) + if not isinstance(remember_max_value, int): + raise ValueError(f"{self.REMEMBER_MAX_SERIES_KEY} must be integer") + if remember_max_value < 1 or remember_max_value > 100: + raise ValueError(f"{self.REMEMBER_MAX_SERIES_KEY} must be between 1 and 100") + with self._connect() as conn: conn.execute( """ @@ -181,9 +215,92 @@ class SessionService: """, (self.DEFAULT_ROOT_SETTING_KEY, default_root_path or "", updated_at), ) + conn.execute( + """ + INSERT INTO app_settings (key, value, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + updated_at = excluded.updated_at + """, + (self.REMEMBER_MAX_SERIES_KEY, str(remember_max_value), updated_at), + ) + + self._enforce_remembered_series_limit(remember_max_value) return self.get_settings() + def list_remembered_series(self) -> list[dict]: + with self._connect() as conn: + rows = conn.execute( + """ + SELECT series_id, payload_json, last_selected_at + FROM remembered_series + ORDER BY last_selected_at DESC + """ + ).fetchall() + + items = [] + for row in rows: + payload = json.loads(row["payload_json"]) + items.append( + { + "series_id": row["series_id"], + "last_selected_at": row["last_selected_at"], + "series": payload, + } + ) + return items + + def remember_series(self, item: dict) -> list[dict]: + series_id = str(item.get("id") or "").strip() + if not series_id: + raise ValueError("series id is required") + + # Preserve only expected display fields plus raw payload. + payload = { + "id": item.get("id"), + "name": item.get("name"), + "year": item.get("year"), + "display_name": item.get("display_name"), + "raw": item.get("raw", {}), + } + now = datetime.now(timezone.utc).isoformat() + with self._connect() as conn: + conn.execute( + """ + INSERT INTO remembered_series (series_id, payload_json, last_selected_at) + VALUES (?, ?, ?) + ON CONFLICT(series_id) DO UPDATE SET + payload_json = excluded.payload_json, + last_selected_at = excluded.last_selected_at + """, + (series_id, json.dumps(payload, ensure_ascii=True), now), + ) + + remember_max = int(self.get_settings().get(self.REMEMBER_MAX_SERIES_KEY, self.DEFAULT_REMEMBER_MAX_SERIES)) + self._enforce_remembered_series_limit(remember_max) + return self.list_remembered_series() + + def clear_remembered_series(self) -> None: + with self._connect() as conn: + conn.execute("DELETE FROM remembered_series") + + def _enforce_remembered_series_limit(self, limit: int) -> None: + with self._connect() as conn: + conn.execute( + """ + DELETE FROM remembered_series + WHERE series_id NOT IN ( + SELECT series_id + FROM remembered_series + ORDER BY last_selected_at DESC + LIMIT ? + ) + """, + (int(limit),), + ) + def list_selected_episodes(self, session_id: str) -> list[dict]: with self._connect() as conn: rows = conn.execute( diff --git a/app/static/app.js b/app/static/app.js index 13fb60c..4d4c948 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -20,13 +20,17 @@ settings: { set_file_date_to_first_aired_date: false, default_media_root_path: null, + remember_max_series: 10, }, + rememberedSeries: [], + liveSearchItems: [], }; const el = { sessionMeta: document.getElementById("sessionMeta"), outputBox: document.getElementById("outputBox"), searchInput: document.getElementById("searchInput"), + searchDropdownBtn: document.getElementById("searchDropdownBtn"), searchBtn: document.getElementById("searchBtn"), searchResults: document.getElementById("searchResults"), seriesInfo: document.getElementById("seriesInfo"), @@ -43,6 +47,8 @@ saveSettingsBtn: document.getElementById("saveSettingsBtn"), setFileDateToFirstAiredDateInput: document.getElementById("setFileDateToFirstAiredDateInput"), defaultMediaRootSelect: document.getElementById("defaultMediaRootSelect"), + rememberMaxSeriesInput: document.getElementById("rememberMaxSeriesInput"), + purgeRememberedSeriesBtn: document.getElementById("purgeRememberedSeriesBtn"), refreshEpisodesBtn: document.getElementById("refreshEpisodesBtn"), episodesList: document.getElementById("episodesList"), episodeMeta: document.getElementById("episodeMeta"), @@ -214,6 +220,9 @@ function applySettingsToForm() { el.setFileDateToFirstAiredDateInput.checked = !!state.settings.set_file_date_to_first_aired_date; + if (el.rememberMaxSeriesInput) { + el.rememberMaxSeriesInput.value = String(state.settings.remember_max_series || 10); + } if (el.defaultMediaRootSelect) { const wanted = state.settings.default_media_root_path || ""; el.defaultMediaRootSelect.value = wanted; @@ -262,14 +271,20 @@ state.settings = data.settings || { set_file_date_to_first_aired_date: false, default_media_root_path: null, + remember_max_series: 10, }; applySettingsToForm(); } async function saveSettings() { + const rememberMax = Number(el.rememberMaxSeriesInput.value || "10"); + if (!Number.isInteger(rememberMax) || rememberMax < 1 || rememberMax > 100) { + throw new Error("Remember max series must be an integer between 1 and 100"); + } const payload = { set_file_date_to_first_aired_date: !!el.setFileDateToFirstAiredDateInput.checked, default_media_root_path: (el.defaultMediaRootSelect.value || "").trim() || null, + remember_max_series: rememberMax, }; const data = await api("/api/session/settings", { method: "PUT", @@ -283,6 +298,89 @@ out("Settings saved", data); } + async function loadRememberedSeries() { + const data = await api("/api/session/remembered-series"); + state.rememberedSeries = data.items || []; + } + + async function rememberSeries(item) { + await api("/api/session/remembered-series", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ item }), + }); + await loadRememberedSeries(); + } + + async function purgeRememberedSeries() { + const ok = window.confirm("Are you sure?"); + if (!ok) return; + await api("/api/session/remembered-series", { method: "DELETE" }); + await loadRememberedSeries(); + renderRememberedDropdown(); + } + + function rememberedForQuery(query) { + const normalized = (query || "").trim().toLowerCase(); + const rememberedItems = (state.rememberedSeries || []).map((entry) => entry.series || {}); + return rememberedItems.filter((item) => { + if (!normalized) return true; + const text = `${item.display_name || ""} ${item.name || ""}`.toLowerCase(); + return text.includes(normalized); + }); + } + + function renderRememberedDropdown() { + const query = (el.searchInput.value || "").trim(); + const items = rememberedForQuery(query); + el.searchResults.innerHTML = ""; + + items.forEach((item) => { + const li = document.createElement("li"); + const left = document.createElement("span"); + const label = item.display_name || item.name || "(series)"; + left.textContent = label; + + const right = document.createElement("div"); + const tag = document.createElement("span"); + tag.className = "badge"; + tag.textContent = "Remembered"; + right.appendChild(tag); + li.appendChild(left); + li.appendChild(right); + li.addEventListener("click", () => withHandler(() => selectSeries(item), li)); + el.searchResults.appendChild(li); + }); + } + + function renderSearchResults(items) { + el.searchResults.innerHTML = ""; + (items || []).forEach((item) => { + const li = document.createElement("li"); + const left = document.createElement("span"); + left.textContent = item.display_name || item.name || "(series)"; + li.appendChild(left); + li.addEventListener("click", () => withHandler(() => selectSeries(item), li)); + el.searchResults.appendChild(li); + }); + } + + async function selectSeries(item) { + state.selectedSeries = item; + state.selectedSeriesSummary = null; + el.seriesInfo.textContent = `Selected: ${item.display_name || item.name}`; + renderSelectedSeriesDetails(); + await rememberSeries(item); + renderRememberedDropdown(); + try { + await loadSeriesSummary(item.id); + renderSelectedSeriesDetails(); + } catch (_err) { + // Keep UI responsive with fallback data. + } + await loadEpisodes(); + } + function preferredRootId() { if (!state.roots.length) return ""; const wantedPath = (state.settings.default_media_root_path || "").trim(); @@ -370,31 +468,14 @@ async function doSearch() { const query = (el.searchInput.value || "").trim(); - if (!query) return; + if (!query || query.length < 2) { + state.liveSearchItems = []; + renderRememberedDropdown(); + return; + } const data = await api(`/api/tvdb/search?q=${encodeURIComponent(query)}`); - el.searchResults.innerHTML = ""; - (data.items || []).forEach((item) => { - const li = document.createElement("li"); - const left = document.createElement("span"); - left.textContent = item.display_name || item.name || "(series)"; - const right = document.createElement("div"); - right.appendChild(makeBtn("Select", async () => { - state.selectedSeries = item; - state.selectedSeriesSummary = null; - el.seriesInfo.textContent = `Selected: ${item.display_name || item.name}`; - renderSelectedSeriesDetails(); - try { - await loadSeriesSummary(item.id); - renderSelectedSeriesDetails(); - } catch (_err) { - // Keep UI responsive with search payload fallback if summary lookup fails. - } - await loadEpisodes(); - })); - li.appendChild(left); - li.appendChild(right); - el.searchResults.appendChild(li); - }); + state.liveSearchItems = data.items || []; + renderSearchResults(state.liveSearchItems); out("Search result", data); } @@ -704,9 +785,14 @@ function bindEvents() { el.searchBtn.addEventListener("click", () => withHandler(doSearch, el.searchBtn)); + el.searchInput.addEventListener("focus", renderRememberedDropdown); + el.searchInput.addEventListener("click", renderRememberedDropdown); + el.searchInput.addEventListener("input", renderRememberedDropdown); + el.searchDropdownBtn.addEventListener("click", renderRememberedDropdown); el.settingsBtn.addEventListener("click", openSettingsModal); el.closeSettingsModalBtn.addEventListener("click", closeSettingsModal); el.saveSettingsBtn.addEventListener("click", () => withHandler(saveSettings, el.saveSettingsBtn)); + el.purgeRememberedSeriesBtn.addEventListener("click", () => withHandler(purgeRememberedSeries, el.purgeRememberedSeriesBtn)); el.settingsModal.addEventListener("click", (e) => { if (e.target === el.settingsModal) closeSettingsModal(); }); @@ -765,6 +851,8 @@ renderSelectedSeriesDetails(); bindEvents(); await loadSettings(); + await loadRememberedSeries(); + renderRememberedDropdown(); await loadSelectedEpisodes(); await loadSelectedFiles(); await loadRoots(); diff --git a/app/static/styles.css b/app/static/styles.css index 1ab175f..1dff0cc 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -109,6 +109,17 @@ body { margin-bottom: 8px; } +.search-combobox-row input[type="text"] { + flex: 1; + min-width: 0; +} + +#searchDropdownBtn { + min-width: 34px; + padding-left: 8px; + padding-right: 8px; +} + .stack { display: flex; flex-direction: column; @@ -345,6 +356,15 @@ button.secondary { width: 100%; } +.settings-field input[type="number"] { + min-width: 0; + width: 100%; +} + +.settings-danger-row { + margin-top: 6px; +} + .settings-check { display: flex; align-items: center; diff --git a/app/templates/index.html b/app/templates/index.html index 2a09e60..b648914 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -19,8 +19,9 @@
-
+
+
@@ -152,6 +153,13 @@
+
+ + +
+
+ +

Renaming Rules

diff --git a/data/session_state.sqlite3 b/data/session_state.sqlite3 index e5170fd..326eeb4 100644 Binary files a/data/session_state.sqlite3 and b/data/session_state.sqlite3 differ diff --git a/feature_tests_remembered_series.sh b/feature_tests_remembered_series.sh new file mode 100755 index 0000000..4d328be --- /dev/null +++ b/feature_tests_remembered_series.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ -z "${BASE_URL:-}" ]; then + if curl --silent --fail http://127.0.0.1:8085/api/health >/dev/null 2>&1; then + BASE_URL="http://127.0.0.1:8085" + elif curl --silent --fail http://host.containers.internal:8085/api/health >/dev/null 2>&1; then + BASE_URL="http://host.containers.internal:8085" + else + echo "ERROR: could not determine BASE_URL. Tried 127.0.0.1 and host.containers.internal." >&2 + exit 1 + fi +fi + +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +echo "== Feature test 1: remembered series list can be purged and starts empty ==" +curl --fail --silent --show-error \ + -X DELETE "${BASE_URL}/api/session/remembered-series" \ + -o "${TMP_DIR}/purge.json" + +curl --fail --silent --show-error \ + "${BASE_URL}/api/session/remembered-series" \ + -o "${TMP_DIR}/remembered_empty.json" + +cat "${TMP_DIR}/remembered_empty.json" + +python3 - "${TMP_DIR}/remembered_empty.json" <<'PY' +import json +import sys +from pathlib import Path + +data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +items = data.get("items") +assert isinstance(items, list), "items must be list" +assert len(items) == 0, "items should be empty after purge" +print("purge baseline validation passed") +PY + +echo +echo "== Feature test 2: MRU and dedupe by series id ==" +cat > "${TMP_DIR}/s1.json" <<'JSON' +{"item":{"id":"100","name":"Series A","year":"2020","display_name":"Series A (2020)","raw":{"slug":"series-a"}}} +JSON +cat > "${TMP_DIR}/s2.json" <<'JSON' +{"item":{"id":"200","name":"Series B","year":"2021","display_name":"Series B (2021)","raw":{"slug":"series-b"}}} +JSON +cat > "${TMP_DIR}/s1b.json" <<'JSON' +{"item":{"id":"100","name":"Series A","year":"2020","display_name":"Series A (2020)","raw":{"slug":"series-a"}}} +JSON + +curl --fail --silent --show-error -X POST "${BASE_URL}/api/session/remembered-series" -H "Content-Type: application/json" --data @"${TMP_DIR}/s1.json" >/dev/null +curl --fail --silent --show-error -X POST "${BASE_URL}/api/session/remembered-series" -H "Content-Type: application/json" --data @"${TMP_DIR}/s2.json" >/dev/null +curl --fail --silent --show-error -X POST "${BASE_URL}/api/session/remembered-series" -H "Content-Type: application/json" --data @"${TMP_DIR}/s1b.json" >/dev/null + +curl --fail --silent --show-error \ + "${BASE_URL}/api/session/remembered-series" \ + -o "${TMP_DIR}/remembered_mru.json" + +cat "${TMP_DIR}/remembered_mru.json" + +python3 - "${TMP_DIR}/remembered_mru.json" <<'PY' +import json +import sys +from pathlib import Path + +data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +items = data.get("items") or [] +assert len(items) == 2, "expected deduped list of 2" +assert items[0]["series"]["id"] == "100", "series 100 should be most-recent after reselection" +assert items[1]["series"]["id"] == "200", "series 200 should be second" +print("MRU and dedupe validation passed") +PY + +echo +echo "== Feature test 3: remember_max_series limit evicts oldest ==" +cat > "${TMP_DIR}/settings_limit_2.json" <<'JSON' +{"remember_max_series":2} +JSON +curl --fail --silent --show-error \ + -X PUT "${BASE_URL}/api/session/settings" \ + -H "Content-Type: application/json" \ + --data @"${TMP_DIR}/settings_limit_2.json" \ + -o "${TMP_DIR}/settings_limit_resp.json" + +cat > "${TMP_DIR}/s3.json" <<'JSON' +{"item":{"id":"300","name":"Series C","year":"2022","display_name":"Series C (2022)","raw":{"slug":"series-c"}}} +JSON +curl --fail --silent --show-error -X POST "${BASE_URL}/api/session/remembered-series" -H "Content-Type: application/json" --data @"${TMP_DIR}/s3.json" >/dev/null + +curl --fail --silent --show-error \ + "${BASE_URL}/api/session/remembered-series" \ + -o "${TMP_DIR}/remembered_limited.json" + +cat "${TMP_DIR}/remembered_limited.json" + +python3 - "${TMP_DIR}/remembered_limited.json" <<'PY' +import json +import sys +from pathlib import Path + +data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +items = data.get("items") or [] +ids = [str(i["series"]["id"]) for i in items] +assert len(items) == 2, f"limit should keep 2, got {len(items)}" +assert ids[0] == "300", "latest should be first" +assert "200" not in ids, "oldest series should be evicted" +print("limit enforcement validation passed") +PY + +cat > "${TMP_DIR}/settings_reset_10.json" <<'JSON' +{"remember_max_series":10} +JSON +curl --fail --silent --show-error \ + -X PUT "${BASE_URL}/api/session/settings" \ + -H "Content-Type: application/json" \ + --data @"${TMP_DIR}/settings_reset_10.json" \ + >/dev/null + +echo +echo "All remembered series feature tests passed."