From 06e0b51e62e4cbf3a108f418b48f9fab4e87a9ee Mon Sep 17 00:00:00 2001 From: slashmad Date: Wed, 29 Jul 2026 13:32:06 +0200 Subject: [PATCH] Restore missing Radarr hardlinks safely --- src/stowarr/clients.py | 9 +- src/stowarr/engine.py | 379 +++++++++++++++++++++++++++++++++---- src/stowarr/web/app.js | 75 +++++++- tests/test_clients.py | 26 +++ tests/test_engine.py | 417 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 861 insertions(+), 45 deletions(-) diff --git a/src/stowarr/clients.py b/src/stowarr/clients.py index 1c115ff..25eadf5 100644 --- a/src/stowarr/clients.py +++ b/src/stowarr/clients.py @@ -3,6 +3,7 @@ import time from collections.abc import Callable from typing import Any +from urllib.error import HTTPError from .config import Service from .http import JsonClient @@ -86,7 +87,13 @@ def download_mapping(self, download_id: str) -> dict | None: return None item_id = next(iter(item_ids)) endpoint = "movie" if self.kind == "radarr" else "series" - item = self.http.request("GET", f"/api/v3/{endpoint}/{item_id}") + try: + item = self.http.request("GET", f"/api/v3/{endpoint}/{item_id}") + except HTTPError as error: + if error.code != 404: + raise + error.close() + return None if self.kind == "radarr": movie_file = item.get("movieFile") files = [] if not movie_file else [{ diff --git a/src/stowarr/engine.py b/src/stowarr/engine.py index 41276dc..23ba284 100644 --- a/src/stowarr/engine.py +++ b/src/stowarr/engine.py @@ -1155,7 +1155,10 @@ def _torrent_paths(torrent: dict, files: list[dict]) -> list[tuple[Path, int]]: result = [] for item in files: path = save_path / item["name"] - if path.suffix.lower() in VIDEO_EXTENSIONS: + if ( + int(item.get("priority", 1)) > 0 + and path.suffix.lower() in VIDEO_EXTENSIONS + ): result.append((path, int(item["size"]))) return result @@ -1163,9 +1166,15 @@ def _torrent_paths(torrent: dict, files: list[dict]) -> list[tuple[Path, int]]: def _torrent_sidecars(torrent: dict, files: list[dict], target_item: Path) -> list[AuxiliaryFile]: save_path = Path(torrent["save_path"]) result = [] + by_target: dict[Path, AuxiliaryFile] = {} for item in files: source = save_path / item["name"] - if source.suffix.lower() in VIDEO_EXTENSIONS or is_archive(source) or source.name.startswith(".!qB"): + if ( + int(item.get("priority", 1)) <= 0 + or source.suffix.lower() in VIDEO_EXTENSIONS + or is_archive(source) + or source.name.startswith(".!qB") + ): continue target = target_item / source.name if not target.exists(): @@ -1174,12 +1183,16 @@ def _torrent_sidecars(torrent: dict, files: list[dict], target_item: Path) -> li status = "target-exists-same-size" else: status = "target-conflict" - result.append( - AuxiliaryFile( - str(source), str(target), int(item["size"]), status, - "qbittorrent", "hardlink", sidecar_kind(source), - ) + previous = by_target.get(target) + if previous: + previous.status = "torrent-name-conflict" + status = "torrent-name-conflict" + candidate = AuxiliaryFile( + str(source), str(target), int(item["size"]), status, + "qbittorrent", "hardlink", sidecar_kind(source), ) + result.append(candidate) + by_target[target] = candidate return result @staticmethod @@ -2248,7 +2261,24 @@ def plan( ) mapping = self.arr[app].download_mapping(torrent_hash) or mapping_hint if not mapping: - all_items = getattr(self.arr[app], "all_items", lambda: [])() + all_items = list( + getattr(self.arr[app], "all_items", lambda: [])() + ) + history_for_downloads = getattr( + self.arr[app], "history_for_downloads", None + ) + history_item_id = None + if callable(history_for_downloads): + history_item_id = history_for_downloads({ + torrent_hash.casefold() + }).get(torrent_hash.casefold()) + current_item_ids = { + int(item["id"]) for item in all_items if item.get("id") is not None + } + history_item_missing = bool( + history_item_id + and int(history_item_id) not in current_item_ids + ) candidates = [ { "id": item.get("id"), @@ -2261,16 +2291,38 @@ def plan( issues = [] if category_issue: issues.append(category_issue) + identity_code = ( + "ARR_HISTORY_ITEM_MISSING" + if history_item_missing + else "ARR_DOWNLOAD_HISTORY_MISSING" + ) + if history_item_missing: + identity_summary = ( + f"{app.capitalize()} history associates this hash with item " + f"#{history_item_id}, but that item no longer exists" + ) + else: + identity_summary = ( + f"{app.capitalize()} has no history record that associates this " + "exact qBittorrent hash with a current library item" + ) + if app == "radarr" and not candidates: + identity_action = ( + "Add the intended movie with Radarr Add New, select the root on " + f"{pool.name}, and do not start another download. Then use Radarr " + "Manual Import to associate the existing qBittorrent release, " + "refresh/rescan the movie, and rerun the audit." + ) + else: + identity_action = ( + f"Verify the intended item in {app.capitalize()}, then use " + f"{app.capitalize()} Manual Import to associate the existing " + "qBittorrent release. Refresh/rescan it and rerun the audit." + ) issues.append({ - "code": "ARR_DOWNLOAD_HISTORY_MISSING", - "summary": ( - f'{app.capitalize()} has no history record that associates this exact ' - "qBittorrent hash with a library item" - ), - "action": ( - f'Use {app.capitalize()} Manual Import to associate the intended release, ' - "then refresh/rescan the item and rerun the audit." - ), + "code": identity_code, + "summary": identity_summary, + "action": identity_action, }) if has_archives: issues.append({ @@ -2291,10 +2343,12 @@ def plan( if candidates else "" ) return Plan( - torrent_hash, torrent["name"], app, pool.name, None, None, None, None, [], + torrent_hash, torrent["name"], app, pool.name, + int(history_item_id) if history_item_id else None, + None, None, None, [], "blocked", - f"No matching {app.capitalize()} download history item.{candidate_note}", - "ARR_DOWNLOAD_HISTORY_MISSING", + f"{identity_summary}.{candidate_note}", + identity_code, { "issues": issues, "candidates": candidates, @@ -2302,6 +2356,7 @@ def plan( "expected_category": expected_category, "save_path": torrent.get("save_path", ""), "contains_archives": has_archives, + "historical_item_id": history_item_id, "action": ( f"Resolve every prerequisite below in qBittorrent and {app.capitalize()}, " "then rerun the audit. Stowarr will not reconcile by title alone." @@ -2398,6 +2453,43 @@ def plan( ) torrent_files = self._torrent_paths(torrent, torrent_records) library_files = self._library_files(mapping) + missing_library_pair: FilePair | None = None + if not library_files: + direct_videos = [ + (path, size) + for path, size in torrent_files + if path.suffix.casefold() in VIDEO_EXTENSIONS + ] + if app != "radarr" or has_archives or len(direct_videos) != 1: + reason = ( + f"{app.capitalize()} does not report any managed media file and " + "Stowarr cannot prove one unique direct torrent video to restore" + ) + return Plan( + torrent_hash, torrent["name"], app, pool.name, item["id"], + item.get("title"), item.get("path"), str(target_item), [], + "blocked", reason, "ARR_MANAGED_MEDIA_MISSING", + { + "issues": [{ + "code": "ARR_MANAGED_MEDIA_MISSING", + "summary": reason, + "action": ( + "Keep the qBittorrent data intact. Radarr restoration requires " + "exactly one selected direct video; Sonarr requires a complete " + "episode-to-file mapping." + ), + }], + "torrent_video_count": len(direct_videos), + "contains_archives": has_archives, + }, + ) + torrent_file, size = direct_videos[0] + target = target_item / torrent_file.name + missing_library_pair = FilePair( + str(target), str(target), str(torrent_file), size, + "missing-library", "hardlink", + ) + library_files = [(target, size)] if not title_matches(item.get("title", ""), Path(item["path"]).name): item_title = item.get("title") or "" folder_name = Path(item["path"]).name @@ -2443,6 +2535,10 @@ def plan( pairs: list[FilePair] = [] used: set[Path] = set() for source, size in library_files: + if missing_library_pair and source == Path(missing_library_pair.source_library): + pairs.append(missing_library_pair) + used.add(Path(missing_library_pair.torrent_file)) + continue candidates = [(path, item_size) for path, item_size in torrent_files if item_size == size and path not in used] relative = source.relative_to(Path(item["path"])) target = target_item / relative @@ -2572,7 +2668,17 @@ def plan( "action": blocked_actions[blocked], } if blocked else None, auxiliary_files=auxiliary_files, - managed_files=mapping.get("files", []), + managed_files=mapping.get("files", []) or ( + [{ + "id": None, + "path": missing_library_pair.target_library, + "relativePath": Path(missing_library_pair.target_library).name, + "size": missing_library_pair.size, + "episodeIds": [], + "plannedRestore": True, + }] + if missing_library_pair else [] + ), ) def sync_audit(self, app: str) -> dict: @@ -2613,6 +2719,10 @@ def sync_audit(self, app: str) -> dict: expected_roots: tuple[Path, ...] = () expected_category = None category_repairable = False + title_candidate_count = sum( + title_matches(candidate.get("title", ""), torrent["name"]) + for candidate in items.values() + ) if pool: expected_roots = roots_by_pool[pool.name] item_path = Path(str((item or {}).get("path") or "")) @@ -2633,8 +2743,20 @@ def sync_audit(self, app: str) -> dict: "code": "ARR_DOWNLOAD_HISTORY_MISSING", "summary": f"{service} does not associate this exact qBittorrent hash with an item.", "action": ( - f"Use {service} Manual Import for the intended release, then refresh/rescan " - "the item. Stowarr will not infer identity from the title alone." + ( + "Add the intended movie with Radarr Add New, select the " + f"root on {pool.name if pool else 'the intended pool'}, " + "and do not start another download. Then use Radarr " + "Manual Import to associate the existing qBittorrent " + "release and rerun the audit." + ) + if app == "radarr" and not title_candidate_count + else ( + f"Verify the intended item in {service}, use {service} " + "Manual Import to associate the existing qBittorrent " + "release, then refresh/rescan it and rerun the audit. " + "Stowarr will not infer identity from the title alone." + ) ), }) elif not item: @@ -2643,8 +2765,18 @@ def sync_audit(self, app: str) -> dict: "code": "ARR_HISTORY_ITEM_MISSING", "summary": f"{service} history points to an item that no longer exists.", "action": ( - f"Remove the stale association or restore the intended item in {service}, " - "then manually import and rescan it." + ( + "Add the intended movie with Radarr Add New, select the " + f"root on {pool.name if pool else 'the intended pool'}, " + "and do not start another download. Then manually import " + "the existing qBittorrent release, rescan, and rerun the audit." + ) + if app == "radarr" and not title_candidate_count + else ( + f"Verify or restore the intended item in {service}, then " + "manually import the existing qBittorrent release, rescan, " + "and rerun the audit." + ) ), }) elif not pool: @@ -2708,6 +2840,69 @@ def sync_audit(self, app: str) -> dict: "exact torrent-to-library mapping and no competing torrent exists." ), }) + elif app == "radarr": + movie_file = item.get("movieFile") or {} + relative = str(movie_file.get("relativePath") or "") + recorded_path = str(movie_file.get("path") or "") + library_path = Path( + recorded_path + or str(Path(str(item.get("path") or "")) / relative) + ) + if ( + not movie_file + or not (recorded_path or relative) + or not library_path.is_file() + ): + status = "missing-library-file" + reason = ( + "Radarr does not manage a visible movie file for this download" + ) + issues.append({ + "code": "ARR_MANAGED_MEDIA_MISSING", + "summary": reason, + "action": ( + "Build a Reconcile plan. Stowarr can restore one uniquely " + "identified direct qBittorrent video and its sidecars after " + "force recheck; ambiguous or packed content remains manual." + ), + }) + else: + library_stat = library_path.stat() + torrent_videos = [ + path + for path, size in self._torrent_paths( + torrent, self.qbit.files(torrent["hash"]) + ) + if size == int( + movie_file.get("size") or library_stat.st_size + ) + and path.is_file() + ] + hardlinked = any( + (candidate.stat().st_dev, candidate.stat().st_ino) + == (library_stat.st_dev, library_stat.st_ino) + for candidate in torrent_videos + ) + if hardlinked: + status, reason = ( + "in-sync", + "Hash, category, save path, *Arr root and hardlink agree", + ) + else: + status = "hardlink-missing" + reason = ( + "Radarr media is not hardlinked to the matched " + "qBittorrent download" + ) + issues.append({ + "code": "LIBRARY_HARDLINK_MISSING", + "summary": reason, + "action": ( + "Build a Reconcile plan. Stowarr will require one exact " + "same-content torrent file before replacing the library " + "entry with a verified hardlink." + ), + }) else: status, reason = "in-sync", "Hash, category, save path and *Arr root agree" rows.append({ @@ -2723,6 +2918,7 @@ def sync_audit(self, app: str) -> dict: "expected_roots": [str(root) for root in expected_roots], "expected_category": expected_category, "category_repairable": category_repairable, + "title_candidate_count": title_candidate_count, "status": status, "reason": reason, "issues": issues, @@ -2792,7 +2988,7 @@ def safe_sync_plan(self, app: str, progress=None) -> dict: if item.get("state") in {"QUEUED", "RUNNING"} } category_repairs = [] - root_mismatches = [] + reconcile_issues = [] reconcile_candidates = [] queued_reconciles = [] manual = [] @@ -2812,8 +3008,10 @@ def safe_sync_plan(self, app: str, progress=None) -> dict: "category": row["expected_category"], }) continue - if row["status"] == "root-mismatch": - root_mismatches.append(row) + if row["status"] in { + "root-mismatch", "missing-library-file", "hardlink-missing", + }: + reconcile_issues.append(row) continue manual.append({ "hash": row["hash"], @@ -2827,20 +3025,20 @@ def safe_sync_plan(self, app: str, progress=None) -> dict: "stage": "categories", "current": 1, "total": 1, "message": ( f"{len(category_repairs)} safe category fixes; " - f"{len(root_mismatches)} root mismatches need fresh plans" + f"{len(reconcile_issues)} repair candidates need fresh plans" ), }) - reconcile_total = len(root_mismatches) + reconcile_total = len(reconcile_issues) if progress: progress({ "stage": "reconciles", "current": 0, "total": reconcile_total, "message": ( - "No root mismatches need planning" + "No repair candidates need planning" if not reconcile_total else f"Building 0 of {reconcile_total} fresh Reconcile plans" ), }) - for index, row in enumerate(root_mismatches, 1): + for index, row in enumerate(reconcile_issues, 1): queued = active_reconciles.get(row["hash"].casefold()) if queued: queued_reconciles.append({ @@ -3455,19 +3653,30 @@ def digest(path_value: str) -> dict: videos = [] mismatches = [] + pending_restores = [] for pair in plan.pairs: old_library = digest(pair.source_library) new_library = digest(pair.target_library) torrent = digest(pair.torrent_file) if pair.torrent_file else None if pair.strategy == "hardlink": - old_matches = bool(torrent["sha256"] and torrent["sha256"] == old_library["sha256"]) + old_matches = ( + None + if pair.status == "missing-library" and not old_library["exists"] + else bool( + torrent["sha256"] + and torrent["sha256"] == old_library["sha256"] + ) + ) new_matches = None if not new_library["exists"] else torrent["sha256"] == new_library["sha256"] else: old_matches = None new_matches = None if not new_library["exists"] else old_library["sha256"] == new_library["sha256"] if old_matches is False or new_matches is False: mismatches.append(pair.source_library) + elif pair.status == "missing-library": + pending_restores.append(pair.target_library) videos.append({ + "status": pair.status, "strategy": pair.strategy, "torrent": torrent, "old_library": old_library, @@ -3482,11 +3691,18 @@ def digest(path_value: str) -> dict: matches = None if not target["exists"] else source["sha256"] == target["sha256"] sidecars.append({**asdict(item), "source_hash": source, "target_hash": target, "matches_target": matches}) return { - "status": "mismatch" if mismatches else "verified", + "status": ( + "mismatch" + if mismatches + else "ready-to-restore" + if pending_restores + else "verified" + ), "torrent_hash": torrent_hash, "video_files": videos, "sidecar_files": sidecars, "mismatches": mismatches, + "pending_restores": pending_restores, } def reconcile( @@ -3580,6 +3796,88 @@ def progress_callback(state, percent, **progress): created: list[Path] = [] copied_auxiliary: list[tuple[Path, Path]] = [] try: + restores_missing_library = any( + pair.status == "missing-library" for pair in plan.pairs + ) + if restores_missing_library: + if progress_callback: + progress_callback( + state_name("RECONCILE_VERIFYING", "MOVE_LIBRARY_VERIFYING"), + 0, + message="Force-rechecking qBittorrent before restoring missing media", + ) + self.qbit.recheck(torrent_hash) + self._wait_for_recheck( + torrent_hash, + progress=( + lambda torrent, started: progress_callback( + state_name( + "RECONCILE_VERIFYING", "MOVE_LIBRARY_VERIFYING" + ), + float(torrent.get("progress", 0)) * 100 + if started else 0, + qbit_state=torrent.get("state", ""), + message=( + "qBittorrent is verifying torrent pieces" + if started else + "Waiting for qBittorrent to begin verification" + ), + ) + if progress_callback else None + ), + ) + self._wait_for_visible_torrent_files(torrent_hash) + live_torrent = self.qbit.torrent(torrent_hash) + live_pool = ( + self.config.pool_for_path(live_torrent.get("save_path", "")) + if live_torrent else None + ) + live_route = ( + self.config.pool_for_category( + live_torrent.get("category", "") + ) + if live_torrent else None + ) + if ( + not live_torrent + or not live_pool + or live_pool.name != plan.target_pool + or not live_route + or live_route[0].name != plan.target_pool + or live_route[1] != plan.app + ): + raise RuntimeError( + "qBittorrent route changed during missing-media verification" + ) + selected_manifest = { + str(Path(live_torrent["save_path"]) / record["name"]): int( + record["size"] + ) + for record in self.qbit.files(torrent_hash) + if int(record.get("priority", 1)) > 0 + } + required_sources = { + pair.torrent_file: pair.size + for pair in plan.pairs + if pair.status == "missing-library" + } + required_sources.update({ + item.source: item.size + for item in plan.auxiliary_files or [] + if ( + item.origin == "qbittorrent" + and item.source in selected_auxiliary + ) + }) + changed = [ + path for path, size in required_sources.items() + if selected_manifest.get(path) != size + ] + if changed: + raise RuntimeError( + "One or more selected qBittorrent files changed during " + "missing-media verification" + ) pair_total = max(len(plan.pairs), 1) for pair_index, pair in enumerate(plan.pairs): source = Path(pair.source_library) @@ -3625,7 +3923,10 @@ def pair_progress(completed, total, label="library media"): torrent_file, progress=lambda done, total: pair_progress(done, total, "qBittorrent media") ): raise RuntimeError(f"Hash mismatch: {source} != {torrent_file}") - elif str(source) not in (relocated_library_sources or set()): + elif ( + pair.status != "missing-library" + and str(source) not in (relocated_library_sources or set()) + ): raise RuntimeError(f"Source changed or missing: {source}") if target.exists(): target_stat, torrent_stat = target.stat(), torrent_file.stat() @@ -3716,9 +4017,15 @@ def pair_progress(completed, total, label="library media"): if resolver: refreshed = resolver(expected_paths) refreshed_files = {record.get("id"): Path(record.get("path", "")) for record in (refreshed or {}).get("files", [])} + refreshed_paths = set(refreshed_files.values()) for record in plan.managed_files or []: expected = Path(plan.target_item_path or "") / record["relativePath"] - if refreshed_files.get(record.get("id")) != expected: + confirmed = ( + expected in refreshed_paths + if record.get("id") is None + else refreshed_files.get(record.get("id")) == expected + ) + if not confirmed: raise RuntimeError( f"{plan.app.capitalize()} did not confirm the managed file on its new path: {expected}" ) diff --git a/src/stowarr/web/app.js b/src/stowarr/web/app.js index 5fa4faf..9244b61 100644 --- a/src/stowarr/web/app.js +++ b/src/stowarr/web/app.js @@ -484,27 +484,86 @@ function renderReconcileBlocker(plan){ const affectedMarkup=affected.length?`
${affected.map(item=>``).join('')}
Blocked media fileTorrent fileReason
${esc(item.source_library||'—')}${esc(item.torrent_file||'No unique file')}${badge(item.status)}
`:''; return `

Reconcile needs manual resolution

Stowarr made no changes and will not enable Reconcile until the identity and storage route are safe.

${badge('blocked')}
${esc(plan.reason)}${plan.error_code?`${esc(plan.error_code)}`:''}
${issueMarkup}${candidateMarkup}${relatedMarkup}${affectedMarkup}${details.action?`
Before trying again

${esc(details.action)}

`:''}
`; } +function reconcileActionCopy(plan){ + const restoresMissing=plan.pairs?.some(pair=>pair.status==='missing-library'); + const samePath=Boolean(plan.pairs?.length)&&plan.pairs.every(pair=>pair.source_library===pair.target_library); + if(restoresMissing)return{ + title:'Ready to restore missing Radarr media', + message:'Stowarr will force-recheck qBittorrent, create the missing movie and selected subtitle hardlinks, rescan Radarr, and require Radarr to manage the exact movie path. No verified duplicate is removed.', + button:'Restore missing media hardlink', + confirmTitle:`Restore ${plan.item_title} from qBittorrent?`, + confirmMessage:'The qBittorrent video is retained as the source of truth. Stowarr creates library hardlinks only after a successful force recheck.', + detailLabel:'Missing library media', + }; + if(samePath)return{ + title:'Ready to repair missing hardlink identity', + message:'Stowarr will hash-verify the existing Radarr media against qBittorrent and replace the same library entry with a hardlink only when the content is identical.', + button:'Replace with verified hardlink', + confirmTitle:`Repair the ${plan.item_title} hardlink?`, + confirmMessage:'The existing library entry is replaced only after exact content verification.', + detailLabel:'Library entry repaired', + }; + return{ + title:'Ready for verified reconciliation', + message:'Stowarr will hash-verify, create the new hardlink, update *Arr, and only then remove the verified old duplicate.', + button:'Reconcile & remove verified duplicate', + confirmTitle:`Reconcile ${plan.item_title}?`, + confirmMessage:'Suspected duplicates are removed only after content verification.', + detailLabel:'Suspected duplicates', + }; +} +function renderReconcilePair(pair){ + const packed=pair.strategy==='archive-reextract'||pair.strategy==='verified-copy'; + const already=pair.status==='already-on-target'; + const restore=pair.status==='missing-library'; + const sourceTitle=packed + ?'qBittorrent archive set' + :`qBittorrent download on ${esc(poolForPath(pair.torrent_file))}`; + const sourceText=packed + ?'The archive manifest and save path are authoritative. Extracted files are disposable derived artifacts.' + :'This save path determines the authoritative pool. The download is kept.'; + const sourcePath=packed + ?'Archive content must pass a qBittorrent recheck and extractor test' + :esc(pair.torrent_file); + const targetStep=packed?(already?'KEEP':'RE-EXTRACT'):'CREATE'; + const targetTitle=packed + ?(already + ?'Imported media already on the authoritative pool' + :`Regenerate media with Stowarr on ${esc(poolForPath(pair.target_library))}`) + :`New library hardlink on ${esc(poolForPath(pair.target_library))}`; + const targetText=restore + ?'Created from the selected qBittorrent video only after a successful force recheck.' + :packed + ?(already + ?'No media move is required.' + :'Stowarr extracts the qBittorrent-owned archives into isolated staging and verifies the result before import.') + :'Created as a hardlink to the qBittorrent file after verification.'; + const previous=restore?'':`
3 · ${already?'CURRENT LIBRARY':'SUSPECTED STALE DERIVATIVE'}

${already?'Current imported media':`Old derived media on ${esc(poolForPath(pair.source_library))}`}

${already?'This file is already on the pool selected by qBittorrent.':'It is removed only after Stowarr has extracted, verified, and imported the media on the authoritative pool.'}

${esc(pair.source_library)}
`; + return `
${badge(pair.status)}${badge(pair.strategy)}${(pair.size/1073741824).toFixed(2)} GiB
1 · SOURCE OF TRUTH / KEEP

${sourceTitle}

${sourceText}

${sourcePath}
2 · ${targetStep}

${targetTitle}

${targetText}

${esc(pair.target_library)}
${previous}
`; +} function renderPlan(plan){ const error=renderReconcileBlocker(plan); const service=plan.app==='sonarr'?'Sonarr':'Radarr'; const itemType=plan.app==='sonarr'?'Series':'Movie'; const details=[['Status',badge(plan.status)],['Application',esc(service)],['Authoritative pool',esc(plan.target_pool||'—')],[`${service} ${itemType.toLowerCase()}`,plan.item_id?`${esc(plan.item_title)} (#${plan.item_id})`:'—'],['Torrent',esc(plan.torrent_name||'—')],[`Current ${service} ${itemType.toLowerCase()} folder`,`${esc(plan.current_item_path||'—')}`],[`New ${service} ${itemType.toLowerCase()} folder`,`${esc(plan.target_item_path||'—')}`],['Torrent hash',`${esc(plan.torrent_hash)}`]]; - const pairs=plan.pairs?.length?plan.pairs.map(p=>{const packed=p.strategy==='archive-reextract'||p.strategy==='verified-copy';const already=p.status==='already-on-target';return `
${badge(p.status)}${badge(p.strategy)}${(p.size/1073741824).toFixed(2)} GiB
1 · SOURCE OF TRUTH / KEEP

${packed?'qBittorrent archive set':`qBittorrent download on ${esc(poolForPath(p.torrent_file))}`}

${packed?'The archive manifest and save path are authoritative. Extracted files are disposable derived artifacts.':'This save path determines the authoritative pool. The download is kept.'}

${packed?'Archive content must pass a qBittorrent recheck and extractor test':esc(p.torrent_file)}
2 · ${packed?(already?'KEEP':'RE-EXTRACT'):'CREATE'}

${packed?(already?'Imported media already on the authoritative pool':`Regenerate media with Stowarr on ${esc(poolForPath(p.target_library))}`):`New library hardlink on ${esc(poolForPath(p.target_library))}`}

${packed?(already?'No media move is required.':'Stowarr extracts the qBittorrent-owned archives into isolated staging and verifies the result before import.'):'Created as a hardlink to the qBittorrent file after verification.'}

${esc(p.target_library)}
3 · ${already?'CURRENT LIBRARY':'SUSPECTED STALE DERIVATIVE'}

${already?'Current imported media':`Old derived media on ${esc(poolForPath(p.source_library))}`}

${already?'This file is already on the pool selected by qBittorrent.':'It is removed only after Stowarr has extracted, verified, and imported the media on the authoritative pool.'}

${esc(p.source_library)}
`}).join(''):'
No file pairs available
'; + const pairs=plan.pairs?.length?plan.pairs.map(renderReconcilePair).join(''):'
No file pairs available
'; const missingAux=(plan.auxiliary_files||[]).filter(x=>['missing-target','torrent-sidecar'].includes(x.status)); const conflicts=(plan.auxiliary_files||[]).filter(x=>['target-conflict','torrent-name-conflict'].includes(x.status)); const eligibleAux=(plan.auxiliary_files||[]).filter(x=>!['target-conflict','torrent-name-conflict'].includes(x.status)); const auxiliary=plan.auxiliary_files?.length?`
Sidecar files · ${missingAux.length} missing at destination${conflicts.length?` · ${conflicts.length} conflict`:''}
${plan.auxiliary_files.map(x=>{const selectable=!['target-conflict','torrent-name-conflict'].includes(x.status);const origin=x.origin==='qbittorrent'?'qBittorrent · hardlink':'Old library · copy';return ``}).join('')}
${conflicts.length?`
${conflicts.length} file(s) compete for the same destination or differ from an existing target. They are disabled and will not be overwritten automatically.
`:''}
`:''; const ready=plan.status==='ready'; const apply=Boolean(state.config?.apply); - const action=ready?`
${apply?'Ready for verified reconciliation':'Deletion is locked in dry-run mode'}

${apply?'Stowarr will hash-verify, create the new hardlink, update *Arr, and only then remove the verified duplicate.':'Set STOWARR_APPLY=true and provide writable media mounts only after reviewing the plan.'}

`:''; + const copy=reconcileActionCopy(plan); + const action=ready?`
${apply?esc(copy.title):'Changes are locked in dry-run mode'}

${apply?esc(copy.message):'Enable Write mode and provide writable media mounts only after reviewing the plan.'}

`:''; $('#plan-result').innerHTML=`${error}

Plan details

No changes have been made

${details.map(([k,v])=>`
${k}${v}
`).join('')}

File migration and hardlinks

qBittorrent determines the authoritative pool; suspected duplicates are removed only after verification

${pairs}${auxiliary}${action}
` } async function applyPlan(){ const plan=state.plan; if(!plan||plan.status!=='ready'||!state.config?.apply)return; + const copy=reconcileActionCopy(plan); const oldFiles=plan.pairs.map(p=>p.source_library).join(', '); const auxiliaryFiles=$$('.aux-file:checked').map(input=>input.dataset.source); - if(!await confirmAction({title:`Reconcile ${plan.item_title}?`,message:'Suspected duplicates are removed only after content verification.',details:[['Suspected duplicates',oldFiles],['Selected sidecar files',String(auxiliaryFiles.length)]],confirmLabel:'Reconcile',danger:true}))return; + if(!await confirmAction({title:copy.confirmTitle,message:copy.confirmMessage,details:[[copy.detailLabel,oldFiles],['Selected sidecar files',String(auxiliaryFiles.length)]],confirmLabel:copy.button,danger:true}))return; const button=$('#apply-plan'); button.disabled=true; button.textContent='Authorizing…'; @@ -540,7 +599,7 @@ async function applyPlan(){ if(showSubmissionTracker)rejectOperationTracking(plan.torrent_hash,'reconcile',e.message,afterId); toast(`Reconcile failed: ${e.message}`); button.disabled=false; - button.textContent='Reconcile & remove verified duplicate'; + button.textContent=copy.button; } } async function enqueueReconcile(){const plan=state.plan;if(!plan||plan.status!=='ready'||!state.config?.apply)return;const auxiliaryFiles=$$('.aux-file:checked').map(input=>input.dataset.source);if(!await confirmAction({title:`Add ${plan.item_title} to the Reconcile queue?`,message:'The plan is revalidated immediately before execution.',details:[['Selected sidecar files',String(auxiliaryFiles.length)]],confirmLabel:'Add to queue'}))return;const button=$('#queue-reconcile');button.disabled=true;button.textContent='Authorizing…';try{const confirmation=await api('/api/confirmations',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({kind:'reconcile',torrentHash:plan.torrent_hash,payload:{auxiliaryFiles}})});const queued=await api('/api/reconcile-queue',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({torrentHash:plan.torrent_hash,auxiliaryFiles,confirmationToken:confirmation.token})});toast(`Reconcile queued as ${queued.public_id}`);await refreshQueue();navigate('queue')}catch(error){toast(`Reconcile was not queued: ${error.message}`);button.disabled=false;button.textContent='Add to queue'}} @@ -834,7 +893,7 @@ function clearMoveSelection(){state.moveTorrent=null;state.movePlan=null;$('#mov async function loadQbitCatalog(force=false){if(state.qbitCatalog&&state.routingAudit&&!force){renderRoutingAudit();renderQbitCatalog();return}clearMoveSelection();$('#move-search-loading').classList.remove('hidden');$('#move-search-results').innerHTML='';try{const [catalog,audit]=await Promise.all([api('/api/qbittorrent/torrents'),api('/api/routing/audit')]);state.qbitCatalog=catalog;state.routingAudit=audit;renderRoutingAudit();renderQbitCatalog()}catch(e){$('#move-search-results').innerHTML=`
qBittorrent catalog failed

${esc(e.message)}

`}finally{$('#move-search-loading').classList.add('hidden')}} function selectMoveTorrent(hash){const torrent=catalogRows().find(row=>row.hash===hash);if(!torrent)return;state.moveTorrent=torrent;$('#move-hash').value=hash;$('#move-selected-name').textContent=torrent.name;$('#move-selected-hash').textContent=hash;$('.move-selection').classList.remove('hidden');const destination=state.config.pools.find(pool=>pool.name!==torrent.pool)||state.config.pools[0];if(destination)$('#move-target').value=destination.name;$('#move-result').innerHTML='';$('.move-selection').scrollIntoView({behavior:'smooth',block:'nearest'})} function hashCell(file,label){if(!file)return `
${esc(label)}Not applicablePacked torrent: media is not part of the torrent manifest
`;if(!file.exists)return `
${esc(label)}Not present${esc(file.path)}
`;return `
${esc(label)}SHA-256 ${esc(file.sha256.slice(0,16))}…inode ${file.inode} · links ${file.links}${esc(file.path)}
`} -async function verifyPlan(){const plan=state.plan;if(!plan||plan.status!=='ready')return;const button=$('#verify-plan');button.disabled=true;button.textContent='Hashing files…';$('#verification-result').innerHTML='
Hashing torrent, old library, and new library paths. This may take several minutes…
';try{const result=await api(`/api/verify/${encodeURIComponent(plan.torrent_hash)}`,{method:'POST'});const videos=result.video_files.map(file=>{const packed=file.strategy==='archive-reextract'||file.strategy==='verified-copy';const valid=packed?file.new_matches_torrent!==false:file.old_matches_torrent&&file.new_matches_torrent!==false;return `
${badge(valid?'verified':'mismatch')}${packed?'Extracted media comparison':'Direct torrent media comparison'}
${hashCell(file.torrent,'qBittorrent media file')}${hashCell(file.old_library,'Old library path')}${hashCell(file.new_library,'New library path')}

${packed?'The archive set remains authoritative; extracted media is verified independently.':`Old matches torrent: ${file.old_matches_torrent?'Yes':'No'}`} · New matches expected content: ${file.new_matches_torrent===null?'Not present yet':file.new_matches_torrent?'Yes':'No'}

`}).join('');const sidecarMatched=result.sidecar_files.filter(x=>x.matches_target===true).length;const sidecarMissing=result.sidecar_files.filter(x=>x.matches_target===null).length;const sidecarDifferent=result.sidecar_files.filter(x=>x.matches_target===false).length;$('#verification-result').innerHTML=`
Hash verification: ${esc(result.status)}${result.sidecar_files.length} sidecars · ${sidecarMatched} matching · ${sidecarMissing} destination missing · ${sidecarDifferent} different
${videos}
`}catch(e){$('#verification-result').innerHTML=`
Hash verification failed

${esc(e.message)}

`}finally{button.disabled=false;button.textContent='Verify content hashes'}} +async function verifyPlan(){const plan=state.plan;if(!plan||plan.status!=='ready')return;const button=$('#verify-plan');button.disabled=true;button.textContent='Hashing files…';$('#verification-result').innerHTML='
Hashing torrent, old library, and new library paths. This may take several minutes…
';try{const result=await api(`/api/verify/${encodeURIComponent(plan.torrent_hash)}`,{method:'POST'});const videos=result.video_files.map(file=>{const packed=file.strategy==='archive-reextract'||file.strategy==='verified-copy';const restore=file.status==='missing-library';const valid=restore?file.torrent?.exists&&file.old_library?.exists===false:packed?file.new_matches_torrent!==false:file.old_matches_torrent&&file.new_matches_torrent!==false;const comparison=restore?'Missing Radarr media restoration':packed?'Extracted media comparison':'Direct torrent media comparison';const explanation=restore?'The selected qBittorrent source is present. Its pieces are force-rechecked during execution before the missing library hardlink is created.':packed?'The archive set remains authoritative; extracted media is verified independently.':`Old matches torrent: ${file.old_matches_torrent?'Yes':'No'} · New matches expected content: ${file.new_matches_torrent===null?'Not present yet':file.new_matches_torrent?'Yes':'No'}`;return `
${badge(restore&&valid?'ready-to-restore':valid?'verified':'mismatch')}${comparison}
${hashCell(file.torrent,'qBittorrent media file')}${hashCell(file.old_library,restore?'Missing library path':'Old library path')}${hashCell(file.new_library,'New library path')}

${explanation}

`}).join('');const sidecarMatched=result.sidecar_files.filter(x=>x.matches_target===true).length;const sidecarMissing=result.sidecar_files.filter(x=>x.matches_target===null).length;const sidecarDifferent=result.sidecar_files.filter(x=>x.matches_target===false).length;$('#verification-result').innerHTML=`
Hash verification: ${esc(result.status)}${result.sidecar_files.length} sidecars · ${sidecarMatched} matching · ${sidecarMissing} destination missing · ${sidecarDifferent} different
${videos}
`}catch(e){$('#verification-result').innerHTML=`
Hash verification failed

${esc(e.message)}

`}finally{button.disabled=false;button.textContent='Verify content hashes'}} function setSyncApp(app){ state.syncApp=app; $$('.service-tab').forEach(x=>x.classList.toggle('active',x.dataset.app===app)); @@ -911,12 +970,12 @@ function renderSafeSyncPlan(plan){ const manual=plan.manual||[]; const preview=(items,empty)=>items.length?``:`

${esc(empty)}

`; const reconcileDisabled=!reconcile.length||!state.config?.apply||category.length>0; - target.innerHTML=`

Safe assisted repair

Fresh plans only. Category fixes are applied first; Reconcile candidates enter the shared FIFO queue. Ambiguous items remain manual.

${plan.safe_count} safe

1 · Category fixes ${category.length}

${preview(category,'No safely repairable categories.')}

2 · Reconcile queue ${reconcile.length}

${preview(reconcile,'No root mismatches have a ready Reconcile plan.')}

Manual review ${manual.length}

${preview(manual,'No remaining manual issues in this plan.')}${queued.length?`

${queued.length} Reconcile ${queued.length===1?'job is':'jobs are'} already active.

`:''}
`; + target.innerHTML=`

Safe assisted repair

Fresh plans only. Category fixes are applied first; Reconcile candidates enter the shared FIFO queue. Ambiguous items remain manual.

${plan.safe_count} safe

1 · Category fixes ${category.length}

${preview(category,'No safely repairable categories.')}

2 · Reconcile queue ${reconcile.length}

${preview(reconcile,'No repair candidates have a ready Reconcile plan.')}

Manual review ${manual.length}

${preview(manual,'No remaining manual issues in this plan.')}${queued.length?`

${queued.length} Reconcile ${queued.length===1?'job is':'jobs are'} already active.

`:''}
`; } const safePlanSteps=()=>[ {id:'audit',title:'Read current audit',description:'Reading qBittorrent and exact *Arr history associations.'}, {id:'categories',title:'Classify category fixes',description:'Checking save paths, pool routes, and configured categories.'}, - {id:'reconciles',title:'Build fresh Reconcile plans',description:'Testing each root mismatch against the complete Reconcile safety plan.'}, + {id:'reconciles',title:'Build fresh Reconcile plans',description:'Testing each repair candidate against the complete Reconcile safety plan.'}, {id:'manual',title:'Separate manual issues',description:'Keeping ambiguous or incomplete evidence outside automation.'}, ]; async function fetchSafeSyncPlan(app,onProgress){ @@ -995,7 +1054,7 @@ async function applySafeCategories(options={}){ {id:'validation',title:'Revalidate complete batch',description:'Every selected torrent must remain safe before any category changes.'}, {id:'apply',title:'Apply qBittorrent categories',description:'Changing only the previously validated category routes.'}, {id:'audit',title:'Refresh Sync audit',description:'Reading qBittorrent and *Arr again after the batch.'}, - {id:'rebuild',title:'Rebuild safe action plan',description:'Discovering which root mismatches are now ready for Reconcile.'}, + {id:'rebuild',title:'Rebuild safe action plan',description:'Discovering which repair candidates are now ready for Reconcile.'}, ]); try{ const confirmation=await api(`/api/sync/${encodeURIComponent(app)}/safe-category/confirmation`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({torrentHashes:hashes})}); diff --git a/tests/test_clients.py b/tests/test_clients.py index ab1b690..c5cf924 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -1,5 +1,6 @@ import unittest from unittest.mock import patch +from urllib.error import HTTPError from stowarr.clients import ArrClient, QBittorrentClient from stowarr.config import Service @@ -68,6 +69,31 @@ def request(self, method, path, query=None, **kwargs): self.assertEqual(client.history_for_downloads({"abc123"}), {"abc123": 42}) self.assertEqual(client.http.query["sortDirection"], "descending") + def test_download_mapping_returns_none_when_historical_item_was_deleted(self): + class DeletedMovieHttp: + def request(self, method, path, query=None, **kwargs): + if path == "/api/v3/history": + return { + "records": [{ + "downloadId": "HASH", + "movieId": 42, + }] + } + if path == "/api/v3/movie/42": + raise HTTPError( + "http://unused/api/v3/movie/42", + 404, + "Not Found", + {}, + None, + ) + raise AssertionError(path) + + client = ArrClient(Service("http://unused", api_key="unused"), "radarr") + client.http = DeletedMovieHttp() + + self.assertIsNone(client.download_mapping("hash")) + def test_sonarr_mapping_includes_only_episode_files_owned_by_download(self): class SonarrHttp: def request(self, method, path, query=None, **kwargs): diff --git a/tests/test_engine.py b/tests/test_engine.py index 1e013d8..9fa2df2 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -646,6 +646,30 @@ def test_torrent_sidecars_are_hardlink_candidates(self): self.assertEqual(sidecars[0].operation, "hardlink") self.assertEqual(sidecars[0].target, str(target / "Movie.sv.srt")) + def test_torrent_sidecars_block_flattened_name_conflicts(self): + files = [ + {"name": "Release/Movie.mkv", "size": 100, "priority": 1}, + { + "name": "Release/Subs-A/Movie.en.srt", + "size": 20, + "priority": 1, + }, + { + "name": "Release/Subs-B/Movie.en.srt", + "size": 21, + "priority": 1, + }, + ] + + sidecars = Stowarr._torrent_sidecars( + {"save_path": "/downloads"}, files, Path("/library/Movie (2020)") + ) + + self.assertEqual(len(sidecars), 2) + self.assertEqual( + {item.status for item in sidecars}, {"torrent-name-conflict"} + ) + def test_archive_detection_covers_multi_part_releases(self): self.assertTrue(is_archive(Path("movie.rar"))) self.assertTrue(is_archive(Path("movie.r00"))) @@ -991,6 +1015,57 @@ def test_reconcile_plan_blocks_missing_history_and_reports_packed_media_and_cate self.assertTrue(plan.error_details["contains_archives"]) self.assertEqual(plan.error_details["candidates"][0]["title"], "Click") + def test_missing_radarr_title_requires_add_new_before_manual_import(self): + pool = Pool( + "p3", Path("/p3"), (Path("/p3/download"),), + Path("/p3/movies"), Path("/p3/series"), + "radarr-pool3", "sonarr-pool3", + "radarr-pool3", "sonarr-pool3", + ) + torrent = { + "hash": "MISSING", "name": "Missing.Movie.2024.REMUX", + "category": "radarr-pool3", + "save_path": "/p3/download/Missing.Movie.2024.REMUX", + } + manager = Stowarr.__new__(Stowarr) + manager.qbit = SimpleNamespace( + torrents=lambda: [torrent], + files=lambda torrent_hash: [{ + "name": "Missing.Movie.2024.REMUX.mkv", + "size": 100, + "priority": 1, + }], + categories=lambda: { + "radarr-pool3": {"savePath": "/p3/download"}, + }, + ) + manager.config = SimpleNamespace( + pools=(pool,), + pool_for_path=lambda path: pool, + pool_for_category=lambda category: (pool, "radarr"), + ) + manager.arr = {"radarr": SimpleNamespace( + download_mapping=lambda torrent_hash: None, + history_for_downloads=lambda hashes: {"missing": 42}, + all_items=lambda: [], + )} + + plan = manager.plan("MISSING") + audit = manager.sync_audit("radarr") + + self.assertEqual(plan.status, "blocked") + self.assertEqual(plan.error_code, "ARR_HISTORY_ITEM_MISSING") + self.assertEqual(plan.item_id, 42) + self.assertIn( + "Radarr Add New", + plan.error_details["issues"][0]["action"], + ) + self.assertEqual(audit["rows"][0]["status"], "missing-item") + self.assertIn( + "Radarr Add New", + audit["rows"][0]["action"], + ) + def test_reconcile_plan_blocks_multiple_torrents_for_one_arr_item(self): pool = Pool( "p3", Path("/p3"), (Path("/p3/download"),), @@ -1027,6 +1102,253 @@ def test_reconcile_plan_blocks_multiple_torrents_for_one_arr_item(self): self.assertEqual(len(plan.error_details["related_torrents"]), 2) self.assertIn("seeding", plan.error_details["action"]) + def test_radarr_plan_restores_one_missing_video_and_selected_subtitles(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + download_root = root / "download" + release = download_root / "Moana.2.2024.REMUX" + movie = release / "Moana.2.2024.REMUX.mkv" + english = release / "Moana.2.2024.REMUX.en.srt" + swedish = release / "Moana.2.2024.REMUX.sv.srt" + ignored = release / "Moana.2.2024.REMUX.no.srt" + release.mkdir(parents=True) + movie.write_bytes(b"verified movie") + english.write_bytes(b"english") + swedish.write_bytes(b"swedish") + ignored.write_bytes(b"not selected") + movie_root = root / "movies" + item = { + "id": 66, + "title": "Moana 2", + "path": str(movie_root / "Moana 2 (2024)"), + } + torrent = { + "hash": "MOANA", + "name": "Moana.2.2024.REMUX", + "category": "radarr-pool3", + "save_path": str(download_root), + } + records = [ + { + "name": str(path.relative_to(download_root)), + "size": path.stat().st_size, + "priority": priority, + } + for path, priority in ( + (movie, 1), (english, 1), (swedish, 1), (ignored, 0) + ) + ] + pool = Pool( + "p3", root, (download_root,), movie_root, root / "series", + "radarr-pool3", "sonarr-pool3", + "radarr-pool3", "sonarr-pool3", + ) + manager = Stowarr.__new__(Stowarr) + manager.qbit = SimpleNamespace( + torrents=lambda: [torrent], + files=lambda torrent_hash: records, + ) + manager.config = SimpleNamespace( + pools=(pool,), + pool_for_path=lambda path: pool, + pool_for_category=lambda category: (pool, "radarr"), + ) + manager.arr = {"radarr": SimpleNamespace( + download_mapping=lambda torrent_hash: { + "app": "radarr", "item": item, "files": [], + }, + history_for_downloads=lambda hashes: {"moana": 66}, + )} + + plan = manager.plan("MOANA") + + self.assertEqual(plan.status, "ready") + self.assertEqual(len(plan.pairs), 1) + self.assertEqual(plan.pairs[0].status, "missing-library") + self.assertEqual(plan.pairs[0].torrent_file, str(movie)) + self.assertEqual( + plan.pairs[0].target_library, + str(movie_root / "Moana 2 (2024)" / movie.name), + ) + self.assertEqual( + {Path(item.source).name for item in plan.auxiliary_files}, + {english.name, swedish.name}, + ) + self.assertEqual(plan.managed_files[0]["id"], None) + self.assertTrue(plan.managed_files[0]["plannedRestore"]) + + verification = manager.verify("MOANA") + self.assertEqual(verification["status"], "ready-to-restore") + self.assertIsNone( + verification["video_files"][0]["old_matches_torrent"] + ) + + def test_radarr_missing_media_restore_blocks_multiple_selected_videos(self): + pool = Pool( + "p3", Path("/p3"), (Path("/p3/download"),), + Path("/p3/movies"), Path("/p3/series"), + "radarr-pool3", "sonarr-pool3", + "radarr-pool3", "sonarr-pool3", + ) + item = { + "id": 66, "title": "Moana 2", + "path": "/p3/movies/Moana 2 (2024)", + } + manager = Stowarr.__new__(Stowarr) + manager.qbit = SimpleNamespace( + torrents=lambda: [{ + "hash": "MOANA", "name": "Moana.2.2024", + "category": "radarr-pool3", + "save_path": "/p3/download/Moana.2.2024", + }], + files=lambda torrent_hash: [ + {"name": "disc1.mkv", "size": 10, "priority": 1}, + {"name": "disc2.mkv", "size": 11, "priority": 1}, + ], + ) + manager.config = SimpleNamespace( + pools=(pool,), + pool_for_path=lambda path: pool, + pool_for_category=lambda category: (pool, "radarr"), + ) + manager.arr = {"radarr": SimpleNamespace( + download_mapping=lambda torrent_hash: { + "app": "radarr", "item": item, "files": [], + }, + history_for_downloads=lambda hashes: {"moana": 66}, + )} + + plan = manager.plan("MOANA") + + self.assertEqual(plan.status, "blocked") + self.assertEqual(plan.error_code, "ARR_MANAGED_MEDIA_MISSING") + self.assertEqual(plan.error_details["torrent_video_count"], 2) + + def test_radarr_missing_media_restore_force_rechecks_and_hardlinks_subtitles(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + download_root = root / "download" + release = download_root / "Moana.2.2024.REMUX" + movie = release / "Moana.2.2024.REMUX.mkv" + subtitle = release / "Moana.2.2024.REMUX.sv.srt" + release.mkdir(parents=True) + movie.write_bytes(b"verified movie") + subtitle.write_bytes(b"verified subtitle") + movie_root = root / "movies" + target_root = movie_root / "Moana 2 (2024)" + target_movie = target_root / movie.name + target_subtitle = target_root / subtitle.name + item = { + "id": 66, + "title": "Moana 2", + "path": str(target_root), + } + initial_mapping = { + "app": "radarr", "item": item, "files": [], + } + refreshed_mapping = { + "app": "radarr", + "item": item, + "files": [{ + "id": 700, + "path": str(target_movie), + "relativePath": target_movie.name, + "size": movie.stat().st_size, + "episodeIds": [], + }], + } + plan = Plan( + "MOANA", "Moana.2.2024.REMUX", "radarr", "p3", 66, + "Moana 2", str(target_root), str(target_root), + [FilePair( + str(target_movie), str(target_movie), str(movie), + movie.stat().st_size, "missing-library", "hardlink", + )], + "ready", + auxiliary_files=[AuxiliaryFile( + str(subtitle), str(target_subtitle), + subtitle.stat().st_size, "torrent-sidecar", + "qbittorrent", "hardlink", "subtitle", + )], + managed_files=[{ + "id": None, + "path": str(target_movie), + "relativePath": target_movie.name, + "size": movie.stat().st_size, + "episodeIds": [], + "plannedRestore": True, + }], + ) + rescanned = False + + def rescan(item_id): + nonlocal rescanned + rescanned = True + + client = SimpleNamespace( + download_mapping=lambda torrent_hash: ( + refreshed_mapping if rescanned else initial_mapping + ), + sync_pool=Mock(), + rescan=Mock(side_effect=rescan), + ) + pool = Pool( + "p3", root, (download_root,), movie_root, root / "series", + "radarr-pool3", "sonarr-pool3", + "radarr-pool3", "sonarr-pool3", + ) + manager = Stowarr.__new__(Stowarr) + manager.arr = {"radarr": client} + manager.config = SimpleNamespace( + apply=True, + pools=(pool,), + pool_for_path=lambda path: pool, + pool_for_category=lambda category: (pool, "radarr"), + ) + manager.store = SimpleNamespace(update=Mock()) + manager.qbit = SimpleNamespace( + recheck=Mock(), + torrent=lambda torrent_hash: { + "hash": torrent_hash, + "save_path": str(download_root), + "category": "radarr-pool3", + }, + files=lambda torrent_hash: [ + { + "name": str(movie.relative_to(download_root)), + "size": movie.stat().st_size, + "priority": 1, + }, + { + "name": str(subtitle.relative_to(download_root)), + "size": subtitle.stat().st_size, + "priority": 1, + }, + ], + ) + manager._wait_for_recheck = Mock(return_value={}) + manager._wait_for_visible_torrent_files = Mock(return_value=[]) + + result = manager.reconcile( + "MOANA", {str(subtitle)}, operation_id=9, + mapping_hint=initial_mapping, prepared_plan=plan, + ) + + self.assertEqual(result["state"], "COMPLETE") + manager.qbit.recheck.assert_called_once_with("MOANA") + manager._wait_for_recheck.assert_called_once() + client.rescan.assert_called_once_with(66) + self.assertEqual( + (target_movie.stat().st_dev, target_movie.stat().st_ino), + (movie.stat().st_dev, movie.stat().st_ino), + ) + self.assertEqual( + (target_subtitle.stat().st_dev, target_subtitle.stat().st_ino), + (subtitle.stat().st_dev, subtitle.stat().st_ino), + ) + self.assertTrue(movie.exists()) + self.assertTrue(subtitle.exists()) + def test_sync_audit_reports_duplicate_and_unrouted_history_torrents(self): p1 = Pool( "p1", Path("/p1"), (Path("/p1/download"),), @@ -1077,6 +1399,101 @@ def test_sync_audit_reports_duplicate_and_unrouted_history_torrents(self): self.assertEqual(by_hash["C"]["expected_category"], "radarr-pool3") self.assertTrue(by_hash["C"]["category_repairable"]) + def test_radarr_sync_audit_finds_missing_media_and_missing_hardlinks(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + download_root = root / "download" + movie_root = root / "movies" + download_root.mkdir() + movie_root.mkdir() + missing_download = download_root / "Missing.2024.mkv" + copied_download = download_root / "Copied.2024.mkv" + linked_download = download_root / "Linked.2024.mkv" + for path in (missing_download, copied_download, linked_download): + path.write_bytes(path.stem.encode()) + copied_root = movie_root / "Copied (2024)" + linked_root = movie_root / "Linked (2024)" + copied_root.mkdir() + linked_root.mkdir() + copied_library = copied_root / copied_download.name + copied_library.write_bytes(copied_download.read_bytes()) + linked_library = linked_root / linked_download.name + os.link(linked_download, linked_library) + pool = Pool( + "p3", root, (download_root,), movie_root, root / "series", + "radarr-pool3", "sonarr-pool3", + "radarr-pool3", "sonarr-pool3", + ) + torrents = [ + { + "hash": name.upper(), "name": f"{name}.2024", + "category": "radarr-pool3", + "save_path": str(download_root), + } + for name in ("missing", "copied", "linked") + ] + torrent_paths = { + "MISSING": missing_download, + "COPIED": copied_download, + "LINKED": linked_download, + } + items = [ + { + "id": 1, "title": "Missing", + "path": str(movie_root / "Missing (2024)"), + }, + { + "id": 2, "title": "Copied", "path": str(copied_root), + "movieFile": { + "id": 20, "path": str(copied_library), + "relativePath": copied_library.name, + "size": copied_library.stat().st_size, + }, + }, + { + "id": 3, "title": "Linked", "path": str(linked_root), + "movieFile": { + "id": 30, "path": str(linked_library), + "relativePath": linked_library.name, + "size": linked_library.stat().st_size, + }, + }, + ] + manager = Stowarr.__new__(Stowarr) + manager.qbit = SimpleNamespace( + torrents=lambda: torrents, + categories=lambda: { + "radarr-pool3": {"savePath": str(download_root)}, + }, + files=lambda torrent_hash: [{ + "name": torrent_paths[torrent_hash.upper()].name, + "size": torrent_paths[torrent_hash.upper()].stat().st_size, + "priority": 1, + }], + ) + manager.config = SimpleNamespace( + pools=(pool,), + pool_for_path=lambda path: pool, + pool_for_category=lambda category: (pool, "radarr"), + ) + manager.arr = {"radarr": SimpleNamespace( + history_for_downloads=lambda hashes: { + "missing": 1, "copied": 2, "linked": 3, + }, + all_items=lambda: items, + )} + + audit = manager.sync_audit("radarr") + by_hash = {row["hash"]: row for row in audit["rows"]} + + self.assertEqual( + by_hash["MISSING"]["status"], "missing-library-file" + ) + self.assertEqual( + by_hash["COPIED"]["status"], "hardlink-missing" + ) + self.assertEqual(by_hash["LINKED"]["status"], "in-sync") + def test_sonarr_roots_preserve_anime_family_between_pools(self): p1 = Pool( "p1", Path("/p1"), (Path("/p1/download"),),