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.
272 lines
13 KiB
Python
272 lines
13 KiB
Python
"""Video post-process / import — the Radarr/Sonarr step that organises a finished
|
|
download into the library.
|
|
|
|
Covers the decision matrix (import / upgrade-replace / reject) and the orchestration
|
|
(copy in, carry subtitles, delete a worse existing file, reclaim the source unless
|
|
it's a torrent). The filesystem is injected so it's all unit-tested without disk.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
|
|
from core.video import importer
|
|
|
|
|
|
# ── a recording fake filesystem ──────────────────────────────────────────────
|
|
class FakeFS:
|
|
def __init__(self, dirs=None):
|
|
# dirs: {dirpath: [basename, ...]} — what list_dir returns
|
|
self.dirs = {k: list(v) for k, v in (dirs or {}).items()}
|
|
self.made = []
|
|
self.copied = [] # (src, dst)
|
|
self.moved = [] # (src, dst)
|
|
self.saved = [] # (url, dst)
|
|
self.removed = []
|
|
|
|
def list_dir(self, path):
|
|
return self.dirs.get(str(path), [])
|
|
|
|
def makedirs(self, path):
|
|
self.made.append(str(path))
|
|
|
|
def copy(self, src, dst):
|
|
self.copied.append((src, dst))
|
|
|
|
def move(self, src, dst):
|
|
self.moved.append((src, dst))
|
|
|
|
def save_url(self, url, dst):
|
|
self.saved.append((url, dst))
|
|
|
|
def remove(self, path):
|
|
self.removed.append(path)
|
|
|
|
|
|
def _movie_dl(release, source="soulseek", size=2_000_000_000, root="/lib/movies"):
|
|
return {
|
|
"kind": "movie", "title": "The Matrix", "year": 1999, "source": source,
|
|
"release_title": release, "size_bytes": size, "target_dir": root,
|
|
"search_ctx": json.dumps({"scope": "movie", "title": "The Matrix", "year": 1999}),
|
|
}
|
|
|
|
|
|
def _episode_dl(release, root="/lib/tv", season=1, episode=1):
|
|
return {
|
|
"kind": "show", "title": "Breaking Bad", "source": "soulseek",
|
|
"release_title": release, "size_bytes": 1_000_000_000, "target_dir": root,
|
|
"search_ctx": json.dumps({"scope": "episode", "title": "Breaking Bad",
|
|
"season": season, "episode": episode, "episode_title": "Pilot"}),
|
|
}
|
|
|
|
|
|
# ── sanity gate ───────────────────────────────────────────────────────────────
|
|
def test_rejects_non_video_and_samples():
|
|
dl = _movie_dl("The Matrix 1999 1080p BluRay")
|
|
assert importer.plan_import(dl, "/dl/x/matrix.nfo", list_dir=lambda d: [])["action"] == "reject"
|
|
small = _movie_dl("The Matrix 1999 1080p BluRay", size=20_000_000)
|
|
p = importer.plan_import(small, "/dl/x/sample-matrix.mkv", list_dir=lambda d: [])
|
|
assert p["action"] == "reject" and "sample" in p["reason"].lower()
|
|
|
|
|
|
def test_rejects_wrong_episode():
|
|
dl = _episode_dl("Breaking Bad S01E02 1080p WEB-DL", season=1, episode=1)
|
|
p = importer.plan_import(dl, "/dl/x/bb.s01e02.mkv", list_dir=lambda d: [])
|
|
assert p["action"] == "reject" and "S01E02" in p["reason"]
|
|
|
|
|
|
def test_rejects_season_pack_for_manual():
|
|
dl = _episode_dl("Breaking Bad S01 1080p WEB-DL", season=1, episode=1)
|
|
dl["search_ctx"] = json.dumps({"scope": "season", "title": "Breaking Bad", "season": 1})
|
|
p = importer.plan_import(dl, "/dl/x/bb.s01.mkv", list_dir=lambda d: [])
|
|
assert p["action"] == "reject" and "manual" in p["reason"].lower()
|
|
|
|
|
|
# ── import / upgrade / not-an-upgrade ─────────────────────────────────────────
|
|
def test_fresh_movie_import_path():
|
|
dl = _movie_dl("The Matrix 1999 1080p BluRay x265")
|
|
p = importer.plan_import(dl, "/dl/x/the.matrix.1999.1080p.bluray.x265.mkv", list_dir=lambda d: [])
|
|
assert p["action"] == "import"
|
|
assert p["dest"]["path"] == os.path.join("/lib/movies", "The Matrix (1999)",
|
|
"The Matrix (1999) Bluray-1080p.mkv")
|
|
|
|
|
|
def test_upgrade_replaces_worse_existing():
|
|
dl = _movie_dl("The Matrix 1999 2160p BluRay") # 4K incoming
|
|
folder = os.path.join("/lib/movies", "The Matrix (1999)")
|
|
fs_dirs = {folder: ["The Matrix (1999) Bluray-720p.mkv"]} # owns 720p
|
|
p = importer.plan_import(dl, "/dl/x/matrix.2160p.mkv", list_dir=lambda d: fs_dirs.get(d, []))
|
|
assert p["action"] == "upgrade"
|
|
assert p["replace_path"] == os.path.join(folder, "The Matrix (1999) Bluray-720p.mkv")
|
|
|
|
|
|
def test_not_an_upgrade_is_rejected():
|
|
dl = _movie_dl("The Matrix 1999 720p HDTV") # worse than owned
|
|
folder = os.path.join("/lib/movies", "The Matrix (1999)")
|
|
fs_dirs = {folder: ["The Matrix (1999) Bluray-1080p.mkv"]}
|
|
p = importer.plan_import(dl, "/dl/x/matrix.720p.mkv", list_dir=lambda d: fs_dirs.get(d, []))
|
|
assert p["action"] == "reject" and "upgrade" in p["reason"].lower()
|
|
|
|
|
|
# ── ffprobe verification ──────────────────────────────────────────────────────
|
|
def test_probe_true_resolution_overrides_lying_name():
|
|
# name claims 1080p; the file is really 720p → tag + folder use the truth
|
|
dl = _movie_dl("The Matrix 1999 1080p BluRay")
|
|
probe = {"ok": True, "resolution": "720p", "duration_sec": 8000, "video_codec": "x264"}
|
|
p = importer.plan_import(dl, "/dl/x/matrix.mkv", list_dir=lambda d: [], probe=probe)
|
|
assert p["action"] == "import"
|
|
assert p["dest"]["path"].endswith("The Matrix (1999) Bluray-720p.mkv")
|
|
|
|
|
|
def test_probe_rejects_corrupt_file():
|
|
dl = _movie_dl("The Matrix 1999 1080p BluRay")
|
|
probe = {"ok": False}
|
|
p = importer.plan_import(dl, "/dl/x/matrix.mkv", list_dir=lambda d: [], probe=probe)
|
|
assert p["action"] == "reject" and "corrupt" in p["reason"].lower()
|
|
|
|
|
|
def test_probe_rejects_short_runtime_movie_as_sample():
|
|
dl = _movie_dl("The Matrix 1999 1080p BluRay")
|
|
probe = {"ok": True, "resolution": "1080p", "duration_sec": 120} # 2 minutes
|
|
p = importer.plan_import(dl, "/dl/x/matrix.mkv", list_dir=lambda d: [], probe=probe)
|
|
assert p["action"] == "reject" and "sample" in p["reason"].lower()
|
|
|
|
|
|
def test_probe_resolution_drives_upgrade_decision():
|
|
# name says 1080p but the file is truly 2160p → beats an owned 1080p copy
|
|
dl = _movie_dl("The Matrix 1999 1080p BluRay")
|
|
folder = os.path.join("/lib/movies", "The Matrix (1999)")
|
|
fs_dirs = {folder: ["The Matrix (1999) Bluray-1080p.mkv"]}
|
|
probe = {"ok": True, "resolution": "2160p", "duration_sec": 8000}
|
|
p = importer.plan_import(dl, "/dl/x/matrix.mkv", list_dir=lambda d: fs_dirs.get(d, []), probe=probe)
|
|
assert p["action"] == "upgrade"
|
|
|
|
|
|
def test_run_import_uses_prober_and_can_reject():
|
|
dl = _movie_dl("The Matrix 1999 1080p BluRay")
|
|
fs = FakeFS()
|
|
patch = importer.run_import(dl, "/dl/x/matrix.mkv", fs=fs,
|
|
prober=lambda p: {"ok": False})
|
|
assert patch["status"] == "import_failed" and not fs.copied
|
|
|
|
|
|
# ── orchestration via the fake fs ─────────────────────────────────────────────
|
|
def test_run_import_copies_and_reclaims_source():
|
|
dl = _movie_dl("The Matrix 1999 1080p BluRay")
|
|
fs = FakeFS()
|
|
patch = importer.run_import(dl, "/dl/x/matrix.mkv", fs=fs)
|
|
assert patch["status"] == "completed"
|
|
assert patch["dest_path"].endswith(os.path.join("The Matrix (1999)", "The Matrix (1999) Bluray-1080p.mkv"))
|
|
assert fs.copied and fs.copied[0][0] == "/dl/x/matrix.mkv"
|
|
assert "/dl/x/matrix.mkv" in fs.removed # soulseek source reclaimed
|
|
|
|
|
|
def test_run_import_keeps_torrent_source():
|
|
dl = _movie_dl("The Matrix 1999 1080p BluRay", source="torrent")
|
|
fs = FakeFS()
|
|
importer.run_import(dl, "/dl/x/matrix.mkv", fs=fs)
|
|
assert "/dl/x/matrix.mkv" not in fs.removed # torrent left seeding
|
|
|
|
|
|
def test_run_import_carries_subtitles_and_deletes_old_on_upgrade():
|
|
dl = _movie_dl("The Matrix 1999 2160p BluRay")
|
|
folder = os.path.join("/lib/movies", "The Matrix (1999)")
|
|
fs = FakeFS(dirs={
|
|
"/dl/x": ["matrix.mkv", "matrix.en.srt", "unrelated.srt"],
|
|
folder: ["The Matrix (1999) Bluray-720p.mkv"],
|
|
})
|
|
patch = importer.run_import(dl, "/dl/x/matrix.mkv", fs=fs)
|
|
assert patch["status"] == "completed"
|
|
# the matching sibling sub is carried + renamed to the dest stem, .en preserved
|
|
subs = [d for _s, d in fs.copied if d.endswith(".en.srt")]
|
|
assert subs and subs[0].endswith("The Matrix (1999) Bluray-2160p.en.srt")
|
|
# the worse 720p copy is deleted
|
|
assert os.path.join(folder, "The Matrix (1999) Bluray-720p.mkv") in fs.removed
|
|
|
|
|
|
# ── manual placement (force + override) ───────────────────────────────────────
|
|
def test_force_bypasses_sample_and_files_to_override_identity():
|
|
# a small "sample" file the auto path would reject — forced to a chosen movie
|
|
dl = _movie_dl("sample.mkv", size=20_000_000)
|
|
override = {"scope": "movie", "title": "Blade Runner", "year": 1982, "target_dir": "/lib/movies"}
|
|
p = importer.plan_import(dl, "/dl/x/sample.mkv", list_dir=lambda d: [],
|
|
force=True, override=override)
|
|
assert p["action"] == "import"
|
|
assert p["dest"]["path"] == os.path.join("/lib/movies", "Blade Runner (1982)",
|
|
"Blade Runner (1982).mkv")
|
|
|
|
|
|
def test_force_reidentifies_wrong_episode_to_the_right_one():
|
|
dl = _episode_dl("Show S01E02 1080p WEB-DL", season=1, episode=2)
|
|
override = {"scope": "episode", "title": "Show", "season": 3, "episode": 7,
|
|
"episode_title": "Reckoning", "target_dir": "/lib/tv"}
|
|
p = importer.plan_import(dl, "/dl/x/show.mkv", list_dir=lambda d: [],
|
|
force=True, override=override)
|
|
assert p["action"] == "import"
|
|
# re-identified to S03E07, but the file keeps its real quality tag from the release
|
|
assert p["dest"]["path"] == os.path.join("/lib/tv", "Show", "Season 03",
|
|
"Show - S03E07 - Reckoning WEBDL-1080p.mkv")
|
|
|
|
|
|
def test_force_replaces_existing_regardless_of_quality():
|
|
# forcing a 720p over an owned 1080p still places it (the user decided)
|
|
dl = _movie_dl("The Matrix 1999 720p HDTV")
|
|
folder = os.path.join("/lib/movies", "The Matrix (1999)")
|
|
fs_dirs = {folder: ["The Matrix (1999) Bluray-1080p.mkv"]}
|
|
override = {"scope": "movie", "title": "The Matrix", "year": 1999, "target_dir": "/lib/movies"}
|
|
p = importer.plan_import(dl, "/dl/x/m.mkv", list_dir=lambda d: fs_dirs.get(d, []),
|
|
force=True, override=override)
|
|
assert p["action"] == "upgrade"
|
|
|
|
|
|
def test_run_import_reject_remembers_the_file_location():
|
|
dl = _movie_dl("The Matrix 1999 1080p BluRay")
|
|
fs = FakeFS()
|
|
patch = importer.run_import(dl, "/dl/x/readme.nfo", fs=fs) # non-video → reject
|
|
assert patch["status"] == "import_failed"
|
|
assert patch["dest_path"] == "/dl/x/readme.nfo" # so manual import can find it
|
|
|
|
|
|
# ── organisation settings drive behaviour ─────────────────────────────────────
|
|
def test_custom_template_changes_the_path():
|
|
dl = _movie_dl("The Matrix 1999 1080p BluRay")
|
|
settings = {"movie_template": "$year/$title/$title $resolution"}
|
|
p = importer.plan_import(dl, "/dl/x/matrix.mkv", list_dir=lambda d: [], settings=settings)
|
|
assert p["dest"]["path"] == os.path.join("/lib/movies", "1999", "The Matrix", "The Matrix 1080p.mkv")
|
|
|
|
|
|
def test_replace_disabled_rejects_instead_of_upgrading():
|
|
dl = _movie_dl("The Matrix 1999 2160p BluRay")
|
|
folder = os.path.join("/lib/movies", "The Matrix (1999)")
|
|
fs_dirs = {folder: ["The Matrix (1999) Bluray-720p.mkv"]}
|
|
p = importer.plan_import(dl, "/dl/x/m.mkv", list_dir=lambda d: fs_dirs.get(d, []),
|
|
settings={"replace_existing": False})
|
|
assert p["action"] == "reject" and "replace is turned off" in p["reason"].lower()
|
|
|
|
|
|
def test_move_mode_moves_and_does_not_reclaim():
|
|
dl = _movie_dl("The Matrix 1999 1080p BluRay")
|
|
fs = FakeFS()
|
|
importer.run_import(dl, "/dl/x/matrix.mkv", fs=fs, settings={"transfer_mode": "move"})
|
|
assert fs.moved and fs.moved[0][0] == "/dl/x/matrix.mkv"
|
|
assert not fs.copied and not fs.removed # moved, so no copy + no source reclaim
|
|
|
|
|
|
def test_carry_subtitles_toggle_off():
|
|
dl = _movie_dl("The Matrix 1999 1080p BluRay")
|
|
fs = FakeFS(dirs={"/dl/x": ["matrix.mkv", "matrix.en.srt"]})
|
|
importer.run_import(dl, "/dl/x/matrix.mkv", fs=fs, settings={"carry_subtitles": False})
|
|
assert not any(d.endswith(".srt") for _s, d in fs.copied)
|
|
|
|
|
|
# (artwork/NFO sidecars moved out of run_import into core/video/sidecars.py — see
|
|
# tests/test_video_sidecars.py. run_import is now purely the file mover.)
|
|
|
|
|
|
def test_run_import_reject_leaves_file_and_flags_manual():
|
|
dl = _movie_dl("The Matrix 1999 1080p BluRay")
|
|
fs = FakeFS()
|
|
patch = importer.run_import(dl, "/dl/x/readme.nfo", fs=fs) # not a video file
|
|
assert patch["status"] == "import_failed" and patch["error"]
|
|
assert not fs.copied and not fs.removed # nothing touched on disk
|