feat (ui): series onthouden

This commit is contained in:
kodi
2026-03-09 14:45:24 +01:00
parent c5aaa20ce2
commit 0a294e9bd5
7 changed files with 410 additions and 26 deletions
+29
View File
@@ -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()
+118 -1
View File
@@ -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(
+112 -24
View File
@@ -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();
+20
View File
@@ -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;
+9 -1
View File
@@ -19,8 +19,9 @@
<div class="panel-head-actions panel-head-actions-empty" aria-hidden="true"></div>
</div>
<div class="panel-body">
<div class="row">
<div class="row search-combobox-row">
<input id="searchInput" type="text" placeholder="Search series..." />
<button id="searchDropdownBtn" class="secondary" aria-label="Show remembered series"></button>
<button id="searchBtn">Search</button>
</div>
<div id="seriesInfo" class="muted"></div>
@@ -152,6 +153,13 @@
<option value="">Auto (first available)</option>
</select>
</div>
<div class="settings-field">
<label for="rememberMaxSeriesInput">Remember max series</label>
<input id="rememberMaxSeriesInput" type="number" min="1" max="100" step="1" />
</div>
<div class="settings-field settings-danger-row">
<button id="purgeRememberedSeriesBtn" class="secondary">Purge remembered series list</button>
</div>
</section>
<section class="settings-section">
<h4>Renaming Rules</h4>
Binary file not shown.
+122
View File
@@ -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."