Files
rename-mvp/app/services/session_service.py
T

554 lines
19 KiB
Python

import json
import os
import sqlite3
from pathlib import Path
from app.config import APP_DATA_DIR
class SessionService:
def __init__(self) -> None:
self._db_path = Path(APP_DATA_DIR) / "session_state.sqlite3"
self._db_path.parent.mkdir(parents=True, exist_ok=True)
self._init_db()
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self._db_path)
conn.row_factory = sqlite3.Row
return conn
def _init_db(self) -> None:
with self._connect() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS selected_episodes (
selection_id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
position INTEGER NOT NULL,
payload_json TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE UNIQUE INDEX IF NOT EXISTS idx_selected_episodes_session_position
ON selected_episodes(session_id, position)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_selected_episodes_session
ON selected_episodes(session_id)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS selected_files (
selection_id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
position INTEGER NOT NULL,
payload_json TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE UNIQUE INDEX IF NOT EXISTS idx_selected_files_session_position
ON selected_files(session_id, position)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_selected_files_session
ON selected_files(session_id)
"""
)
def list_selected_episodes(self, session_id: str) -> list[dict]:
with self._connect() as conn:
rows = conn.execute(
"""
SELECT selection_id, position, payload_json
FROM selected_episodes
WHERE session_id = ?
ORDER BY position ASC
""",
(session_id,),
).fetchall()
items = []
for row in rows:
payload = json.loads(row["payload_json"])
items.append(
{
"selection_id": row["selection_id"],
"position": row["position"],
"episode": payload,
}
)
return items
def add_selected_episodes(self, session_id: str, items: list[dict]) -> list[dict]:
if not items:
return self.list_selected_episodes(session_id)
with self._connect() as conn:
current_max = conn.execute(
"""
SELECT COALESCE(MAX(position), -1) AS max_position
FROM selected_episodes
WHERE session_id = ?
""",
(session_id,),
).fetchone()
next_position = int(current_max["max_position"]) + 1
for item in items:
conn.execute(
"""
INSERT INTO selected_episodes (session_id, position, payload_json)
VALUES (?, ?, ?)
""",
(session_id, next_position, json.dumps(item, ensure_ascii=True)),
)
next_position += 1
return self.list_selected_episodes(session_id)
def clear_selected_episodes(self, session_id: str) -> None:
with self._connect() as conn:
conn.execute(
"DELETE FROM selected_episodes WHERE session_id = ?",
(session_id,),
)
def remove_selected_episode(self, session_id: str, selection_id: int) -> list[dict]:
with self._connect() as conn:
conn.execute(
"""
DELETE FROM selected_episodes
WHERE session_id = ? AND selection_id = ?
""",
(session_id, selection_id),
)
return self._compact_positions(session_id)
def reorder_selected_episodes(
self,
session_id: str,
from_index: int,
to_index: int,
) -> list[dict]:
current_items = self.list_selected_episodes(session_id)
if from_index < 0 or from_index >= len(current_items):
raise ValueError("from_index out of range")
if to_index < 0 or to_index >= len(current_items):
raise ValueError("to_index out of range")
if from_index == to_index:
return current_items
moved = current_items.pop(from_index)
current_items.insert(to_index, moved)
with self._connect() as conn:
# Two-phase update avoids transient UNIQUE conflicts on (session_id, position).
for position, item in enumerate(current_items):
conn.execute(
"""
UPDATE selected_episodes
SET position = ?
WHERE session_id = ? AND selection_id = ?
""",
(-(position + 1), session_id, item["selection_id"]),
)
for position, item in enumerate(current_items):
conn.execute(
"""
UPDATE selected_episodes
SET position = ?
WHERE session_id = ? AND selection_id = ?
""",
(position, session_id, item["selection_id"]),
)
return self.list_selected_episodes(session_id)
def _compact_positions(self, session_id: str) -> list[dict]:
items = self.list_selected_episodes(session_id)
with self._connect() as conn:
for position, item in enumerate(items):
if item["position"] == position:
continue
conn.execute(
"""
UPDATE selected_episodes
SET position = ?
WHERE session_id = ? AND selection_id = ?
""",
(position, session_id, item["selection_id"]),
)
return self.list_selected_episodes(session_id)
def list_selected_files(self, session_id: str) -> list[dict]:
with self._connect() as conn:
rows = conn.execute(
"""
SELECT selection_id, position, payload_json
FROM selected_files
WHERE session_id = ?
ORDER BY position ASC
""",
(session_id,),
).fetchall()
items = []
for row in rows:
payload = json.loads(row["payload_json"])
items.append(
{
"selection_id": row["selection_id"],
"position": row["position"],
"file": payload,
}
)
return items
def add_selected_files(self, session_id: str, items: list[dict]) -> list[dict]:
if not items:
return self.list_selected_files(session_id)
with self._connect() as conn:
current_max = conn.execute(
"""
SELECT COALESCE(MAX(position), -1) AS max_position
FROM selected_files
WHERE session_id = ?
""",
(session_id,),
).fetchone()
next_position = int(current_max["max_position"]) + 1
for item in items:
conn.execute(
"""
INSERT INTO selected_files (session_id, position, payload_json)
VALUES (?, ?, ?)
""",
(session_id, next_position, json.dumps(item, ensure_ascii=True)),
)
next_position += 1
return self.list_selected_files(session_id)
def clear_selected_files(self, session_id: str) -> None:
with self._connect() as conn:
conn.execute(
"DELETE FROM selected_files WHERE session_id = ?",
(session_id,),
)
def remove_selected_file(self, session_id: str, selection_id: int) -> list[dict]:
with self._connect() as conn:
conn.execute(
"""
DELETE FROM selected_files
WHERE session_id = ? AND selection_id = ?
""",
(session_id, selection_id),
)
return self._compact_file_positions(session_id)
def reorder_selected_files(
self,
session_id: str,
from_index: int,
to_index: int,
) -> list[dict]:
current_items = self.list_selected_files(session_id)
if from_index < 0 or from_index >= len(current_items):
raise ValueError("from_index out of range")
if to_index < 0 or to_index >= len(current_items):
raise ValueError("to_index out of range")
if from_index == to_index:
return current_items
moved = current_items.pop(from_index)
current_items.insert(to_index, moved)
with self._connect() as conn:
# Two-phase update avoids transient UNIQUE conflicts on (session_id, position).
for position, item in enumerate(current_items):
conn.execute(
"""
UPDATE selected_files
SET position = ?
WHERE session_id = ? AND selection_id = ?
""",
(-(position + 1), session_id, item["selection_id"]),
)
for position, item in enumerate(current_items):
conn.execute(
"""
UPDATE selected_files
SET position = ?
WHERE session_id = ? AND selection_id = ?
""",
(position, session_id, item["selection_id"]),
)
return self.list_selected_files(session_id)
def _compact_file_positions(self, session_id: str) -> list[dict]:
items = self.list_selected_files(session_id)
with self._connect() as conn:
for position, item in enumerate(items):
if item["position"] == position:
continue
conn.execute(
"""
UPDATE selected_files
SET position = ?
WHERE session_id = ? AND selection_id = ?
""",
(position, session_id, item["selection_id"]),
)
return self.list_selected_files(session_id)
def build_mapping_preview(self, session_id: str) -> dict:
episodes = self.list_selected_episodes(session_id)
files = self.list_selected_files(session_id)
if len(episodes) != len(files):
raise ValueError(
"Selected episodes and selected files count mismatch: "
f"{len(episodes)} episodes vs {len(files)} files"
)
mappings = []
for index, (episode_item, file_item) in enumerate(zip(episodes, files)):
mappings.append(
{
"index": index,
"episode_selection_id": episode_item["selection_id"],
"file_selection_id": file_item["selection_id"],
"episode": episode_item["episode"],
"file": file_item["file"],
}
)
return {
"session_id": session_id,
"counts": {
"episodes": len(episodes),
"files": len(files),
},
"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,
}
def execute_rename(self, session_id: str, confirm: bool) -> dict:
if not confirm:
raise ValueError("confirm=true is required to execute rename")
preview = self.build_filename_preview(session_id)
allowed_roots = self._allowed_media_roots()
preflight_items = []
preflight_errors = 0
for item in preview["items"]:
source_path_str = str(item["file"].get("path") or "").strip()
proposed_filename = item["proposed_filename"]
source_path = Path(source_path_str)
destination_path = source_path.with_name(proposed_filename) if source_path_str else Path("")
errors = self._preflight_errors(
source_path=source_path,
destination_path=destination_path,
proposed_filename=proposed_filename,
allowed_roots=allowed_roots,
)
status = "ready"
if errors:
status = "preflight_error"
preflight_errors += 1
preflight_items.append(
{
"index": item["index"],
"episode_selection_id": item["episode_selection_id"],
"file_selection_id": item["file_selection_id"],
"source_path": source_path_str,
"destination_path": str(destination_path) if source_path_str else "",
"proposed_filename": proposed_filename,
"status": status,
"errors": errors,
}
)
if preflight_errors > 0:
return {
"session_id": session_id,
"confirm": confirm,
"executed": False,
"preflight_ok": False,
"counts": preview["counts"],
"items": preflight_items,
}
results = []
for item in preflight_items:
source_path = Path(item["source_path"])
destination_path = Path(item["destination_path"])
os.replace(str(source_path), str(destination_path))
results.append(
{
**item,
"status": "renamed",
"errors": [],
}
)
return {
"session_id": session_id,
"confirm": confirm,
"executed": True,
"preflight_ok": True,
"counts": preview["counts"],
"items": results,
}
def _allowed_media_roots(self) -> list[Path]:
raw = os.getenv("ALLOWED_MEDIA_ROOTS", "").strip()
if raw:
candidates = [p.strip() for p in raw.split(",") if p.strip()]
else:
media_root = os.getenv("MEDIA_ROOT", "").strip()
if media_root:
candidates = [media_root]
else:
candidates = [
"/Volumes/8TB/Shared_Folders/TV_Shows",
"/Volumes/8TB_RAID1/Shared_Folders/Library/TV_Shows",
]
roots = []
for candidate in candidates:
try:
roots.append(Path(candidate).resolve())
except Exception:
continue
return roots
def _is_within_allowed_roots(self, path: Path, allowed_roots: list[Path]) -> bool:
try:
resolved = path.resolve()
except Exception:
return False
for root in allowed_roots:
try:
resolved.relative_to(root)
return True
except ValueError:
continue
return False
def _preflight_errors(
self,
source_path: Path,
destination_path: Path,
proposed_filename: str,
allowed_roots: list[Path],
) -> list[str]:
errors = []
if not str(source_path):
errors.append("source path missing")
return errors
if ".." in source_path.parts:
errors.append("source path traversal is not allowed")
if ".." in Path(proposed_filename).parts:
errors.append("destination filename traversal is not allowed")
if Path(proposed_filename).name != proposed_filename:
errors.append("destination filename must not contain path separators")
if not self._is_within_allowed_roots(source_path, allowed_roots):
errors.append("source path is outside allowed media roots")
if not self._is_within_allowed_roots(destination_path, allowed_roots):
errors.append("destination path is outside allowed media roots")
if not source_path.exists():
errors.append("source file does not exist")
if source_path.exists() and not source_path.is_file():
errors.append("source path is not a file")
if source_path == destination_path:
errors.append("source and destination paths are equal")
if destination_path.exists():
errors.append("destination file already exists")
if not destination_path.parent.exists():
errors.append("destination parent directory does not exist")
return errors