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.
120 lines
4.9 KiB
Python
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"] == []
|