diff --git a/src/stowarr/engine.py b/src/stowarr/engine.py index 77a6381..6397765 100644 --- a/src/stowarr/engine.py +++ b/src/stowarr/engine.py @@ -39,6 +39,7 @@ SAFE_RECONCILE_AUDIT_STATUSES = { "root-mismatch", "missing-library-file", "hardlink-missing", } +SYNC_HEALTHY_STATUSES = {"in-sync", "packed-media"} RELEASE_FOLDER_MARKERS = re.compile( r"(?i)(?:^|[._ -])(?:720p|1080[pi]|2160p|uhd|bluray|blu-ray|web(?:[._ -]?dl)?|" r"remux|x26[45]|h[._ -]?26[45]|hevc|avc|hdr10p?|dv|dolby[._ -]?vision|" @@ -2824,6 +2825,7 @@ def sync_audit(self, app: str) -> dict: expected_roots: tuple[Path, ...] = () expected_category = None category_repairable = False + media_candidate_count = None title_candidate_count = sum( title_matches(candidate.get("title", ""), torrent["name"]) for candidate in items.values() @@ -3014,6 +3016,7 @@ def sync_audit(self, app: str) -> dict: ) and path.is_file() ] + media_candidate_count = len(torrent_videos) hardlinked = any( (candidate.stat().st_dev, candidate.stat().st_ino) == (library_stat.st_dev, library_stat.st_ino) @@ -3030,28 +3033,49 @@ def sync_audit(self, app: str) -> dict: "Radarr manages visible media derived from a packed " "qBittorrent download; hardlink identity is not applicable" ) + elif len(torrent_videos) == 1: + status = "hardlink-missing" + reason = ( + "Radarr media and one same-size qBittorrent video are " + "separate files; content is not hash-verified yet" + ) issues.append({ - "code": "PACKED_MEDIA_HARDLINK_NOT_APPLICABLE", + "code": "LIBRARY_HARDLINK_MISSING", "summary": reason, "action": ( - "Keep the qBittorrent archives intact. No hardlink repair " - "is available; use Diagnose only if the imported media or " - "Radarr association is suspect." + "Build a Reconcile plan. Stowarr will hash-verify the " + "single candidate before replacing the library copy " + "in place with a verified hardlink." + ), + }) + elif not torrent_videos: + status = "media-file-unmatched" + reason = ( + "No selected qBittorrent video has the same size as " + "Radarr's managed movie" + ) + issues.append({ + "code": "QBITTORRENT_MEDIA_CANDIDATE_MISSING", + "summary": reason, + "action": ( + "Force recheck qBittorrent and verify the selected files " + "and Radarr download association. Do not replace the " + "library file until one exact candidate is identified." ), }) else: - status = "hardlink-missing" + status = "media-file-ambiguous" reason = ( - "Radarr media is not hardlinked to the matched " - "qBittorrent download" + f"{len(torrent_videos)} selected qBittorrent videos have " + "the same size as Radarr's managed movie" ) issues.append({ - "code": "LIBRARY_HARDLINK_MISSING", + "code": "QBITTORRENT_MEDIA_CANDIDATE_AMBIGUOUS", "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." + "Inspect the selected torrent files and use Radarr Manual " + "Import if needed. Stowarr will not choose between multiple " + "same-size candidates automatically." ), }) else: @@ -3069,6 +3093,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, + "media_candidate_count": media_candidate_count, "title_candidate_count": title_candidate_count, "status": status, "reason": reason, @@ -3107,13 +3132,14 @@ def sync_audit(self, app: str) -> dict: row["related_torrents"] = torrent_evidence for row in rows: row["safe_plan_candidate"] = safe_sync_candidate(row) - rows.sort(key=lambda row: (row["status"] == "in-sync", row["torrent_name"].casefold())) + row["healthy"] = row["status"] in SYNC_HEALTHY_STATUSES + rows.sort(key=lambda row: (row["healthy"], row["torrent_name"].casefold())) return { "app": app, "scanned": len(rows), "matched_history": len(history), - "in_sync": sum(row["status"] == "in-sync" for row in rows), - "issues": sum(row["status"] != "in-sync" for row in rows), + "in_sync": sum(row["healthy"] for row in rows), + "issues": sum(not row["healthy"] for row in rows), "rows": rows, } @@ -3144,7 +3170,7 @@ def safe_sync_plan(self, app: str, progress=None) -> dict: queued_reconciles = [] manual = [] for row in audit["rows"]: - if row["status"] == "in-sync": + if row.get("healthy") is True or row["status"] in SYNC_HEALTHY_STATUSES: continue if ( row["status"] == "category-unconfigured" diff --git a/src/stowarr/web/app.js b/src/stowarr/web/app.js index 1a79ed1..1480cf2 100644 --- a/src/stowarr/web/app.js +++ b/src/stowarr/web/app.js @@ -486,6 +486,7 @@ function renderReconcileBlocker(plan){ } function reconcileActionCopy(plan){ const restoresMissing=plan.pairs?.some(pair=>pair.status==='missing-library'); + const mediaAlreadyValid=Boolean(plan.pairs?.length)&&plan.pairs.every(pair=>['linked','already-on-target','verified-derived'].includes(pair.status)); 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', @@ -495,6 +496,15 @@ function reconcileActionCopy(plan){ 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(mediaAlreadyValid)return{ + title:'Ready to repair selected sidecars only', + message:'The primary movie file is already valid and remains untouched. Stowarr processes only the selected subtitles, NFO, or other sidecar files.', + button:'Repair selected sidecars', + confirmTitle:`Repair selected sidecars for ${plan.item_title}?`, + confirmMessage:'No primary movie file is hashed, replaced, moved, or deleted by this plan.', + detailLabel:'Primary movie changes', + mediaUntouched:true, + }; 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.', @@ -515,6 +525,7 @@ function reconcileActionCopy(plan){ function renderReconcilePair(pair){ const packed=pair.strategy==='archive-reextract'||pair.strategy==='verified-copy'; const already=pair.status==='already-on-target'; + const mediaAlreadyValid=['linked','already-on-target','verified-derived'].includes(pair.status); const restore=pair.status==='missing-library'; const sourceTitle=packed ?'qBittorrent archive set' @@ -539,7 +550,21 @@ function renderReconcilePair(pair){ :'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}
`; + const mediaState=restore + ?'The primary movie is missing from the library.' + :mediaAlreadyValid + ?'The primary movie is already valid and requires no repair.' + :pair.source_library===pair.target_library&&!packed + ?'The primary movie already exists, but it is a separate copy rather than a hardlink to qBittorrent.' + :'The primary movie exists at an old or non-authoritative library path.'; + const mediaAction=restore + ?'After qBittorrent recheck, create the missing library hardlink.' + :mediaAlreadyValid + ?'Skip media hashing and leave this file unchanged.' + :pair.source_library===pair.target_library&&!packed + ?'Hash both files; only if identical, replace this library copy with a hardlink at the same path.' + :'Verify and publish the primary movie on the authoritative pool before removing the old derivative.'; + return `
Primary movie file${esc(mediaState)}${esc(mediaAction)}
${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); @@ -550,12 +575,12 @@ function renderPlan(plan){ 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 auxiliary=plan.auxiliary_files?.length?`
Optional secondary filesSubtitles, NFO, and other sidecarsThese selections do not determine whether the primary movie hardlink needs repair.
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} sidecar file(s) already exist with different content or compete for the same destination. They are skipped and will not be overwritten automatically.
`:''}
`:''; const ready=plan.status==='ready'; const apply=Boolean(state.config?.apply); 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}
` + $('#plan-result').innerHTML=`${error}

Plan details

No changes have been made

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

Primary media repair

The movie action is shown first. Optional subtitles and metadata are handled separately below.

${pairs}${auxiliary}${action}
` } async function applyPlan(){ const plan=state.plan; @@ -563,7 +588,7 @@ async function applyPlan(){ 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:copy.confirmTitle,message:copy.confirmMessage,details:[[copy.detailLabel,oldFiles],['Selected sidecar files',String(auxiliaryFiles.length)]],confirmLabel:copy.button,danger:true}))return; + if(!await confirmAction({title:copy.confirmTitle,message:copy.confirmMessage,details:[[copy.detailLabel,copy.mediaUntouched?'None — existing file kept':oldFiles],['Selected sidecar files',String(auxiliaryFiles.length)]],confirmLabel:copy.button,danger:true}))return; const button=$('#apply-plan'); button.disabled=true; button.textContent='Authorizing…'; @@ -924,19 +949,21 @@ function renderSync(result){ result.rows.forEach(row=>statusCounts.set(row.status,(statusCounts.get(row.status)||0)+1)); const visibleRows=result.rows.filter(row=>!hidden.has(row.status)); const rowMarkup=row=>{ - const issue=row.status!=='in-sync'; + const issue=row.healthy===undefined?row.status!=='in-sync':row.healthy!==true; const queued=activeReconcileQueueItem(row.hash); const canRepairCategory=row.status==='category-unconfigured'&&row.category_repairable; const safePlanCandidate=row.safe_plan_candidate===true; const actionLabel=canRepairCategory?'Stowarr fix available':safePlanCandidate?'Stowarr safe-plan candidate':'Manual fix'; const categoryAction=canRepairCategory?``:''; - const primaryAction=queued + const primaryAction=!issue&&row.status==='packed-media' + ?'' + :queued ?`` :``; return `${badge(row.status)}${esc(row.torrent_name)}${esc(row.item_title||missingLabel)}${issue?`${esc(row.reason)}`:''}${esc(row.hash.slice(0,12))}…${esc(row.category||'none')}${row.expected_category&&row.category!==row.expected_category?`Expected: ${esc(row.expected_category)}`:''}${esc(row.qbit_pool||'—')}${esc(row.arr_path||row.reason)}${issue&&row.action?`${actionLabel}: ${esc(row.action)}`:''}
${categoryAction}${primaryAction}
`; }; - const issues=visibleRows.filter(row=>row.status!=='in-sync'); - const healthy=visibleRows.filter(row=>row.status==='in-sync'); + const issues=visibleRows.filter(row=>row.healthy===undefined?row.status!=='in-sync':row.healthy!==true); + const healthy=visibleRows.filter(row=>row.healthy===undefined?row.status==='in-sync':row.healthy===true); const expanded=state.syncExpanded.has(result.app); const healthyGroup=healthy.length?`${expanded?healthy.map(rowMarkup).join(''):''}`:''; $('#sync-summary').innerHTML=`${result.scanned}qBit torrents${result.matched_history}hash matches${result.in_sync}in sync${result.issues}issues${visibleRows.length}visible`; diff --git a/src/stowarr/web/routing.css b/src/stowarr/web/routing.css index e2d9813..5b613d2 100644 --- a/src/stowarr/web/routing.css +++ b/src/stowarr/web/routing.css @@ -1722,11 +1722,42 @@ white-space: nowrap; } +.primary-media { + padding: 18px 20px 20px; +} + +.primary-media-head { + align-items: flex-start; + display: flex; + gap: 20px; + justify-content: space-between; + margin-bottom: 16px; +} + +.primary-media-head > div:first-child, +.auxiliary-head { + display: grid; + gap: 5px; +} + +.primary-media-head small, +.auxiliary-head small { + color: #9a9da2; +} + +.section-kicker { + color: #62a8e5; + font-size: 11px; + font-weight: 700; + letter-spacing: .06em; + text-transform: uppercase; +} + .pair-summary { align-items: center; display: flex; gap: 12px; - margin-bottom: 16px; + white-space: nowrap; } .pair-summary strong { @@ -1947,6 +1978,12 @@ padding: 16px 20px; } +.auxiliary-head { + border-bottom: 1px solid #35383c; + margin-bottom: 16px; + padding-bottom: 14px; +} + .check-option { align-items: flex-start; cursor: pointer; diff --git a/tests/test_engine.py b/tests/test_engine.py index 4a615e2..7be1fed 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -1430,6 +1430,82 @@ def rescan(item_id): self.assertTrue(movie.exists()) self.assertTrue(subtitle.exists()) + def test_sidecar_only_reconcile_skips_primary_media_hashing(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + download_root = root / "download" + release = download_root / "Movie.2024" + torrent_movie = release / "Movie.2024.mkv" + subtitle = release / "Movie.2024.sv.srt" + library_root = root / "movies" / "Movie (2024)" + library_movie = library_root / torrent_movie.name + target_subtitle = library_root / subtitle.name + release.mkdir(parents=True) + library_root.mkdir(parents=True) + torrent_movie.write_bytes(b"already hardlinked movie") + os.link(torrent_movie, library_movie) + subtitle.write_bytes(b"missing subtitle") + item = { + "id": 77, "title": "Movie", "path": str(library_root), + "tags": [], + } + record = { + "id": 700, "path": str(library_movie), + "relativePath": library_movie.name, + "size": library_movie.stat().st_size, "episodeIds": [], + } + mapping = {"app": "radarr", "item": item, "files": [record]} + plan = Plan( + "SIDE", "Movie.2024", "radarr", "p3", 77, "Movie", + str(library_root), str(library_root), + [FilePair( + str(library_movie), str(library_movie), str(torrent_movie), + torrent_movie.stat().st_size, "linked", "hardlink", + )], + "ready", + auxiliary_files=[AuxiliaryFile( + str(subtitle), str(target_subtitle), + subtitle.stat().st_size, "torrent-sidecar", + "qbittorrent", "hardlink", "subtitle", + )], + managed_files=[record], + ) + pool = Pool( + "p3", root, (download_root,), root / "movies", root / "series", + "radarr-pool3", "sonarr-pool3", + "radarr-pool3", "sonarr-pool3", + ) + client = SimpleNamespace( + download_mapping=lambda torrent_hash: mapping, + sync_pool=Mock(), + rescan=Mock(), + ) + manager = Stowarr.__new__(Stowarr) + manager.arr = {"radarr": client} + manager.config = SimpleNamespace(apply=True, pools=(pool,)) + manager.store = SimpleNamespace(update=Mock()) + manager.qbit = SimpleNamespace() + + with patch( + "stowarr.engine.sha256", + side_effect=AssertionError("sidecar-only repair hashed media"), + ): + result = manager.reconcile( + "SIDE", {str(subtitle)}, operation_id=10, + mapping_hint=mapping, prepared_plan=plan, + ) + + self.assertEqual(result["state"], "COMPLETE") + self.assertEqual( + (library_movie.stat().st_dev, library_movie.stat().st_ino), + (torrent_movie.stat().st_dev, torrent_movie.stat().st_ino), + ) + self.assertEqual( + (target_subtitle.stat().st_dev, target_subtitle.stat().st_ino), + (subtitle.stat().st_dev, subtitle.stat().st_ino), + ) + client.rescan.assert_called_once_with(77) + def test_sync_audit_reports_duplicate_and_unrouted_history_torrents(self): p1 = Pool( "p1", Path("/p1"), (Path("/p1/download"),), @@ -1493,7 +1569,13 @@ def test_radarr_sync_audit_finds_missing_media_and_missing_hardlinks(self): 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): + unmatched_download = download_root / "Unmatched.2024.mkv" + ambiguous_a = download_root / "Ambiguous.2024.a.mkv" + ambiguous_b = download_root / "Ambiguous.2024.b.mkv" + for path in ( + missing_download, copied_download, linked_download, + unmatched_download, ambiguous_a, ambiguous_b, + ): path.write_bytes(path.stem.encode()) copied_root = movie_root / "Copied (2024)" linked_root = movie_root / "Linked (2024)" @@ -1503,6 +1585,15 @@ def test_radarr_sync_audit_finds_missing_media_and_missing_hardlinks(self): copied_library.write_bytes(copied_download.read_bytes()) linked_library = linked_root / linked_download.name os.link(linked_download, linked_library) + unmatched_root = movie_root / "Unmatched (2024)" + unmatched_root.mkdir() + unmatched_library = unmatched_root / unmatched_download.name + unmatched_library.write_bytes(b"definitely-a-different-size") + ambiguous_root = movie_root / "Ambiguous (2024)" + ambiguous_root.mkdir() + ambiguous_library = ambiguous_root / "Ambiguous.2024.mkv" + ambiguous_library.write_bytes(ambiguous_a.read_bytes()) + ambiguous_b.write_bytes(ambiguous_a.read_bytes()) pool = Pool( "p3", root, (download_root,), movie_root, root / "series", "radarr-pool3", "sonarr-pool3", @@ -1514,12 +1605,13 @@ def test_radarr_sync_audit_finds_missing_media_and_missing_hardlinks(self): "category": "radarr-pool3", "save_path": str(download_root), } - for name in ("missing", "copied", "linked") + for name in ("missing", "copied", "linked", "unmatched", "ambiguous") ] torrent_paths = { "MISSING": missing_download, "COPIED": copied_download, "LINKED": linked_download, + "UNMATCHED": unmatched_download, } items = [ { @@ -1542,6 +1634,22 @@ def test_radarr_sync_audit_finds_missing_media_and_missing_hardlinks(self): "size": linked_library.stat().st_size, }, }, + { + "id": 4, "title": "Unmatched", "path": str(unmatched_root), + "movieFile": { + "id": 40, "path": str(unmatched_library), + "relativePath": unmatched_library.name, + "size": unmatched_library.stat().st_size, + }, + }, + { + "id": 5, "title": "Ambiguous", "path": str(ambiguous_root), + "movieFile": { + "id": 50, "path": str(ambiguous_library), + "relativePath": ambiguous_library.name, + "size": ambiguous_library.stat().st_size, + }, + }, ] manager = Stowarr.__new__(Stowarr) manager.qbit = SimpleNamespace( @@ -1549,11 +1657,18 @@ def test_radarr_sync_audit_finds_missing_media_and_missing_hardlinks(self): 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, - }], + files=lambda torrent_hash: [ + { + "name": path.name, + "size": path.stat().st_size, + "priority": 1, + } + for path in ( + [ambiguous_a, ambiguous_b] + if torrent_hash.upper() == "AMBIGUOUS" + else [torrent_paths[torrent_hash.upper()]] + ) + ], ) manager.config = SimpleNamespace( pools=(pool,), @@ -1563,6 +1678,7 @@ def test_radarr_sync_audit_finds_missing_media_and_missing_hardlinks(self): manager.arr = {"radarr": SimpleNamespace( history_for_downloads=lambda hashes: { "missing": 1, "copied": 2, "linked": 3, + "unmatched": 4, "ambiguous": 5, }, all_items=lambda: items, )} @@ -1577,9 +1693,21 @@ def test_radarr_sync_audit_finds_missing_media_and_missing_hardlinks(self): self.assertEqual( by_hash["COPIED"]["status"], "hardlink-missing" ) + self.assertEqual(by_hash["COPIED"]["media_candidate_count"], 1) + self.assertIn("not hash-verified", by_hash["COPIED"]["reason"]) self.assertTrue(by_hash["COPIED"]["safe_plan_candidate"]) self.assertEqual(by_hash["LINKED"]["status"], "in-sync") self.assertFalse(by_hash["LINKED"]["safe_plan_candidate"]) + self.assertEqual( + by_hash["UNMATCHED"]["status"], "media-file-unmatched" + ) + self.assertEqual(by_hash["UNMATCHED"]["media_candidate_count"], 0) + self.assertFalse(by_hash["UNMATCHED"]["safe_plan_candidate"]) + self.assertEqual( + by_hash["AMBIGUOUS"]["status"], "media-file-ambiguous" + ) + self.assertEqual(by_hash["AMBIGUOUS"]["media_candidate_count"], 2) + self.assertFalse(by_hash["AMBIGUOUS"]["safe_plan_candidate"]) def test_radarr_sync_audit_does_not_offer_hardlink_repair_for_packed_media(self): with tempfile.TemporaryDirectory() as directory: @@ -1636,11 +1764,13 @@ def test_radarr_sync_audit_does_not_offer_hardlink_repair_for_packed_media(self) row = manager.sync_audit("radarr")["rows"][0] self.assertEqual(row["status"], "packed-media") + self.assertTrue(row["healthy"]) self.assertFalse(row["safe_plan_candidate"]) - self.assertEqual( - row["issues"][0]["code"], - "PACKED_MEDIA_HARDLINK_NOT_APPLICABLE", - ) + self.assertEqual(row["issues"], []) + self.assertIsNone(row["action"]) + audit = manager.sync_audit("radarr") + self.assertEqual(audit["in_sync"], 1) + self.assertEqual(audit["issues"], 0) def test_radarr_sync_audit_blocks_a_different_release_folder_from_safe_repair(self): pool = Pool(