soulsync/tests/test_video_manual_import.py
BoulderBadgeDad efa64db04d Video downloads: best-in-class post-process pipeline
End-to-end import for video grabs, mirroring the music side's rigor and the
Radarr/Sonarr standard, fully isolated in core/video + api/video.

- Importer: parse release -> ffprobe-verify (true resolution, reject corrupt/
  samples) -> templated rename into Movie (Year)/ + Show/Season NN/ -> copy or
  move, carry subtitles, upgrade-replace a worse copy.
- Library Organization settings: editable $token path templates + toggles
  (transfer mode, verify, replace, carry subs, save artwork, write NFO,
  download subtitles + langs). Stored in video.db; matches the music File
  Organization section's look.
- Sidecar writer: movie.nfo / tvshow.nfo + full artwork set (poster, fanart,
  clearlogo, season posters) from on-demand TMDB detail, and external .srt from
  OpenSubtitles. Owned re-grabs resolve their library tmdb_id; tmdb_full_detail
  bypasses the owned->library redirect so they enrich too.
- Import page: surfaces import_failed downloads, resolve by hand (library-first
  -> TMDB picker -> force-place) or dismiss; fires a library refresh on place.
- "Grab whole season": episode-level batch (reuses searchInto + _autoPick).
- Brutalist redesign of the download modal sources + result cards.

All new logic has seam-level tests (pure parsers/planners + injected I/O);
sidecars/subtitles are best-effort and never break an import.
2026-06-21 01:44:35 -07:00

120 lines
4.9 KiB
Python

"""Manual / failed-import resolution endpoints — list the unplaced queue, force-place
a file to a chosen identity, and dismiss. Uses real temp files (the place flow runs
the real importer against disk)."""
from __future__ import annotations
import os
import pytest
from flask import Flask
import api.video as videoapi
from core.video import organization
from database.video_database import VideoDatabase
@pytest.fixture()
def env(tmp_path):
db = VideoDatabase(database_path=str(tmp_path / "video_library.db"))
movies = tmp_path / "Movies"
movies.mkdir()
db.set_setting("movies_path", str(movies))
db.set_setting("tv_path", str(tmp_path / "TV"))
organization.save(db, {"verify_with_ffprobe": False}) # don't depend on ffprobe in CI
dl_dir = tmp_path / "dl"
dl_dir.mkdir()
src = dl_dir / "the.matrix.1999.1080p.bluray.x265.mkv"
src.write_bytes(b"x" * 4096)
dl_id = db.add_video_download({
"kind": "movie", "title": "the matrix", "release_title": src.name,
"source": "soulseek", "username": "neo", "filename": src.name,
"size_bytes": 4096, "target_dir": str(movies), "status": "import_failed",
"search_ctx": "{}",
})
db.update_video_download(dl_id, dest_path=str(src), error="Looks like a sample, not the feature")
videoapi._video_db = db
app = Flask(__name__)
app.register_blueprint(videoapi.create_video_blueprint(), url_prefix="/api/video")
client = app.test_client()
try:
yield {"client": client, "db": db, "dl_id": dl_id, "src": src, "movies": movies}
finally:
videoapi._video_db = None
def test_failed_list_surfaces_unplaced_downloads(env):
items = env["client"].get("/api/video/import/failed").get_json()["items"]
assert len(items) == 1
it = items[0]
assert it["id"] == env["dl_id"]
assert it["file"] == str(env["src"]) # points at the unplaced file
assert "sample" in it["reason"].lower()
def test_place_force_imports_to_chosen_identity(env):
r = env["client"].post("/api/video/import/%d/place" % env["dl_id"],
json={"scope": "movie", "title": "The Matrix", "year": 1999}).get_json()
assert r["success"] and r["status"] == "completed"
final = env["movies"] / "The Matrix (1999)" / "The Matrix (1999) Bluray-1080p.mkv"
assert final.exists() # filed under the standard layout
assert not env["src"].exists() # source reclaimed (copy mode, non-torrent)
# the row is no longer in the failed queue
assert env["client"].get("/api/video/import/failed").get_json()["items"] == []
def test_place_triggers_a_library_refresh(env):
# a successful manual place fires the same batch-complete refresh the auto path
# uses, so the title shows up without waiting for a scheduled scan.
from core.video import download_events
fired = []
download_events.register_batch_complete_callback(lambda d: fired.append(d))
try:
env["client"].post("/api/video/import/%d/place" % env["dl_id"],
json={"scope": "movie", "title": "The Matrix", "year": 1999})
assert fired and fired[-1].get("manual") is True
finally:
download_events._reset_for_tests()
def test_media_ids_resolves_tmdb_and_library_regrabs():
from core.video.download_monitor import _media_ids
# grabbed straight from TMDB → media_id is the tmdb id
assert _media_ids(None, {"media_source": "tmdb", "media_id": "603"}) == (603, None)
# owned re-grab → media_id is the LIBRARY id; resolve via the library row
class _DB:
def media_tmdb_id(self, kind, mid):
assert kind == "movie" and mid == "5107"
return (936075, "tt11378946")
assert _media_ids(_DB(), {"media_source": "library", "media_id": "5107", "kind": "movie"}) \
== (936075, "tt11378946")
assert _media_ids(None, {}) == (None, None) # unresolvable → no sidecars
def test_dismiss_does_not_trigger_a_refresh(env):
from core.video import download_events
fired = []
download_events.register_batch_complete_callback(lambda d: fired.append(d))
try:
env["client"].post("/api/video/import/%d/dismiss" % env["dl_id"], json={})
assert fired == [] # nothing landed → no scan
finally:
download_events._reset_for_tests()
def test_place_rejects_bad_scope(env):
r = env["client"].post("/api/video/import/%d/place" % env["dl_id"], json={"scope": "season"})
assert r.status_code == 400
def test_dismiss_drops_row_and_can_delete_file(env):
r = env["client"].post("/api/video/import/%d/dismiss" % env["dl_id"],
json={"delete_file": True}).get_json()
assert r["success"]
assert not env["src"].exists() # file removed
assert env["client"].get("/api/video/import/failed").get_json()["items"] == []