Video download history: permanent archive snapshotted at terminal status (phase 1)

video_downloads is a transient queue (hard-deleted on cleanup), so there was no record
of what SoulSync actually grabbed. Add a permanent video_download_history table +
capture: the monitor snapshots every terminal download (completed/import_failed/
cancelled/failed) into it, with rich metadata (title, year, S/E from search_ctx,
release, source, size, quality + parsed resolution/codec, dest path, poster, outcome,
timestamps). Idempotent per (download_id, outcome, dest_path).

DB methods: record_download_history, query_download_history (paged/kind/search),
download_history_detail, download_history_counts, latest_completed_download(media_type)
— the last is the probe target for the upcoming smart post-download scan. Schema v17.
This commit is contained in:
BoulderBadgeDad 2026-06-21 14:10:22 -07:00
parent a16afd1f9e
commit 6fe82b2a78
4 changed files with 293 additions and 3 deletions

View file

@ -235,6 +235,15 @@ def _apply_candidate(db, dl_id, row, cand, rest) -> bool:
return True
def _archive_history(db, dl, upd) -> None:
"""Snapshot a terminal download into the permanent history. Best-effort — a
history failure must never disturb the download pipeline."""
try:
db.record_download_history({**dl, **upd})
except Exception:
logger.exception("video download %s: history snapshot failed", dl.get("id"))
def _fail_or_retry(db, dl, error_msg) -> None:
"""A download just failed/disappeared. Try the next candidate inline; if none,
hand off to a requery thread; if nothing left, mark it failed for real."""
@ -246,8 +255,10 @@ def _fail_or_retry(db, dl, error_msg) -> None:
db.update_video_download(dl["id"], status="searching", error=None)
_spawn_requery(dl["id"])
return
db.update_video_download(dl["id"], status="failed", error=error_msg or "Download failed",
completed_at=_now())
err = error_msg or "Download failed"
completed = _now()
db.update_video_download(dl["id"], status="failed", error=err, completed_at=completed)
_archive_history(db, dl, {"status": "failed", "error": err, "completed_at": completed})
def _search_for_retry(query, max_seconds=22):
@ -378,6 +389,10 @@ def _tick(db) -> None:
upd.setdefault("completed_at", _now())
try:
db.update_video_download(dl["id"], **upd)
# Snapshot terminal outcomes into the permanent history (survives the
# queue cleanup; powers the History modal + smart post-download scan).
if upd.get("status") in ("completed", "cancelled", "import_failed"):
_archive_history(db, dl, upd)
except Exception:
logger.exception("video download %s: failed to persist update", dl.get("id"))
for k in [k for k in _misses if k not in live_ids]:

View file

@ -31,7 +31,7 @@ logger = get_logger("video_database")
# Bump when video_schema.sql changes in a way worth recording. Stored in
# PRAGMA user_version as a backstop indicator (nothing gates on it yet).
SCHEMA_VERSION = 16
SCHEMA_VERSION = 17
_DEFAULT_DB_PATH = "database/video_library.db"
_SCHEMA_FILE = Path(__file__).resolve().parent / "video_schema.sql"
@ -1311,6 +1311,144 @@ class VideoDatabase:
finally:
conn.close()
# ── download history (permanent archive; survives the queue cleanup) ───────
@staticmethod
def _parse_resolution(*texts) -> str | None:
"""Best-effort resolution/codec sniff from a release/file name."""
import re
blob = " ".join(t for t in texts if t)
for pat, label in ((r"\b2160p\b|\b4k\b|\buhd\b", "2160p"), (r"\b1080p\b", "1080p"),
(r"\b720p\b", "720p"), (r"\b480p\b", "480p")):
if re.search(pat, blob, re.I):
return label
return None
@staticmethod
def _codec(*texts) -> str | None:
import re
blob = " ".join(t for t in texts if t)
for pat, label in ((r"\b[xh]?265\b|\bhevc\b", "x265"), (r"\b[xh]?264\b|\bavc\b", "x264"),
(r"\bav1\b", "AV1"), (r"\bxvid\b", "XviD")):
if re.search(pat, blob, re.I):
return label
return None
def record_download_history(self, row: dict) -> int:
"""Snapshot a finished download (the merged final ``video_downloads`` record)
into the permanent history. ``outcome`` is derived from its status. Idempotent
per (download_id, outcome, dest_path) so a re-persist / restart never dupes.
Returns the new history id, or 0 if it was a duplicate / not worth recording."""
if not row:
return 0
status = row.get("status") or "completed"
outcome = {"completed": "completed", "import_failed": "import_failed",
"failed": "failed", "cancelled": "cancelled"}.get(status, status)
kind = row.get("kind") or "movie"
media_type = "show" if kind == "show" else ("movie" if kind == "movie" else kind)
# season/episode live in the retry search_ctx JSON for episode grabs.
sn = en = None
ctx = row.get("search_ctx")
if ctx:
try:
ctx = json.loads(ctx) if isinstance(ctx, str) else (ctx or {})
sn, en = ctx.get("season"), ctx.get("episode")
except (ValueError, TypeError):
pass
rel, fn = row.get("release_title"), row.get("filename")
conn = self._get_connection()
try:
cur = conn.execute(
"""INSERT OR IGNORE INTO video_download_history
(download_id, kind, media_type, title, year, season_number, episode_number,
release_title, source, username, filename, dest_path, size_bytes,
quality_label, resolution, video_codec, media_id, media_source, poster_url,
outcome, error, grabbed_at, completed_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(row.get("id"), kind, media_type, row.get("title"), row.get("year"), sn, en,
rel, row.get("source"), row.get("username"), fn, row.get("dest_path"),
int(row.get("size_bytes") or 0), row.get("quality_label"),
self._parse_resolution(rel, fn, row.get("quality_label")), self._codec(rel, fn),
row.get("media_id"), row.get("media_source"), row.get("poster_url"),
outcome, row.get("error"), row.get("created_at"),
row.get("completed_at")))
conn.commit()
return cur.lastrowid or 0
except Exception:
logger.exception("record_download_history failed for download %s", row.get("id"))
conn.rollback()
return 0
finally:
conn.close()
def query_download_history(self, *, kind=None, search=None, outcome=None,
page=1, limit=40) -> dict:
"""Paged history slice for the modal. ``kind`` ∈ movie|show (None=all);
``outcome`` filters by result. Newest first. {items, pagination}."""
try:
page = max(1, int(page or 1)); limit = max(1, min(200, int(limit or 40)))
except (TypeError, ValueError):
page, limit = 1, 40
where, args = ["1=1"], []
if kind in ("movie", "show"):
where.append("kind = ?"); args.append(kind)
if outcome:
where.append("outcome = ?"); args.append(outcome)
s = (search or "").strip()
if s:
where.append("(title LIKE ? OR release_title LIKE ?) COLLATE NOCASE")
args += ["%" + s + "%", "%" + s + "%"]
wsql = " WHERE " + " AND ".join(where)
conn = self._get_connection()
try:
total = conn.execute("SELECT COUNT(*) c FROM video_download_history" + wsql, args).fetchone()["c"]
rows = conn.execute(
"SELECT * FROM video_download_history" + wsql +
" ORDER BY COALESCE(completed_at, created_at) DESC, id DESC LIMIT ? OFFSET ?",
args + [limit, (page - 1) * limit]).fetchall()
items = [dict(r) for r in rows]
finally:
conn.close()
total_pages = max(1, (total + limit - 1) // limit)
return {"items": items, "pagination": {
"page": min(page, total_pages), "total_pages": total_pages, "total_count": total,
"has_prev": page > 1, "has_next": page < total_pages}}
def download_history_detail(self, history_id: int) -> dict | None:
conn = self._get_connection()
try:
row = conn.execute("SELECT * FROM video_download_history WHERE id=?", (int(history_id),)).fetchone()
return dict(row) if row else None
finally:
conn.close()
def download_history_counts(self) -> dict:
"""{movie, show, total} of completed grabs (for the modal tabs/badge)."""
conn = self._get_connection()
try:
rows = conn.execute(
"SELECT kind, COUNT(*) c FROM video_download_history "
"WHERE outcome='completed' GROUP BY kind").fetchall()
by = {r["kind"]: r["c"] for r in rows}
movie, show = by.get("movie", 0), by.get("show", 0)
return {"movie": movie, "show": show, "total": movie + show}
finally:
conn.close()
def latest_completed_download(self, media_type: str = "all") -> dict | None:
"""The most recently completed grab of a type — the probe target for the smart
post-download scan. ``media_type`` movie|show|all."""
where, args = ["outcome = 'completed'"], []
if media_type in ("movie", "show"):
where.append("kind = ?"); args.append(media_type)
conn = self._get_connection()
try:
row = conn.execute(
"SELECT * FROM video_download_history WHERE " + " AND ".join(where) +
" ORDER BY COALESCE(completed_at, created_at) DESC, id DESC LIMIT 1", args).fetchone()
return dict(row) if row else None
finally:
conn.close()
# ── library mapping (which server library is Movies / TV) ─────────────────
def get_library_selection(self, server: str) -> dict:
return {

View file

@ -549,3 +549,41 @@ CREATE TABLE IF NOT EXISTS video_downloads (
completed_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_video_downloads_status ON video_downloads(status);
-- video_download_history — a PERMANENT record of every grab SoulSync completed
-- (movies + episodes). video_downloads is the transient working queue (cleaned when
-- finished); this is the archive that powers the Download History modal AND the
-- smart post-download scan (newest completed item per library → probe the server).
CREATE TABLE IF NOT EXISTS video_download_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
download_id INTEGER, -- original video_downloads.id (transient)
kind TEXT NOT NULL, -- movie | show
media_type TEXT, -- movie | show (normalized; the scan scope)
title TEXT, -- movie/show title
year INTEGER,
season_number INTEGER, -- episodes only
episode_number INTEGER,
episode_title TEXT,
release_title TEXT, -- the release/file grabbed
source TEXT, -- soulseek | torrent | usenet
username TEXT, -- uploader (soulseek)
filename TEXT, -- remote filename grabbed
dest_path TEXT, -- final placed path
size_bytes INTEGER DEFAULT 0,
quality_label TEXT, -- e.g. "1080p", "2160p HDR"
resolution TEXT, -- parsed from the release name, best-effort
video_codec TEXT,
media_id TEXT, -- movie/show id for the detail deep-link
media_source TEXT, -- library | tmdb
poster_url TEXT,
outcome TEXT NOT NULL DEFAULT 'completed', -- completed | import_failed | failed | cancelled
error TEXT, -- failure reason (non-completed)
grabbed_at TEXT, -- when the download started
completed_at TEXT, -- terminal time
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_vdl_history_kind ON video_download_history(kind);
CREATE INDEX IF NOT EXISTS idx_vdl_history_completed ON video_download_history(completed_at);
-- one history row per terminal download (idempotent re-persist / restart-safe)
CREATE UNIQUE INDEX IF NOT EXISTS idx_vdl_history_dedup
ON video_download_history(download_id, outcome, dest_path);

View file

@ -0,0 +1,99 @@
"""Permanent video download history — the archive that powers the History modal
and the smart post-download scan. video_downloads is the transient queue; this
table survives the cleanup, so it's snapshotted at terminal status."""
from __future__ import annotations
import json
import pytest
from database.video_database import VideoDatabase
@pytest.fixture()
def db(tmp_path):
return VideoDatabase(database_path=str(tmp_path / "video_library.db"))
def _movie(**over):
row = {"id": 1, "kind": "movie", "title": "Dune", "year": 2024, "status": "completed",
"release_title": "Dune.2024.2160p.UHD.BluRay.x265-GRP", "source": "soulseek",
"username": "bob", "filename": "Dune.2024.2160p.x265.mkv",
"dest_path": "/movies/Dune (2024)/Dune (2024).mkv", "size_bytes": 9_000_000_000,
"quality_label": "2160p", "media_id": "55", "media_source": "library",
"poster_url": "/p/dune.jpg", "created_at": "2026-06-20 10:00:00",
"completed_at": "2026-06-20 10:30:00"}
row.update(over)
return row
def _episode(**over):
row = {"id": 2, "kind": "show", "title": "Severance", "year": 2025, "status": "completed",
"release_title": "Severance.S02E05.1080p.WEB.h264", "source": "soulseek",
"dest_path": "/tv/Severance/Season 02/Severance - S02E05.mkv", "size_bytes": 2_000_000_000,
"search_ctx": json.dumps({"scope": "episode", "title": "Severance", "season": 2, "episode": 5}),
"media_id": "9", "media_source": "library", "completed_at": "2026-06-21 02:00:00"}
row.update(over)
return row
def test_records_a_completed_movie_with_parsed_quality(db):
hid = db.record_download_history(_movie())
assert hid > 0
d = db.download_history_detail(hid)
assert d["title"] == "Dune" and d["outcome"] == "completed" and d["media_type"] == "movie"
assert d["resolution"] == "2160p" and d["video_codec"] == "x265" # sniffed from the release name
assert d["size_bytes"] == 9_000_000_000 and d["dest_path"].endswith("Dune (2024).mkv")
def test_records_an_episode_with_season_episode_from_search_ctx(db):
hid = db.record_download_history(_episode())
d = db.download_history_detail(hid)
assert (d["kind"], d["media_type"]) == ("show", "show")
assert d["season_number"] == 2 and d["episode_number"] == 5
assert d["resolution"] == "1080p" and d["video_codec"] == "x264"
def test_history_is_idempotent_per_terminal_download(db):
first = db.record_download_history(_movie())
again = db.record_download_history(_movie()) # same download_id/outcome/dest_path
assert first > 0 and again == 0 # INSERT OR IGNORE → no dupe
assert db.download_history_counts()["movie"] == 1
def test_query_filters_by_kind_and_search(db):
db.record_download_history(_movie())
db.record_download_history(_episode())
assert db.query_download_history(kind="movie")["pagination"]["total_count"] == 1
assert db.query_download_history(kind="show")["items"][0]["title"] == "Severance"
hits = db.query_download_history(search="dune")["items"]
assert len(hits) == 1 and hits[0]["title"] == "Dune"
def test_counts_only_count_completed(db):
db.record_download_history(_movie())
db.record_download_history(_movie(id=3, status="failed", dest_path=None,
error="no release found"))
c = db.download_history_counts()
assert c == {"movie": 1, "show": 0, "total": 1} # the failed one isn't counted
def test_latest_completed_download_is_the_probe_target(db):
db.record_download_history(_movie(id=1, completed_at="2026-06-20 10:30:00"))
db.record_download_history(_movie(id=4, title="Wicked",
dest_path="/movies/Wicked (2024)/Wicked.mkv",
completed_at="2026-06-22 09:00:00"))
db.record_download_history(_episode())
assert db.latest_completed_download("movie")["title"] == "Wicked" # newest movie
assert db.latest_completed_download("show")["title"] == "Severance"
assert db.latest_completed_download("all")["title"] == "Wicked" # newest overall
def test_newest_first_ordering_in_the_feed(db):
db.record_download_history(_movie(id=1, title="Old", dest_path="/m/old.mkv",
completed_at="2026-01-01 00:00:00"))
db.record_download_history(_movie(id=2, title="New", dest_path="/m/new.mkv",
completed_at="2026-06-01 00:00:00"))
titles = [i["title"] for i in db.query_download_history()["items"]]
assert titles == ["New", "Old"]