feat (ui): series onthouden
This commit is contained in:
@@ -27,6 +27,11 @@ class SelectedFilesReorderRequest(BaseModel):
|
|||||||
class SessionSettingsRequest(BaseModel):
|
class SessionSettingsRequest(BaseModel):
|
||||||
set_file_date_to_first_aired_date: bool | None = None
|
set_file_date_to_first_aired_date: bool | None = None
|
||||||
default_media_root_path: str | 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:
|
def _normalize_session_id(session_id: str) -> str:
|
||||||
@@ -160,6 +165,30 @@ def put_session_settings(payload: SessionSettingsRequest):
|
|||||||
return {"settings": settings}
|
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")
|
@router.get("/mapping-preview")
|
||||||
def get_mapping_preview(session_id: str = Query("default", min_length=1)):
|
def get_mapping_preview(session_id: str = Query("default", min_length=1)):
|
||||||
service = SessionService()
|
service = SessionService()
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ from app.config import APP_DATA_DIR
|
|||||||
class SessionService:
|
class SessionService:
|
||||||
FILE_DATE_SETTING_KEY = "set_file_date_to_first_aired_date"
|
FILE_DATE_SETTING_KEY = "set_file_date_to_first_aired_date"
|
||||||
DEFAULT_ROOT_SETTING_KEY = "default_media_root_path"
|
DEFAULT_ROOT_SETTING_KEY = "default_media_root_path"
|
||||||
|
REMEMBER_MAX_SERIES_KEY = "remember_max_series"
|
||||||
MAX_FILENAME_LEN = 220
|
MAX_FILENAME_LEN = 220
|
||||||
|
DEFAULT_REMEMBER_MAX_SERIES = 10
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._db_path = Path(APP_DATA_DIR) / "session_state.sqlite3"
|
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:
|
def get_settings(self) -> dict:
|
||||||
with self._connect() as conn:
|
with self._connect() as conn:
|
||||||
@@ -134,16 +151,27 @@ class SessionService:
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
values = {str(row["key"]): str(row["value"]) for row in rows}
|
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 {
|
return {
|
||||||
self.FILE_DATE_SETTING_KEY: values.get(self.FILE_DATE_SETTING_KEY, "0") == "1",
|
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.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:
|
def update_settings(self, settings: dict) -> dict:
|
||||||
updated_at = datetime.now(timezone.utc).isoformat()
|
updated_at = datetime.now(timezone.utc).isoformat()
|
||||||
current = self.get_settings()
|
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]
|
unknown_keys = [key for key in settings.keys() if key not in allowed_keys]
|
||||||
if unknown_keys:
|
if unknown_keys:
|
||||||
raise ValueError(f"unknown setting key: {unknown_keys[0]}")
|
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")
|
raise ValueError(f"{self.DEFAULT_ROOT_SETTING_KEY} must be string or null")
|
||||||
default_root_path = (default_root_path or "").strip() or None
|
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:
|
with self._connect() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -181,9 +215,92 @@ class SessionService:
|
|||||||
""",
|
""",
|
||||||
(self.DEFAULT_ROOT_SETTING_KEY, default_root_path or "", updated_at),
|
(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()
|
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]:
|
def list_selected_episodes(self, session_id: str) -> list[dict]:
|
||||||
with self._connect() as conn:
|
with self._connect() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
|
|||||||
+112
-24
@@ -20,13 +20,17 @@
|
|||||||
settings: {
|
settings: {
|
||||||
set_file_date_to_first_aired_date: false,
|
set_file_date_to_first_aired_date: false,
|
||||||
default_media_root_path: null,
|
default_media_root_path: null,
|
||||||
|
remember_max_series: 10,
|
||||||
},
|
},
|
||||||
|
rememberedSeries: [],
|
||||||
|
liveSearchItems: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const el = {
|
const el = {
|
||||||
sessionMeta: document.getElementById("sessionMeta"),
|
sessionMeta: document.getElementById("sessionMeta"),
|
||||||
outputBox: document.getElementById("outputBox"),
|
outputBox: document.getElementById("outputBox"),
|
||||||
searchInput: document.getElementById("searchInput"),
|
searchInput: document.getElementById("searchInput"),
|
||||||
|
searchDropdownBtn: document.getElementById("searchDropdownBtn"),
|
||||||
searchBtn: document.getElementById("searchBtn"),
|
searchBtn: document.getElementById("searchBtn"),
|
||||||
searchResults: document.getElementById("searchResults"),
|
searchResults: document.getElementById("searchResults"),
|
||||||
seriesInfo: document.getElementById("seriesInfo"),
|
seriesInfo: document.getElementById("seriesInfo"),
|
||||||
@@ -43,6 +47,8 @@
|
|||||||
saveSettingsBtn: document.getElementById("saveSettingsBtn"),
|
saveSettingsBtn: document.getElementById("saveSettingsBtn"),
|
||||||
setFileDateToFirstAiredDateInput: document.getElementById("setFileDateToFirstAiredDateInput"),
|
setFileDateToFirstAiredDateInput: document.getElementById("setFileDateToFirstAiredDateInput"),
|
||||||
defaultMediaRootSelect: document.getElementById("defaultMediaRootSelect"),
|
defaultMediaRootSelect: document.getElementById("defaultMediaRootSelect"),
|
||||||
|
rememberMaxSeriesInput: document.getElementById("rememberMaxSeriesInput"),
|
||||||
|
purgeRememberedSeriesBtn: document.getElementById("purgeRememberedSeriesBtn"),
|
||||||
refreshEpisodesBtn: document.getElementById("refreshEpisodesBtn"),
|
refreshEpisodesBtn: document.getElementById("refreshEpisodesBtn"),
|
||||||
episodesList: document.getElementById("episodesList"),
|
episodesList: document.getElementById("episodesList"),
|
||||||
episodeMeta: document.getElementById("episodeMeta"),
|
episodeMeta: document.getElementById("episodeMeta"),
|
||||||
@@ -214,6 +220,9 @@
|
|||||||
|
|
||||||
function applySettingsToForm() {
|
function applySettingsToForm() {
|
||||||
el.setFileDateToFirstAiredDateInput.checked = !!state.settings.set_file_date_to_first_aired_date;
|
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) {
|
if (el.defaultMediaRootSelect) {
|
||||||
const wanted = state.settings.default_media_root_path || "";
|
const wanted = state.settings.default_media_root_path || "";
|
||||||
el.defaultMediaRootSelect.value = wanted;
|
el.defaultMediaRootSelect.value = wanted;
|
||||||
@@ -262,14 +271,20 @@
|
|||||||
state.settings = data.settings || {
|
state.settings = data.settings || {
|
||||||
set_file_date_to_first_aired_date: false,
|
set_file_date_to_first_aired_date: false,
|
||||||
default_media_root_path: null,
|
default_media_root_path: null,
|
||||||
|
remember_max_series: 10,
|
||||||
};
|
};
|
||||||
applySettingsToForm();
|
applySettingsToForm();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveSettings() {
|
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 = {
|
const payload = {
|
||||||
set_file_date_to_first_aired_date: !!el.setFileDateToFirstAiredDateInput.checked,
|
set_file_date_to_first_aired_date: !!el.setFileDateToFirstAiredDateInput.checked,
|
||||||
default_media_root_path: (el.defaultMediaRootSelect.value || "").trim() || null,
|
default_media_root_path: (el.defaultMediaRootSelect.value || "").trim() || null,
|
||||||
|
remember_max_series: rememberMax,
|
||||||
};
|
};
|
||||||
const data = await api("/api/session/settings", {
|
const data = await api("/api/session/settings", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@@ -283,6 +298,89 @@
|
|||||||
out("Settings saved", data);
|
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() {
|
function preferredRootId() {
|
||||||
if (!state.roots.length) return "";
|
if (!state.roots.length) return "";
|
||||||
const wantedPath = (state.settings.default_media_root_path || "").trim();
|
const wantedPath = (state.settings.default_media_root_path || "").trim();
|
||||||
@@ -370,31 +468,14 @@
|
|||||||
|
|
||||||
async function doSearch() {
|
async function doSearch() {
|
||||||
const query = (el.searchInput.value || "").trim();
|
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)}`);
|
const data = await api(`/api/tvdb/search?q=${encodeURIComponent(query)}`);
|
||||||
el.searchResults.innerHTML = "";
|
state.liveSearchItems = data.items || [];
|
||||||
(data.items || []).forEach((item) => {
|
renderSearchResults(state.liveSearchItems);
|
||||||
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);
|
|
||||||
});
|
|
||||||
out("Search result", data);
|
out("Search result", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -704,9 +785,14 @@
|
|||||||
|
|
||||||
function bindEvents() {
|
function bindEvents() {
|
||||||
el.searchBtn.addEventListener("click", () => withHandler(doSearch, el.searchBtn));
|
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.settingsBtn.addEventListener("click", openSettingsModal);
|
||||||
el.closeSettingsModalBtn.addEventListener("click", closeSettingsModal);
|
el.closeSettingsModalBtn.addEventListener("click", closeSettingsModal);
|
||||||
el.saveSettingsBtn.addEventListener("click", () => withHandler(saveSettings, el.saveSettingsBtn));
|
el.saveSettingsBtn.addEventListener("click", () => withHandler(saveSettings, el.saveSettingsBtn));
|
||||||
|
el.purgeRememberedSeriesBtn.addEventListener("click", () => withHandler(purgeRememberedSeries, el.purgeRememberedSeriesBtn));
|
||||||
el.settingsModal.addEventListener("click", (e) => {
|
el.settingsModal.addEventListener("click", (e) => {
|
||||||
if (e.target === el.settingsModal) closeSettingsModal();
|
if (e.target === el.settingsModal) closeSettingsModal();
|
||||||
});
|
});
|
||||||
@@ -765,6 +851,8 @@
|
|||||||
renderSelectedSeriesDetails();
|
renderSelectedSeriesDetails();
|
||||||
bindEvents();
|
bindEvents();
|
||||||
await loadSettings();
|
await loadSettings();
|
||||||
|
await loadRememberedSeries();
|
||||||
|
renderRememberedDropdown();
|
||||||
await loadSelectedEpisodes();
|
await loadSelectedEpisodes();
|
||||||
await loadSelectedFiles();
|
await loadSelectedFiles();
|
||||||
await loadRoots();
|
await loadRoots();
|
||||||
|
|||||||
@@ -109,6 +109,17 @@ body {
|
|||||||
margin-bottom: 8px;
|
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 {
|
.stack {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -345,6 +356,15 @@ button.secondary {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-field input[type="number"] {
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-danger-row {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-check {
|
.settings-check {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -19,8 +19,9 @@
|
|||||||
<div class="panel-head-actions panel-head-actions-empty" aria-hidden="true"></div>
|
<div class="panel-head-actions panel-head-actions-empty" aria-hidden="true"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
<div class="row">
|
<div class="row search-combobox-row">
|
||||||
<input id="searchInput" type="text" placeholder="Search series..." />
|
<input id="searchInput" type="text" placeholder="Search series..." />
|
||||||
|
<button id="searchDropdownBtn" class="secondary" aria-label="Show remembered series">▼</button>
|
||||||
<button id="searchBtn">Search</button>
|
<button id="searchBtn">Search</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="seriesInfo" class="muted"></div>
|
<div id="seriesInfo" class="muted"></div>
|
||||||
@@ -152,6 +153,13 @@
|
|||||||
<option value="">Auto (first available)</option>
|
<option value="">Auto (first available)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<h4>Renaming Rules</h4>
|
<h4>Renaming Rules</h4>
|
||||||
|
|||||||
Binary file not shown.
Executable
+122
@@ -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."
|
||||||
Reference in New Issue
Block a user