diff --git a/app/api/session.py b/app/api/session.py index 604bc5c..d8112b0 100644 --- a/app/api/session.py +++ b/app/api/session.py @@ -147,3 +147,13 @@ def get_mapping_preview(session_id: str = Query("default", min_length=1)): return service.build_mapping_preview(normalized_session_id) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) + + +@router.get("/filename-preview") +def get_filename_preview(session_id: str = Query("default", min_length=1)): + service = SessionService() + normalized_session_id = _normalize_session_id(session_id) + try: + return service.build_filename_preview(normalized_session_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) diff --git a/app/services/session_service.py b/app/services/session_service.py index 5acbb90..36822ce 100644 --- a/app/services/session_service.py +++ b/app/services/session_service.py @@ -347,3 +347,57 @@ class SessionService: }, "mappings": mappings, } + + def build_filename_preview(self, session_id: str) -> dict: + mapping_preview = self.build_mapping_preview(session_id) + previews = [] + + for item in mapping_preview["mappings"]: + episode = item["episode"] + file_payload = item["file"] + + series = ( + episode.get("series") + or episode.get("series_name") + or episode.get("show") + or "Unknown Series" + ) + year = episode.get("year") or "0000" + title = episode.get("title") or "Untitled" + + season_raw = episode.get("season_number") or episode.get("season") or 0 + episode_raw = episode.get("episode_number") or episode.get("number") or 0 + + try: + season_number = int(season_raw) + except (TypeError, ValueError): + season_number = 0 + try: + episode_number = int(episode_raw) + except (TypeError, ValueError): + episode_number = 0 + + source_name = file_payload.get("name") or file_payload.get("path") or "" + ext = Path(source_name).suffix + + proposed_filename = ( + f"{series} ({year}) - S{season_number:02}E{episode_number:02} - {title}{ext}" + ) + + previews.append( + { + "index": item["index"], + "episode_selection_id": item["episode_selection_id"], + "file_selection_id": item["file_selection_id"], + "episode": episode, + "file": file_payload, + "proposed_filename": proposed_filename, + } + ) + + return { + "session_id": mapping_preview["session_id"], + "counts": mapping_preview["counts"], + "template": "{series} ({year}) - S{season:02}E{episode:02} - {title}{ext}", + "items": previews, + } diff --git a/feature_tests_filename_preview.sh b/feature_tests_filename_preview.sh new file mode 100755 index 0000000..0e11d3d --- /dev/null +++ b/feature_tests_filename_preview.sh @@ -0,0 +1,163 @@ +#!/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 + +SESSION_ID="filename-preview-test-$(date +%s)-$$" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +curl --fail --silent --show-error -X DELETE \ + "${BASE_URL}/api/session/selected-episodes?session_id=${SESSION_ID}" \ + >/dev/null +curl --fail --silent --show-error -X DELETE \ + "${BASE_URL}/api/session/selected-files?session_id=${SESSION_ID}" \ + >/dev/null + +echo "== Feature test 1: filename preview returns template-based proposed filenames ==" +cat > "${TMP_DIR}/episodes_payload.json" <<'JSON' +{ + "items": [ + { + "id": 9784113, + "series": "Elsbeth", + "year": "2024", + "season_number": 1, + "episode_number": 1, + "title": "Pilot" + }, + { + "id": 10347197, + "series": "Elsbeth", + "year": "2024", + "season_number": 1, + "episode_number": 2, + "title": "A Classic New York Character" + } + ] +} +JSON + +cat > "${TMP_DIR}/files_payload.json" <<'JSON' +{ + "items": [ + { + "path": "/Volumes/8TB/Shared_Folders/TV_Shows/Elsbeth/source_a.mkv", + "name": "source_a.mkv" + }, + { + "path": "/Volumes/8TB/Shared_Folders/TV_Shows/Elsbeth/source_b.mp4", + "name": "source_b.mp4" + } + ] +} +JSON + +curl --fail --silent --show-error \ + -X POST "${BASE_URL}/api/session/selected-episodes?session_id=${SESSION_ID}" \ + -H "Content-Type: application/json" \ + --data @"${TMP_DIR}/episodes_payload.json" \ + >/dev/null + +curl --fail --silent --show-error \ + -X POST "${BASE_URL}/api/session/selected-files?session_id=${SESSION_ID}" \ + -H "Content-Type: application/json" \ + --data @"${TMP_DIR}/files_payload.json" \ + >/dev/null + +curl --fail --silent --show-error \ + "${BASE_URL}/api/session/filename-preview?session_id=${SESSION_ID}" \ + -o "${TMP_DIR}/filename_preview.json" + +cat "${TMP_DIR}/filename_preview.json" + +python3 - "${TMP_DIR}/filename_preview.json" <<'PY' +import json +import sys +from pathlib import Path + +data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +assert isinstance(data, dict), "response must be an object" +assert isinstance(data.get("items"), list), "items must be a list" +assert len(data["items"]) == 2, "expected 2 preview items" +assert data["items"][0]["proposed_filename"] == "Elsbeth (2024) - S01E01 - Pilot.mkv", "first proposed filename mismatch" +assert data["items"][1]["proposed_filename"] == "Elsbeth (2024) - S01E02 - A Classic New York Character.mp4", "second proposed filename mismatch" +print("filename preview validation passed") +PY + +echo +echo "== Feature test 2: mismatch still returns HTTP 400 ==" +curl --fail --silent --show-error -X DELETE \ + "${BASE_URL}/api/session/selected-files?session_id=${SESSION_ID}" \ + >/dev/null + +cat > "${TMP_DIR}/one_file_payload.json" <<'JSON' +{ + "items": [ + { + "path": "/Volumes/8TB/Shared_Folders/TV_Shows/Elsbeth/source_only_one.mkv", + "name": "source_only_one.mkv" + } + ] +} +JSON + +curl --fail --silent --show-error \ + -X POST "${BASE_URL}/api/session/selected-files?session_id=${SESSION_ID}" \ + -H "Content-Type: application/json" \ + --data @"${TMP_DIR}/one_file_payload.json" \ + >/dev/null + +curl --silent --show-error \ + -o "${TMP_DIR}/filename_preview_mismatch.json" \ + -w "%{http_code}" \ + "${BASE_URL}/api/session/filename-preview?session_id=${SESSION_ID}" \ + > "${TMP_DIR}/filename_preview_mismatch.status" + +cat "${TMP_DIR}/filename_preview_mismatch.json" + +python3 - "${TMP_DIR}/filename_preview_mismatch.status" "${TMP_DIR}/filename_preview_mismatch.json" <<'PY' +import json +import sys +from pathlib import Path + +status = Path(sys.argv[1]).read_text(encoding="utf-8").strip() +data = json.loads(Path(sys.argv[2]).read_text(encoding="utf-8")) +assert status == "400", f"expected HTTP 400, got {status}" +assert "detail" in data, "error response missing detail" +assert "count mismatch" in data["detail"], "detail should mention count mismatch" +print("mismatch validation passed") +PY + +echo +echo "== Feature test 3: filename preview is read-only (no selected data mutation) ==" +curl --fail --silent --show-error \ + "${BASE_URL}/api/session/selected-episodes?session_id=${SESSION_ID}" \ + -o "${TMP_DIR}/episodes_after.json" +curl --fail --silent --show-error \ + "${BASE_URL}/api/session/selected-files?session_id=${SESSION_ID}" \ + -o "${TMP_DIR}/files_after.json" + +python3 - "${TMP_DIR}/episodes_after.json" "${TMP_DIR}/files_after.json" <<'PY' +import json +import sys +from pathlib import Path + +episodes = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +files = json.loads(Path(sys.argv[2]).read_text(encoding="utf-8")) +assert len(episodes.get("items", [])) == 2, "episodes were unexpectedly mutated" +assert len(files.get("items", [])) == 1, "files were unexpectedly mutated" +print("read-only validation passed") +PY + +echo +echo "All filename preview feature tests passed."