From 1ed4d5cf52c867acfd916c47d4a129aee6bea7e4 Mon Sep 17 00:00:00 2001 From: kodi Date: Sat, 7 Mar 2026 15:50:58 +0100 Subject: [PATCH] fase 7 afgeronf --- app/api/session.py | 23 +++ app/services/session_service.py | 255 +++++++++++++++++++++++++++++++- data/session_state.sqlite3 | Bin 49152 -> 65536 bytes feature_tests_rename_log.sh | 211 ++++++++++++++++++++++++++ 4 files changed, 487 insertions(+), 2 deletions(-) create mode 100755 feature_tests_rename_log.sh diff --git a/app/api/session.py b/app/api/session.py index 7c46ce5..a5f765d 100644 --- a/app/api/session.py +++ b/app/api/session.py @@ -170,3 +170,26 @@ def rename_execute( return service.execute_rename(normalized_session_id, confirm=confirm) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) + + +@router.get("/rename-log") +def get_rename_log( + session_id: str = Query("default", min_length=1), + limit: int = Query(20, ge=1, le=200), +): + service = SessionService() + normalized_session_id = _normalize_session_id(session_id) + items = service.list_rename_runs(normalized_session_id, limit=limit) + return { + "session_id": normalized_session_id, + "items": items, + } + + +@router.get("/rename-log/{run_id}") +def get_rename_log_run(run_id: int): + service = SessionService() + run = service.get_rename_run(run_id) + if run is None: + raise HTTPException(status_code=404, detail="rename run not found") + return run diff --git a/app/services/session_service.py b/app/services/session_service.py index c90b8b7..ec6ff45 100644 --- a/app/services/session_service.py +++ b/app/services/session_service.py @@ -1,6 +1,8 @@ import json import os import sqlite3 +import time +from datetime import datetime, timezone from pathlib import Path from app.config import APP_DATA_DIR @@ -63,6 +65,50 @@ class SessionService: ON selected_files(session_id) """ ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS rename_runs ( + run_id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + created_at TEXT NOT NULL, + confirm INTEGER NOT NULL, + executed INTEGER NOT NULL, + preflight_ok INTEGER NOT NULL, + episodes_count INTEGER NOT NULL, + files_count INTEGER NOT NULL, + duration_ms INTEGER NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_rename_runs_session + ON rename_runs(session_id) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS rename_run_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id INTEGER NOT NULL, + item_index INTEGER NOT NULL, + episode_selection_id INTEGER, + file_selection_id INTEGER, + source_path TEXT NOT NULL, + destination_path TEXT NOT NULL, + proposed_filename TEXT NOT NULL, + status TEXT NOT NULL, + errors_json TEXT NOT NULL, + FOREIGN KEY(run_id) REFERENCES rename_runs(run_id) + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_rename_run_items_run + ON rename_run_items(run_id, item_index) + """ + ) def list_selected_episodes(self, session_id: str) -> list[dict]: with self._connect() as conn: @@ -407,6 +453,7 @@ class SessionService: if not confirm: raise ValueError("confirm=true is required to execute rename") + started_at = time.perf_counter() preview = self.build_filename_preview(session_id) allowed_roots = self._allowed_media_roots() preflight_items = [] @@ -444,7 +491,7 @@ class SessionService: ) if preflight_errors > 0: - return { + result = { "session_id": session_id, "confirm": confirm, "executed": False, @@ -452,6 +499,12 @@ class SessionService: "counts": preview["counts"], "items": preflight_items, } + self._log_rename_run( + session_id=session_id, + result=result, + duration_ms=int((time.perf_counter() - started_at) * 1000), + ) + return result results = [] for item in preflight_items: @@ -466,7 +519,7 @@ class SessionService: } ) - return { + result = { "session_id": session_id, "confirm": confirm, "executed": True, @@ -474,6 +527,204 @@ class SessionService: "counts": preview["counts"], "items": results, } + self._log_rename_run( + session_id=session_id, + result=result, + duration_ms=int((time.perf_counter() - started_at) * 1000), + ) + return result + + def _log_rename_run(self, session_id: str, result: dict, duration_ms: int) -> None: + created_at = datetime.now(timezone.utc).isoformat() + counts = result.get("counts", {}) + + with self._connect() as conn: + cursor = conn.execute( + """ + INSERT INTO rename_runs ( + session_id, + created_at, + confirm, + executed, + preflight_ok, + episodes_count, + files_count, + duration_ms + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + session_id, + created_at, + 1 if result.get("confirm") else 0, + 1 if result.get("executed") else 0, + 1 if result.get("preflight_ok") else 0, + int(counts.get("episodes") or 0), + int(counts.get("files") or 0), + int(duration_ms), + ), + ) + run_id = int(cursor.lastrowid) + + for item in result.get("items", []): + conn.execute( + """ + INSERT INTO rename_run_items ( + run_id, + item_index, + episode_selection_id, + file_selection_id, + source_path, + destination_path, + proposed_filename, + status, + errors_json + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + run_id, + int(item.get("index") or 0), + item.get("episode_selection_id"), + item.get("file_selection_id"), + str(item.get("source_path") or ""), + str(item.get("destination_path") or ""), + str(item.get("proposed_filename") or ""), + str(item.get("status") or ""), + json.dumps(item.get("errors") or [], ensure_ascii=True), + ), + ) + + def list_rename_runs(self, session_id: str, limit: int = 20) -> list[dict]: + with self._connect() as conn: + rows = conn.execute( + """ + SELECT + run_id, + session_id, + created_at, + confirm, + executed, + preflight_ok, + episodes_count, + files_count, + duration_ms + FROM rename_runs + WHERE session_id = ? + ORDER BY run_id DESC + LIMIT ? + """, + (session_id, limit), + ).fetchall() + + item_counts = conn.execute( + """ + SELECT run_id, COUNT(*) AS item_count + FROM rename_run_items + WHERE run_id IN ( + SELECT run_id + FROM rename_runs + WHERE session_id = ? + ORDER BY run_id DESC + LIMIT ? + ) + GROUP BY run_id + """, + (session_id, limit), + ).fetchall() + + counts_by_run = {int(row["run_id"]): int(row["item_count"]) for row in item_counts} + runs = [] + for row in rows: + run_id = int(row["run_id"]) + runs.append( + { + "run_id": run_id, + "session_id": row["session_id"], + "created_at": row["created_at"], + "confirm": bool(row["confirm"]), + "executed": bool(row["executed"]), + "preflight_ok": bool(row["preflight_ok"]), + "counts": { + "episodes": int(row["episodes_count"]), + "files": int(row["files_count"]), + }, + "duration_ms": int(row["duration_ms"]), + "items_count": counts_by_run.get(run_id, 0), + } + ) + return runs + + def get_rename_run(self, run_id: int) -> dict | None: + with self._connect() as conn: + run = conn.execute( + """ + SELECT + run_id, + session_id, + created_at, + confirm, + executed, + preflight_ok, + episodes_count, + files_count, + duration_ms + FROM rename_runs + WHERE run_id = ? + """, + (run_id,), + ).fetchone() + + if run is None: + return None + + item_rows = conn.execute( + """ + SELECT + item_index, + episode_selection_id, + file_selection_id, + source_path, + destination_path, + proposed_filename, + status, + errors_json + FROM rename_run_items + WHERE run_id = ? + ORDER BY item_index ASC + """, + (run_id,), + ).fetchall() + + items = [] + for row in item_rows: + items.append( + { + "index": int(row["item_index"]), + "episode_selection_id": row["episode_selection_id"], + "file_selection_id": row["file_selection_id"], + "source_path": row["source_path"], + "destination_path": row["destination_path"], + "proposed_filename": row["proposed_filename"], + "status": row["status"], + "errors": json.loads(row["errors_json"]), + } + ) + + return { + "run_id": int(run["run_id"]), + "session_id": run["session_id"], + "created_at": run["created_at"], + "confirm": bool(run["confirm"]), + "executed": bool(run["executed"]), + "preflight_ok": bool(run["preflight_ok"]), + "counts": { + "episodes": int(run["episodes_count"]), + "files": int(run["files_count"]), + }, + "duration_ms": int(run["duration_ms"]), + "items": items, + } def _allowed_media_roots(self) -> list[Path]: raw = os.getenv("ALLOWED_MEDIA_ROOTS", "").strip() diff --git a/data/session_state.sqlite3 b/data/session_state.sqlite3 index 962a107eedb86ef60b8c801ea5c170d0a05a2c17..fa5c9b76e3289033f65fc49c80d0990c5475c2a3 100644 GIT binary patch literal 65536 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 I<#Bud53TzY2mk;8 delta 275 zcmZo@U}@EHc`h|T7f~Ys*o2b#LiX8z;}kPigzt9GxrT{ajweE zf&%|JH&=3Xh;jqvnfR|V@W1503KTubKl!S@u)LHgBeOnZacWL#a!G1Rd}={vaehi_ zu@o0jwG{W{$?OKEK*izQEV?YQoRc5=%1usKz`gl^wF?Wg6!+T6><%F5Lhi{MeHFnn zH@&q*fHGugtkxD`W8{xz;Ex5G7|g#pcK&h!pg;@*e+*C{h@ZJpQEIZ`a%G@3$&;V% Gmj(dGl}msC diff --git a/feature_tests_rename_log.sh b/feature_tests_rename_log.sh new file mode 100755 index 0000000..ee2d131 --- /dev/null +++ b/feature_tests_rename_log.sh @@ -0,0 +1,211 @@ +#!/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 + +if [ -z "${TEST_MEDIA_ROOT:-}" ]; then + for candidate in \ + "/Volumes/8TB/Shared_Folders/TV_Shows" \ + "/Volumes/8TB_RAID1/Shared_Folders/Library/TV_Shows" + do + if [ -d "$candidate" ] && [ -w "$candidate" ]; then + TEST_MEDIA_ROOT="$candidate" + break + fi + done +fi + +if [ -z "${TEST_MEDIA_ROOT:-}" ]; then + echo "ERROR: no writable allowed media root found. Set TEST_MEDIA_ROOT." >&2 + exit 1 +fi + +TEST_DIR="${TEST_MEDIA_ROOT}/_rename_mvp_phase7_$(date +%s)_$$" +mkdir -p "${TEST_DIR}" + +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +clear_session() { + local sid="$1" + curl --fail --silent --show-error -X DELETE \ + "${BASE_URL}/api/session/selected-episodes?session_id=${sid}" \ + >/dev/null + curl --fail --silent --show-error -X DELETE \ + "${BASE_URL}/api/session/selected-files?session_id=${sid}" \ + >/dev/null +} + +add_payloads() { + local sid="$1" + local file1="$2" + local file2="$3" + + cat > "${TMP_DIR}/episodes_${sid}.json" <<'JSON' +{ + "items": [ + { + "id": 1, + "series": "Elsbeth", + "year": "2024", + "season_number": 1, + "episode_number": 1, + "title": "Pilot" + }, + { + "id": 2, + "series": "Elsbeth", + "year": "2024", + "season_number": 1, + "episode_number": 2, + "title": "Second Episode" + } + ] +} +JSON + + cat > "${TMP_DIR}/files_${sid}.json" </dev/null + + curl --fail --silent --show-error \ + -X POST "${BASE_URL}/api/session/selected-files?session_id=${sid}" \ + -H "Content-Type: application/json" \ + --data @"${TMP_DIR}/files_${sid}.json" \ + >/dev/null +} + +echo "== Feature test 1: successful rename run is logged with duration ==" +SESSION_OK="rename-log-ok-$(date +%s)-$$" +SRC1="${TEST_DIR}/ok_src1.mkv" +SRC2="${TEST_DIR}/ok_src2.mp4" +printf "a" > "${SRC1}" +printf "b" > "${SRC2}" + +clear_session "${SESSION_OK}" +add_payloads "${SESSION_OK}" "${SRC1}" "${SRC2}" + +curl --fail --silent --show-error \ + -X POST "${BASE_URL}/api/session/rename-execute?session_id=${SESSION_OK}&confirm=true" \ + -o "${TMP_DIR}/rename_ok.json" + +curl --fail --silent --show-error \ + "${BASE_URL}/api/session/rename-log?session_id=${SESSION_OK}" \ + -o "${TMP_DIR}/log_ok.json" + +cat "${TMP_DIR}/log_ok.json" + +python3 - "${TMP_DIR}/log_ok.json" > "${TMP_DIR}/run_id_ok.txt" <<'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 len(items) >= 1, "expected at least one logged run" +run = items[0] +assert run.get("executed") is True, "expected executed=true" +assert run.get("preflight_ok") is True, "expected preflight_ok=true" +assert isinstance(run.get("duration_ms"), int), "duration_ms must be int" +assert run["duration_ms"] >= 0, "duration_ms must be >= 0" +assert run.get("items_count") == 2, "expected 2 item logs" +print(run["run_id"]) +PY + +RUN_ID_OK="$(cat "${TMP_DIR}/run_id_ok.txt")" + +echo +echo "== Feature test 2: preflight-failed run is also logged ==" +SESSION_FAIL="rename-log-fail-$(date +%s)-$$" +SRC3="${TEST_DIR}/fail_src1.mkv" +SRC4="${TEST_DIR}/fail_src2.mp4" +printf "c" > "${SRC3}" +printf "d" > "${SRC4}" + +# Force destination conflict for first mapping. +CONFLICT1="${TEST_DIR}/Elsbeth (2024) - S01E01 - Pilot.mkv" +printf "existing" > "${CONFLICT1}" + +clear_session "${SESSION_FAIL}" +add_payloads "${SESSION_FAIL}" "${SRC3}" "${SRC4}" + +curl --fail --silent --show-error \ + -X POST "${BASE_URL}/api/session/rename-execute?session_id=${SESSION_FAIL}&confirm=true" \ + -o "${TMP_DIR}/rename_fail.json" + +curl --fail --silent --show-error \ + "${BASE_URL}/api/session/rename-log?session_id=${SESSION_FAIL}" \ + -o "${TMP_DIR}/log_fail.json" + +cat "${TMP_DIR}/log_fail.json" + +python3 - "${TMP_DIR}/log_fail.json" > "${TMP_DIR}/run_id_fail.txt" <<'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 len(items) >= 1, "expected at least one logged run" +run = items[0] +assert run.get("executed") is False, "expected executed=false" +assert run.get("preflight_ok") is False, "expected preflight_ok=false" +assert isinstance(run.get("duration_ms"), int), "duration_ms must be int" +assert run["duration_ms"] >= 0, "duration_ms must be >= 0" +print(run["run_id"]) +PY + +RUN_ID_FAIL="$(cat "${TMP_DIR}/run_id_fail.txt")" + +echo +echo "== Feature test 3: run detail endpoint returns item statuses and errors ==" +curl --fail --silent --show-error \ + "${BASE_URL}/api/session/rename-log/${RUN_ID_FAIL}" \ + -o "${TMP_DIR}/run_detail_fail.json" + +cat "${TMP_DIR}/run_detail_fail.json" + +python3 - "${TMP_DIR}/run_detail_fail.json" <<'PY' +import json +import sys +from pathlib import Path + +data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +assert data.get("executed") is False, "detail should show executed=false" +assert data.get("preflight_ok") is False, "detail should show preflight_ok=false" +assert isinstance(data.get("duration_ms"), int), "detail duration_ms must be int" +items = data.get("items", []) +assert len(items) == 2, "expected 2 item details" +assert any("destination file already exists" in " ".join(i.get("errors", [])) for i in items), \ + "expected destination conflict error in item details" +print("rename-log detail validation passed") +PY + +echo +echo "All rename log feature tests passed."