From 0a294e9bd5cb7de58df19903750f5787cef3e344 Mon Sep 17 00:00:00 2001 From: kodi Date: Mon, 9 Mar 2026 14:45:24 +0100 Subject: [PATCH] feat (ui): series onthouden --- app/api/session.py | 29 ++++++ app/services/session_service.py | 119 ++++++++++++++++++++++++- app/static/app.js | 136 ++++++++++++++++++++++++----- app/static/styles.css | 20 +++++ app/templates/index.html | 10 ++- data/session_state.sqlite3 | Bin 106496 -> 135168 bytes feature_tests_remembered_series.sh | 122 ++++++++++++++++++++++++++ 7 files changed, 410 insertions(+), 26 deletions(-) create mode 100755 feature_tests_remembered_series.sh 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 e5170fd8b4e685bc90ec08f8dd3d72b8dc1ae21c..326eeb4dbc57b1b1504ae070f3d0ed68aaf4ecdc 100644 GIT binary patch literal 135168 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 delta 152 zcmZozz|nAkZGyC*HUk5L6cEDz_e33I9&HA_szP3t$qc;AtrN4QHwy~poI zi9eiye+Pd!P{^BqdN@C$uauFMiGh`YnVylQp`nSnwgHf1;Ks<}&+j|^zCEMj^!xmb iynLvNw*TU1/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."