Track repair collection cycles and reconcile request availability
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
"""Replacement-cycle regressions. All collectors/downloads are fixtures."""
|
||||
from contextlib import ExitStack
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from backend.app import db
|
||||
from backend.app.config import settings
|
||||
from backend.app.models import NormalizedState, RequestType, Snapshot
|
||||
from backend.app.routers import requests as requests_router
|
||||
from backend.app.services import snapshot as service, media_repair
|
||||
from backend.tests.test_backend_quality import TempDatabaseMixin
|
||||
|
||||
|
||||
class RepairPipelineTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.cycle = datetime.now(timezone.utc).isoformat()
|
||||
self.item = {"id": 12, "title": "Example", "hasFile": False}
|
||||
self.jf = {"Id": "jf-1", "Name": "Example", "Type": "Movie", "ProviderIds": {"Tmdb": "123"}, "Etag": "old"}
|
||||
self.episodes = [
|
||||
{"id": 109, "seasonNumber": 5, "episodeNumber": 9, "hasFile": False, "episodeFileId": 0},
|
||||
{"id": 110, "seasonNumber": 5, "episodeNumber": 10, "hasFile": True, "episodeFileId": 42},
|
||||
]
|
||||
self.torrents = []
|
||||
self.queue = []
|
||||
self.commands = []
|
||||
self.jf_episodes = [{"Id": "ep9", "ParentIndexNumber": 5, "IndexNumber": 9, "Etag": "old"}]
|
||||
self.media_type = RequestType.movie
|
||||
self.fail_collector = False
|
||||
|
||||
def start(self, media_type=RequestType.movie):
|
||||
self.media_type = media_type
|
||||
if media_type == RequestType.tv:
|
||||
self.jf.update(Type="Series", ProviderIds={"Tvdb": "456"})
|
||||
tracking = {
|
||||
"requestId": "12", "startedAt": self.cycle, "actionId": "replace_media",
|
||||
"collectorId": 12, "mediaType": media_type.value, "originalFileIds": [40],
|
||||
"previousDownloadIds": ["old"],
|
||||
"episodes": [{"id": 109, "seasonNumber": 5, "episodeNumber": 9}] if media_type == RequestType.tv else [],
|
||||
"jellyfinFoundAtStart": True,
|
||||
"jellyfinBaseline": [{"Id": "ep9", "Etag": "old", "seasonNumber": 5, "episodeNumber": 9}] if media_type == RequestType.tv else [{"Id": "jf-1", "Etag": "old"}],
|
||||
}
|
||||
db.start_request_repair(tracking)
|
||||
return tracking
|
||||
|
||||
async def snapshot(self):
|
||||
runtime = settings.model_copy(update={"requests_data_source": "prefer_cache", "jellyfin_public_url": "https://media.test"})
|
||||
lookup = AsyncMock(side_effect=RuntimeError("offline")) if self.fail_collector else AsyncMock(return_value=[self.item])
|
||||
collector = SimpleNamespace(
|
||||
get_movie_by_tmdb_id=lookup, get_series_by_tvdb_id=lookup,
|
||||
get_episodes=AsyncMock(return_value=self.episodes), get_queue=AsyncMock(return_value={"records": self.queue}),
|
||||
get=AsyncMock(return_value=self.commands),
|
||||
)
|
||||
jellyfin = SimpleNamespace(configured=lambda: True, search_items=AsyncMock(return_value={"Items": [self.jf]}),
|
||||
get_series_episodes=AsyncMock(return_value=self.jf_episodes))
|
||||
with ExitStack() as stack:
|
||||
mocks = {
|
||||
"get_runtime_settings": runtime,
|
||||
"get_request_cache_payload": {"id": 12, "type": self.media_type.value, "status": 4,
|
||||
"media": {"title": "Example", "tmdbId": 123, "tvdbId": 456}},
|
||||
"get_request_cache_by_id": None,
|
||||
"JellyseerrClient": SimpleNamespace(configured=lambda: False), "JellyfinClient": jellyfin,
|
||||
"QBittorrentClient": SimpleNamespace(configured=lambda: True,
|
||||
get_torrents_by_hashes=AsyncMock(return_value=self.torrents), get_torrents_by_tag=AsyncMock(return_value=self.torrents)),
|
||||
"SonarrClient": collector, "RadarrClient": collector,
|
||||
"ProwlarrClient": SimpleNamespace(get_health=AsyncMock(return_value=[])),
|
||||
"_latest_repair_action": None,
|
||||
}
|
||||
for name, value in mocks.items():
|
||||
stack.enter_context(patch.object(service, name, return_value=value))
|
||||
stack.enter_context(patch.object(service, "_maybe_refresh_jellyfin", new=AsyncMock()))
|
||||
stack.enter_context(patch.object(media_repair, "JellyfinClient", return_value=jellyfin))
|
||||
return await service.build_snapshot("12")
|
||||
|
||||
@staticmethod
|
||||
def stage(snapshot, name):
|
||||
return next(stage for stage in snapshot.presentation["pipeline"] if stage["id"] == name)
|
||||
|
||||
async def test_movie_old_catalog_and_completed_torrent_do_not_complete_repair(self):
|
||||
self.start()
|
||||
self.torrents = [{"hash": "old", "progress": 1, "state": "uploading", "added_on": 1, "completion_on": 2}]
|
||||
self.queue = [{"movieId": 12, "downloadId": "old"}]
|
||||
snapshot = await self.snapshot()
|
||||
self.assertEqual(self.stage(snapshot, "download")["stateLabel"], "Pending")
|
||||
self.assertEqual(self.stage(snapshot, "available")["state"], "waiting")
|
||||
self.assertEqual(snapshot.presentation["status"]["label"], "Waiting for a replacement")
|
||||
self.assertFalse(snapshot.presentation["download"]["visible"])
|
||||
self.assertTrue(snapshot.raw["jellyfin"]["catalogFound"])
|
||||
self.assertFalse(snapshot.raw["jellyfin"]["available"])
|
||||
self.assertIn("search_auto", [a.id for a in snapshot.actions])
|
||||
for name in ["requested", "approved"]:
|
||||
self.assertEqual(self.stage(snapshot, name)["state"], "complete")
|
||||
|
||||
async def test_movie_repair_search_download_import_index_and_complete(self):
|
||||
self.start()
|
||||
self.commands = [{"name": "MoviesSearch", "status": "started", "body": {"movieIds": [12]}}]
|
||||
searching = await self.snapshot()
|
||||
self.assertEqual(searching.presentation["status"]["label"], "Searching for a replacement")
|
||||
self.commands = []
|
||||
self.torrents = [{"hash": "new", "progress": .32, "state": "downloading"}]
|
||||
downloading = await self.snapshot()
|
||||
self.assertEqual(downloading.state, NormalizedState.downloading)
|
||||
self.assertEqual(self.stage(downloading, "download")["torrents"][0]["progressPercent"], 32)
|
||||
self.assertNotIn("resume_torrent", [a.id for a in downloading.actions])
|
||||
self.item.update(hasFile=True, movieFile={"id": 41})
|
||||
imported = await self.snapshot()
|
||||
self.assertEqual(self.stage(imported, "available")["stateLabel"], "Indexing")
|
||||
self.assertEqual(self.stage(imported, "download")["state"], "complete")
|
||||
self.assertEqual(len(db.get_request_repairs("12")), 1)
|
||||
self.jf["Etag"] = "new"
|
||||
self.torrents = []
|
||||
completed = await self.snapshot()
|
||||
self.assertEqual(completed.state, NormalizedState.completed)
|
||||
self.assertEqual(db.get_request_repairs("12"), [])
|
||||
self.assertEqual(completed.presentation["status"]["label"], "Available to watch")
|
||||
|
||||
async def test_old_queue_record_without_torrent_is_not_new_download_attempt(self):
|
||||
self.start()
|
||||
self.queue = [{"movieId": 12, "downloadId": "old"}]
|
||||
snapshot = await self.snapshot()
|
||||
self.assertEqual(self.stage(snapshot, "download")["stateLabel"], "Pending")
|
||||
self.assertFalse(snapshot.presentation["download"]["visible"])
|
||||
|
||||
async def test_same_original_file_cannot_confirm_replacement(self):
|
||||
self.start()
|
||||
self.item.update(hasFile=True, movieFile={"id": 40})
|
||||
self.jf["Etag"] = "new"
|
||||
snapshot = await self.snapshot()
|
||||
self.assertNotEqual(snapshot.state, NormalizedState.completed)
|
||||
self.assertEqual(self.stage(snapshot, "download")["state"], "waiting")
|
||||
|
||||
async def test_tv_preserves_unaffected_episodes_and_verifies_exact_replacement(self):
|
||||
self.start(RequestType.tv)
|
||||
self.item["statistics"] = {"episodeFileCount": 2, "totalEpisodeCount": 2} # stale summary
|
||||
pending = await self.snapshot()
|
||||
self.assertEqual(self.stage(pending, "library")["missing"], 1)
|
||||
self.assertEqual(self.stage(pending, "available")["state"], "partial")
|
||||
self.assertEqual(self.stage(pending, "download")["stateLabel"], "Pending")
|
||||
self.episodes[0].update(hasFile=True, episodeFileId=43)
|
||||
imported = await self.snapshot()
|
||||
self.assertEqual(imported.state, NormalizedState.importing)
|
||||
self.assertEqual(self.stage(imported, "available")["state"], "partial")
|
||||
self.assertEqual(len(db.get_request_repairs("12")), 1)
|
||||
self.jf_episodes[0]["Etag"] = "new"
|
||||
completed = await self.snapshot()
|
||||
self.assertEqual(completed.state, NormalizedState.completed)
|
||||
self.assertEqual(db.get_request_repairs("12"), [])
|
||||
|
||||
async def test_collector_outage_does_not_restore_old_availability(self):
|
||||
self.start()
|
||||
self.fail_collector = True
|
||||
snapshot = await self.snapshot()
|
||||
self.assertNotEqual(snapshot.state, NormalizedState.completed)
|
||||
self.assertEqual(snapshot.presentation["status"]["label"], "Repair status temporarily unavailable")
|
||||
|
||||
async def test_external_movie_removal_reconciles_old_jellyfin_entry(self):
|
||||
snapshot = await self.snapshot()
|
||||
self.assertNotEqual(snapshot.state, NormalizedState.completed)
|
||||
self.assertFalse(snapshot.raw["jellyfin"]["available"])
|
||||
|
||||
async def test_history_from_previous_cycle_and_late_old_poll_are_ignored(self):
|
||||
old = Snapshot(request_id="12", title="Example", state=NormalizedState.completed,
|
||||
timeline=[{"service": "qBittorrent", "status": "completed", "details": {"torrents": [{"hash": "old"}]}}])
|
||||
db.save_snapshot(old)
|
||||
self.start()
|
||||
self.assertFalse(db.get_request_download_evidence("12")["observed"])
|
||||
old.state_reason = "A pre-repair poll returned late"
|
||||
db.save_snapshot(old)
|
||||
self.assertFalse(db.get_request_download_evidence("12")["observed"])
|
||||
self.torrents = [{"hash": "new", "progress": .5, "state": "downloading"}]
|
||||
await self.snapshot()
|
||||
self.assertTrue(db.get_request_download_evidence("12")["observed"])
|
||||
|
||||
def test_same_hash_redownload_and_new_completed_job_are_kept(self):
|
||||
old = {"hash": "same", "progress": 1, "added_on": 1, "completion_on": 2}
|
||||
retry = {**old, "progress": .3}
|
||||
fresh = {**old, "completion_on": datetime.now(timezone.utc).timestamp() + 1}
|
||||
self.assertEqual(media_repair.current_cycle_torrents([old, retry, fresh], self.cycle), [retry, fresh])
|
||||
|
||||
def test_repair_cycle_survives_restart_and_list_does_not_say_ready(self):
|
||||
self.start()
|
||||
db.init_db()
|
||||
self.assertEqual(len(db.get_request_repairs("12")), 1)
|
||||
with patch.dict(requests_router._recent_cache, {"items": [{"request_id": 12, "status": 4, "requested_by_id": 7}]}):
|
||||
self.assertEqual(requests_router._get_recent_from_cache(None, 7, 10, 0, None, [4]), [])
|
||||
rows = requests_router._get_recent_from_cache(None, 7, 10, 0, None, [5])
|
||||
self.assertEqual(rows[0]["status"], 5)
|
||||
|
||||
async def test_live_poll_ignores_old_completed_download(self):
|
||||
self.start()
|
||||
runtime = settings.model_copy(update={"jellyseerr_base_url": None, "jellyseerr_api_key": None})
|
||||
qbit = SimpleNamespace(configured=lambda: True, get_torrents_by_tag=AsyncMock(return_value=[{"progress": 1, "hash": "old", "state": "uploading"}]))
|
||||
with patch.object(requests_router, "get_runtime_settings", return_value=runtime), patch.object(requests_router, "QBittorrentClient", return_value=qbit):
|
||||
progress = await requests_router.get_download_progress("12", {"role": "user"})
|
||||
self.assertEqual(progress["state"], "not_started")
|
||||
self.assertFalse(progress["visible"])
|
||||
self.assertEqual(progress["repairCycle"], self.cycle)
|
||||
|
||||
async def test_failed_search_after_deletion_keeps_cycle_and_pending_pipeline(self):
|
||||
before = Snapshot(request_id="12", title="Example", request_type=RequestType.movie,
|
||||
raw={"arr": {"item": {"id": 12, "hasFile": True, "movieFile": {"id": 40}}},
|
||||
"jellyfin": {"found": True, "item": self.jf}})
|
||||
async def delete(_):
|
||||
self.assertEqual(len(db.get_request_repairs("12")), 1, "Must persist before removal")
|
||||
radarr = SimpleNamespace(configured=lambda: True, monitor_movie=AsyncMock(),
|
||||
delete_movie_file=AsyncMock(side_effect=delete), search=AsyncMock(side_effect=RuntimeError("search failed")))
|
||||
with ExitStack() as stack:
|
||||
for name, value in {"_user_can_use_search_auto": True, "_linked_issue_for_replacement": None,
|
||||
"JellyseerrClient": SimpleNamespace(configured=lambda: False), "RadarrClient": radarr}.items():
|
||||
stack.enter_context(patch.object(requests_router, name, return_value=value))
|
||||
stack.enter_context(patch.object(requests_router, "build_snapshot", new=AsyncMock(return_value=before)))
|
||||
with self.assertRaises(requests_router.HTTPException):
|
||||
await requests_router.action_replace_media("12", {"file_ids": [40], "confirmed": True}, {"role": "admin"})
|
||||
self.assertEqual(len(db.get_request_repairs("12")), 1)
|
||||
snapshot = await self.snapshot()
|
||||
self.assertEqual(self.stage(snapshot, "download")["state"], "waiting")
|
||||
|
||||
def test_existing_issue_tracking_is_migrated_once_and_survives_ticket_deletion(self):
|
||||
tracking = {"requestId": "12", "startedAt": self.cycle, "actionId": "replace_media", "collectorId": 12,
|
||||
"mediaType": "movie", "originalFileIds": [40]}
|
||||
issue = db.create_portal_item(kind="issue", title="Repair", description="Replace movie", status="in_progress",
|
||||
created_by_username="reporter", created_by_id=None, issue_type="broken_media")
|
||||
db.add_portal_item_activity(issue["id"], event_type="replacement_started", actor_username="reporter",
|
||||
actor_role="user", message="Repair requested", metadata_json=json.dumps({"repairTracking": tracking}))
|
||||
db.init_db()
|
||||
db.init_db()
|
||||
self.assertEqual(len(db.get_request_repairs("12")), 1)
|
||||
db.delete_portal_item(issue["id"])
|
||||
self.assertEqual(len(db.get_request_repairs("12")), 1)
|
||||
|
||||
async def test_multiple_repairs_wait_for_every_target_not_just_latest(self):
|
||||
first = self.start(RequestType.tv)
|
||||
second = {**first, "startedAt": (datetime.now(timezone.utc) + timedelta(seconds=1)).isoformat(),
|
||||
"episodes": [{"id": 110, "seasonNumber": 5, "episodeNumber": 10}], "originalFileIds": [42],
|
||||
"jellyfinFoundAtStart": False, "jellyfinBaseline": []}
|
||||
db.start_request_repair(second)
|
||||
self.episodes[1].update(episodeFileId=43)
|
||||
self.jf_episodes.append({"Id": "ep10", "ParentIndexNumber": 5, "IndexNumber": 10})
|
||||
snapshot = await self.snapshot()
|
||||
self.assertNotEqual(snapshot.state, NormalizedState.completed)
|
||||
self.assertEqual([r["originalFileIds"] for r in db.get_request_repairs("12")], [[40]])
|
||||
Reference in New Issue
Block a user