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.'}
`;
+ 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)}
${conflicts.length} file(s) compete for the same destination or differ from an existing target. They are disabled and will not be overwritten automatically.
${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.