Archived
Add watchlist filesystem reconciliation
This commit is contained in:
@@ -1,5 +1,11 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.49.5 - 2026-07-21
|
||||||
|
|
||||||
|
- Added a manual Config page action that reconciles watchlist downloaded episode flags against the current filesystem library.
|
||||||
|
- The new sync removes downloaded flags for missing files, adds flags for episodes already present on disk for the watchlist's active download mode, and updates movie entries when their library folder exists again.
|
||||||
|
- Expanded regression coverage for the new filesystem reconciliation flow and Config route wiring.
|
||||||
|
|
||||||
## 0.49.4 - 2026-07-19
|
## 0.49.4 - 2026-07-19
|
||||||
|
|
||||||
- Added optional download job stdout mirroring with `ANI_CLI_WEB_JOB_STDOUT`, enabled by default in Docker and Docker Compose so container log collectors can capture downloader output.
|
- Added optional download job stdout mirroring with `ANI_CLI_WEB_JOB_STDOUT`, enabled by default in Docker and Docker Compose so container log collectors can capture downloader output.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Local web UI for a system-wide `ani-cli` install.
|
Local web UI for a system-wide `ani-cli` install.
|
||||||
|
|
||||||
Current version: `0.49.4`
|
Current version: `0.49.5`
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ Current version: `0.49.4`
|
|||||||
- `/`: Search and queue downloads, with poster-card search results below the selection box and alternate English titles under the main name when available
|
- `/`: Search and queue downloads, with poster-card search results below the selection box and alternate English titles under the main name when available
|
||||||
- `/queue`: Monitor downloads and Jellyfin handoff jobs, inspect logs, retry failed downloads, avoid duplicate re-queues for the same failed download, clear failed jobs, and remove finished jobs
|
- `/queue`: Monitor downloads and Jellyfin handoff jobs, inspect logs, retry failed downloads, avoid duplicate re-queues for the same failed download, clear failed jobs, and remove finished jobs
|
||||||
- `/watchlist`: Track shows, refresh them, download all available episodes, and manage auto-download settings such as `Source name`, library `Name`, season, and episode offset
|
- `/watchlist`: Track shows, refresh them, download all available episodes, and manage auto-download settings such as `Source name`, library `Name`, season, and episode offset
|
||||||
- `/config`: Save defaults, choose queue download methods and fallback retries, schedule refreshes, configure Jellyfin and Discord, monitor Jellyfin handoff progress, and view runtime info and the changelog
|
- `/config`: Save defaults, choose queue download methods and fallback retries, schedule refreshes, configure Jellyfin and Discord, manually reconcile watchlist download flags against the filesystem, monitor Jellyfin handoff progress, and view runtime info and the changelog
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
@@ -130,6 +130,7 @@ Docker notes:
|
|||||||
- TV libraries move only after the show is finished and download-complete
|
- TV libraries move only after the show is finished and download-complete
|
||||||
- Movie libraries move after download completion
|
- Movie libraries move after download completion
|
||||||
- Manual `Run now` scans the watchlist in a background job and shows live progress for processed entries, moved files, and the current destination path
|
- Manual `Run now` scans the watchlist in a background job and shows live progress for processed entries, moved files, and the current destination path
|
||||||
|
- Manual watchlist filesystem sync rechecks downloaded episode flags against the files currently present under the download library
|
||||||
- Recent Jellyfin handoff jobs survive page reloads and show their latest status after app restarts
|
- Recent Jellyfin handoff jobs survive page reloads and show their latest status after app restarts
|
||||||
|
|
||||||
## Homepage widget API
|
## Homepage widget API
|
||||||
|
|||||||
@@ -987,6 +987,14 @@ def watchlist_source_library_dir(item, config):
|
|||||||
return Path(str((config or {}).get("download_dir") or DEFAULT_CONFIG["download_dir"])).expanduser() / media_type / watchlist_download_library_name(item)
|
return Path(str((config or {}).get("download_dir") or DEFAULT_CONFIG["download_dir"])).expanduser() / media_type / watchlist_download_library_name(item)
|
||||||
|
|
||||||
|
|
||||||
|
def watchlist_source_season_dir(item, config):
|
||||||
|
source_root = watchlist_source_library_dir(item, config)
|
||||||
|
if normalize_media_type(item.get("media_type")) == "movie":
|
||||||
|
return source_root
|
||||||
|
season = normalize_season((item or {}).get("auto_download_series") or "1")
|
||||||
|
return source_root / f"Season {int(season):02d}"
|
||||||
|
|
||||||
|
|
||||||
def jellyfin_target_library_dir(item, config):
|
def jellyfin_target_library_dir(item, config):
|
||||||
media_type = normalize_media_type(item.get("media_type"))
|
media_type = normalize_media_type(item.get("media_type"))
|
||||||
key = "jellyfin_movie_dir" if media_type == "movie" else "jellyfin_tv_dir"
|
key = "jellyfin_movie_dir" if media_type == "movie" else "jellyfin_tv_dir"
|
||||||
@@ -1043,6 +1051,62 @@ def watchlist_item_download_complete(item, mode=""):
|
|||||||
return True, "ready"
|
return True, "ready"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_downloaded_episode_value(value):
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
match = re.match(r"^0*([0-9]+)(.*)$", text)
|
||||||
|
if not match:
|
||||||
|
return text
|
||||||
|
number = int(match.group(1) or "0", 10)
|
||||||
|
suffix = str(match.group(2) or "")
|
||||||
|
return f"{number}{suffix}" if number > 0 else text
|
||||||
|
|
||||||
|
|
||||||
|
def reverse_episode_offset(value, offset):
|
||||||
|
normalized_offset = normalize_episode_offset(offset)
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not normalized_offset or not text:
|
||||||
|
return normalize_downloaded_episode_value(text)
|
||||||
|
match = re.match(r"^0*([0-9]+)(.*)$", text)
|
||||||
|
if not match:
|
||||||
|
return normalize_downloaded_episode_value(text)
|
||||||
|
number = int(match.group(1) or "0", 10)
|
||||||
|
if number < 1:
|
||||||
|
return normalize_downloaded_episode_value(text)
|
||||||
|
logical_number = number - int(normalized_offset, 10) + 1
|
||||||
|
if logical_number < 1:
|
||||||
|
return ""
|
||||||
|
suffix = str(match.group(2) or "")
|
||||||
|
return f"{logical_number}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def watchlist_downloaded_episode_values_from_filesystem(item, config, mode):
|
||||||
|
normalized_mode = str(mode or "").strip().lower()
|
||||||
|
if normalized_mode not in MODE_CHOICES:
|
||||||
|
return []
|
||||||
|
season_dir = watchlist_source_season_dir(item, config)
|
||||||
|
if not season_dir.exists() or not season_dir.is_dir():
|
||||||
|
return []
|
||||||
|
season = normalize_season((item or {}).get("auto_download_series") or "1")
|
||||||
|
pattern = re.compile(rf"\bS{int(season):02d}E([0-9][0-9A-Za-z.\-]*)\b", re.IGNORECASE)
|
||||||
|
values = []
|
||||||
|
seen = set()
|
||||||
|
offset = (item or {}).get("auto_download_offset")
|
||||||
|
for path in sorted(season_dir.rglob("*")):
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
match = pattern.search(path.stem)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
logical_value = reverse_episode_offset(match.group(1), offset)
|
||||||
|
normalized_value = normalize_downloaded_episode_value(logical_value)
|
||||||
|
if normalized_value and normalized_value not in seen:
|
||||||
|
seen.add(normalized_value)
|
||||||
|
values.append(normalized_value)
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
def _move_tree_contents(source_dir, target_dir, progress_fn=None):
|
def _move_tree_contents(source_dir, target_dir, progress_fn=None):
|
||||||
moved = []
|
moved = []
|
||||||
file_paths = [
|
file_paths = [
|
||||||
@@ -2095,6 +2159,81 @@ class WatchlistStore:
|
|||||||
)
|
)
|
||||||
return self.schedule_refresh(existing["show_id"], source=source)
|
return self.schedule_refresh(existing["show_id"], source=source)
|
||||||
|
|
||||||
|
def reconcile_downloaded_filesystem(self, config=None):
|
||||||
|
active_config = normalize_config(config or self.config_getter() or {})
|
||||||
|
items = []
|
||||||
|
changed = 0
|
||||||
|
with self.lock, self._connect() as conn:
|
||||||
|
rows = conn.execute("SELECT * FROM watchlist ORDER BY title COLLATE NOCASE ASC").fetchall()
|
||||||
|
for row in rows:
|
||||||
|
existing_item = self._row_to_item(row)
|
||||||
|
media_type = normalize_media_type(existing_item.get("media_type"))
|
||||||
|
if media_type == "movie":
|
||||||
|
source_dir = watchlist_source_library_dir(existing_item, active_config)
|
||||||
|
has_files = source_dir.exists() and any(path.is_file() for path in source_dir.rglob("*"))
|
||||||
|
new_sub_values = []
|
||||||
|
new_dub_values = []
|
||||||
|
downloaded = has_files
|
||||||
|
else:
|
||||||
|
filesystem_values = watchlist_downloaded_episode_values_from_filesystem(existing_item, active_config, "dub")
|
||||||
|
if filesystem_values:
|
||||||
|
preferred_mode = watchlist_preferred_completion_mode(existing_item)
|
||||||
|
new_sub_values = list(existing_item.get("downloaded_sub_episodes") or [])
|
||||||
|
new_dub_values = list(existing_item.get("downloaded_dub_episodes") or [])
|
||||||
|
if preferred_mode == "sub":
|
||||||
|
new_sub_values = filesystem_values
|
||||||
|
else:
|
||||||
|
new_dub_values = filesystem_values
|
||||||
|
downloaded = bool(new_sub_values or new_dub_values)
|
||||||
|
else:
|
||||||
|
new_sub_values = []
|
||||||
|
new_dub_values = []
|
||||||
|
downloaded = False
|
||||||
|
old_sub_values = existing_item.get("downloaded_sub_episodes") or []
|
||||||
|
old_dub_values = existing_item.get("downloaded_dub_episodes") or []
|
||||||
|
sub_added = [value for value in new_sub_values if value not in old_sub_values]
|
||||||
|
sub_removed = [value for value in old_sub_values if value not in new_sub_values]
|
||||||
|
dub_added = [value for value in new_dub_values if value not in old_dub_values]
|
||||||
|
dub_removed = [value for value in old_dub_values if value not in new_dub_values]
|
||||||
|
downloaded_changed = bool(existing_item.get("downloaded")) != downloaded
|
||||||
|
row_changed = bool(sub_added or sub_removed or dub_added or dub_removed or downloaded_changed)
|
||||||
|
if row_changed:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE watchlist
|
||||||
|
SET downloaded = ?, downloaded_sub_episodes_json = ?, downloaded_dub_episodes_json = ?, updated_at = ?
|
||||||
|
WHERE show_id = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
1 if downloaded else 0,
|
||||||
|
encode_episode_values(new_sub_values) if new_sub_values else None,
|
||||||
|
encode_episode_values(new_dub_values) if new_dub_values else None,
|
||||||
|
now_iso(),
|
||||||
|
existing_item["show_id"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
changed += 1
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"show_id": existing_item.get("show_id"),
|
||||||
|
"title": existing_item.get("title"),
|
||||||
|
"media_type": media_type,
|
||||||
|
"changed": row_changed,
|
||||||
|
"downloaded": downloaded,
|
||||||
|
"sub_added": sub_added,
|
||||||
|
"sub_removed": sub_removed,
|
||||||
|
"dub_added": dub_added,
|
||||||
|
"dub_removed": dub_removed,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"message": f"Reconciled downloaded watchlist files for {len(items)} entr{'y' if len(items) == 1 else 'ies'}."
|
||||||
|
+ (f" Updated {changed}." if changed else " No changes were needed."),
|
||||||
|
"total": len(items),
|
||||||
|
"changed": changed,
|
||||||
|
"items": items,
|
||||||
|
}
|
||||||
|
|
||||||
def mark_downloaded(self, show_id, title="", mode="", episodes="", media_type=""):
|
def mark_downloaded(self, show_id, title="", mode="", episodes="", media_type=""):
|
||||||
normalized_show_id = str(show_id or "").strip()
|
normalized_show_id = str(show_id or "").strip()
|
||||||
if not normalized_show_id:
|
if not normalized_show_id:
|
||||||
@@ -2653,6 +2792,12 @@ def get_jellyfin_sync_status():
|
|||||||
return WATCHLIST_JELLYFIN_SYNC.status()
|
return WATCHLIST_JELLYFIN_SYNC.status()
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile_watchlist_downloaded_files(config=None):
|
||||||
|
runtime = ensure_runtime()
|
||||||
|
active_config = normalize_config(config or runtime["config"] or {})
|
||||||
|
return runtime["watchlist"].reconcile_downloaded_filesystem(active_config)
|
||||||
|
|
||||||
|
|
||||||
def update_watchlist_category(show_id, category):
|
def update_watchlist_category(show_id, category):
|
||||||
ensure_runtime()
|
ensure_runtime()
|
||||||
item = WATCHLIST.update_category(show_id, category)
|
item = WATCHLIST.update_category(show_id, category)
|
||||||
@@ -2951,6 +3096,7 @@ Handler = build_handler_class(
|
|||||||
http_error=HttpError,
|
http_error=HttpError,
|
||||||
index_html=INDEX_HTML,
|
index_html=INDEX_HTML,
|
||||||
queue_html=QUEUE_HTML,
|
queue_html=QUEUE_HTML,
|
||||||
|
reconcile_watchlist_downloaded_files=reconcile_watchlist_downloaded_files,
|
||||||
remove_from_watchlist=remove_from_watchlist,
|
remove_from_watchlist=remove_from_watchlist,
|
||||||
run_jellyfin_sync=run_jellyfin_sync,
|
run_jellyfin_sync=run_jellyfin_sync,
|
||||||
runtime_state=runtime_state,
|
runtime_state=runtime_state,
|
||||||
|
|||||||
+46
-1
@@ -612,6 +612,18 @@ CONFIG_HTML = r"""<!doctype html>
|
|||||||
<div class="muted" id="jellyfinSyncStatus">No recent Jellyfin handoff job.</div>
|
<div class="muted" id="jellyfinSyncStatus">No recent Jellyfin handoff job.</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="settings settings-main">
|
||||||
|
<div class="toolbar">
|
||||||
|
<div>
|
||||||
|
<h2>Watchlist filesystem sync</h2>
|
||||||
|
<p class="muted">Manually reconcile downloaded episode flags with the files currently present under the download library.</p>
|
||||||
|
</div>
|
||||||
|
<button id="reconcileWatchlistDownloadsBtn" type="button">Sync now</button>
|
||||||
|
</div>
|
||||||
|
<div class="field-hint">This scans the configured download library, removes downloaded episode flags for files that no longer exist, and adds flags for episodes already present on disk.</div>
|
||||||
|
<div class="muted" id="watchlistReconcileStatus">No watchlist filesystem sync run yet.</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="settings settings-main">
|
<section class="settings settings-main">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div>
|
<div>
|
||||||
@@ -706,7 +718,8 @@ CONFIG_HTML = r"""<!doctype html>
|
|||||||
home_path: ""
|
home_path: ""
|
||||||
},
|
},
|
||||||
jellyfinSyncRunning: false,
|
jellyfinSyncRunning: false,
|
||||||
jellyfinSyncJobId: null
|
jellyfinSyncJobId: null,
|
||||||
|
watchlistReconcileRunning: false
|
||||||
};
|
};
|
||||||
|
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
@@ -743,6 +756,15 @@ CONFIG_HTML = r"""<!doctype html>
|
|||||||
el.textContent = job.message || "Jellyfin handoff idle.";
|
el.textContent = job.message || "Jellyfin handoff idle.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setWatchlistReconcileButton(running) {
|
||||||
|
$("reconcileWatchlistDownloadsBtn").disabled = !!running;
|
||||||
|
$("reconcileWatchlistDownloadsBtn").textContent = running ? "Syncing..." : "Sync now";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setWatchlistReconcileStatus(text) {
|
||||||
|
$("watchlistReconcileStatus").textContent = text || "No watchlist filesystem sync run yet.";
|
||||||
|
}
|
||||||
|
|
||||||
function selectedWebhookEvents() {
|
function selectedWebhookEvents() {
|
||||||
return [...document.querySelectorAll(".webhook-event:checked")].map((input) => input.value);
|
return [...document.querySelectorAll(".webhook-event:checked")].map((input) => input.value);
|
||||||
}
|
}
|
||||||
@@ -862,6 +884,28 @@ CONFIG_HTML = r"""<!doctype html>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function reconcileWatchlistDownloads() {
|
||||||
|
state.watchlistReconcileRunning = true;
|
||||||
|
setWatchlistReconcileButton(true);
|
||||||
|
setWatchlistReconcileStatus("Scanning the download library...");
|
||||||
|
try {
|
||||||
|
const data = await api("/api/config/watchlist/reconcile-downloads", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(formConfigPayload())
|
||||||
|
});
|
||||||
|
const changed = Number(data.changed || 0);
|
||||||
|
const total = Number(data.total || 0);
|
||||||
|
setWatchlistReconcileStatus(`${changed} updated out of ${total} watchlist entries.`);
|
||||||
|
setNotice(data.message || "Watchlist filesystem sync completed.");
|
||||||
|
} catch (error) {
|
||||||
|
setWatchlistReconcileStatus("Watchlist filesystem sync failed.");
|
||||||
|
setNotice(error.message);
|
||||||
|
} finally {
|
||||||
|
state.watchlistReconcileRunning = false;
|
||||||
|
setWatchlistReconcileButton(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function closePathBrowser() {
|
function closePathBrowser() {
|
||||||
state.pathBrowser = {
|
state.pathBrowser = {
|
||||||
targetId: "",
|
targetId: "",
|
||||||
@@ -1008,6 +1052,7 @@ CONFIG_HTML = r"""<!doctype html>
|
|||||||
$("saveConfigBtn").addEventListener("click", saveConfig);
|
$("saveConfigBtn").addEventListener("click", saveConfig);
|
||||||
$("testWebhookBtn").addEventListener("click", testWebhook);
|
$("testWebhookBtn").addEventListener("click", testWebhook);
|
||||||
$("runJellyfinSyncBtn").addEventListener("click", runJellyfinSync);
|
$("runJellyfinSyncBtn").addEventListener("click", runJellyfinSync);
|
||||||
|
$("reconcileWatchlistDownloadsBtn").addEventListener("click", reconcileWatchlistDownloads);
|
||||||
$("openChangelogBtn").addEventListener("click", openChangelog);
|
$("openChangelogBtn").addEventListener("click", openChangelog);
|
||||||
$("closeChangelogBtn").addEventListener("click", closeChangelog);
|
$("closeChangelogBtn").addEventListener("click", closeChangelog);
|
||||||
$("pathBrowserCloseBtn").addEventListener("click", closePathBrowser);
|
$("pathBrowserCloseBtn").addEventListener("click", closePathBrowser);
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ class HandlerContext:
|
|||||||
http_error: object
|
http_error: object
|
||||||
index_html: str
|
index_html: str
|
||||||
queue_html: str
|
queue_html: str
|
||||||
|
reconcile_watchlist_downloaded_files: object
|
||||||
remove_from_watchlist: object
|
remove_from_watchlist: object
|
||||||
run_jellyfin_sync: object
|
run_jellyfin_sync: object
|
||||||
runtime_state: object
|
runtime_state: object
|
||||||
@@ -787,6 +788,14 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if str(merged.get(key) or "").strip():
|
if str(merged.get(key) or "").strip():
|
||||||
Handler.validate_remote_path(self, merged[key], label)
|
Handler.validate_remote_path(self, merged[key], label)
|
||||||
self.json(Handler._context(self).start_jellyfin_sync(merged), HTTPStatus.ACCEPTED)
|
self.json(Handler._context(self).start_jellyfin_sync(merged), HTTPStatus.ACCEPTED)
|
||||||
|
elif parsed.path == "/api/config/watchlist/reconcile-downloads":
|
||||||
|
payload = Handler.require_json_object(self, self.body_json())
|
||||||
|
current = Handler._context(self).get_config_snapshot()
|
||||||
|
merged = dict(current)
|
||||||
|
merged.update(payload)
|
||||||
|
if str(merged.get("download_dir") or "").strip():
|
||||||
|
Handler.validate_remote_path(self, merged["download_dir"], "download directory")
|
||||||
|
self.json(Handler._context(self).reconcile_watchlist_downloaded_files(merged))
|
||||||
elif parsed.path == "/api/config/webhook/test":
|
elif parsed.path == "/api/config/webhook/test":
|
||||||
payload = Handler.require_json_object(self, self.body_json())
|
payload = Handler.require_json_object(self, self.body_json())
|
||||||
validate_discord_webhook_url(payload.get("discord_webhook_url"))
|
validate_discord_webhook_url(payload.get("discord_webhook_url"))
|
||||||
|
|||||||
+121
@@ -2288,6 +2288,103 @@ class JellyfinSyncTests(unittest.TestCase):
|
|||||||
self.assertIn("title mismatch", str(ctx.exception).lower())
|
self.assertIn("title mismatch", str(ctx.exception).lower())
|
||||||
|
|
||||||
|
|
||||||
|
class WatchlistFilesystemReconcileTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
with APP.WATCHLIST._connect() as conn:
|
||||||
|
conn.execute("DELETE FROM watchlist")
|
||||||
|
|
||||||
|
def seed_watchlist_item(self, **overrides):
|
||||||
|
now = APP.now_iso()
|
||||||
|
item = {
|
||||||
|
"show_id": "show-reconcile-1",
|
||||||
|
"title": "Reconcile Show",
|
||||||
|
"category": "watching",
|
||||||
|
"downloaded": True,
|
||||||
|
"status": "updated",
|
||||||
|
"status_message": "Tracked.",
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
"last_checked": now,
|
||||||
|
"sub_count": 12,
|
||||||
|
"dub_count": 12,
|
||||||
|
"expected_count": 12,
|
||||||
|
"airing_status": "Finished",
|
||||||
|
"sub_latest_episode": "12",
|
||||||
|
"dub_latest_episode": "12",
|
||||||
|
"animeschedule_route": None,
|
||||||
|
"animeschedule_title": None,
|
||||||
|
"anidb_aid": None,
|
||||||
|
"anidb_title": None,
|
||||||
|
"media_type": "tv",
|
||||||
|
"auto_download_mode": "dub",
|
||||||
|
"auto_download_quality": "best",
|
||||||
|
"auto_download_name": "Reconcile Show",
|
||||||
|
"auto_download_source_name": "Reconcile Show",
|
||||||
|
"auto_download_series": "2",
|
||||||
|
"auto_download_offset": "13",
|
||||||
|
"downloaded_sub_episodes_json": None,
|
||||||
|
"downloaded_dub_episodes_json": APP.encode_episode_values(["1", "3"]),
|
||||||
|
"thumbnail_path": None,
|
||||||
|
"thumbnail_checked_at": None,
|
||||||
|
}
|
||||||
|
item.update(overrides)
|
||||||
|
with APP.WATCHLIST.lock, APP.WATCHLIST._connect() as conn:
|
||||||
|
APP.WATCHLIST._upsert_conn(conn, item)
|
||||||
|
|
||||||
|
def test_reconcile_watchlist_downloaded_files_updates_episode_sets_from_filesystem(self):
|
||||||
|
self.seed_watchlist_item()
|
||||||
|
with tempfile.TemporaryDirectory() as temp_root:
|
||||||
|
season_dir = Path(temp_root) / "tv" / "Reconcile Show" / "Season 02"
|
||||||
|
season_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(season_dir / "Reconcile Show - S02E13.mp4").write_bytes(b"one")
|
||||||
|
(season_dir / "Reconcile Show - S02E14.mkv").write_bytes(b"two")
|
||||||
|
|
||||||
|
result = APP.reconcile_watchlist_downloaded_files({"download_dir": temp_root, "mode": "sub", "quality": "best"})
|
||||||
|
|
||||||
|
self.assertEqual(result["changed"], 1)
|
||||||
|
item = APP.WATCHLIST.get("show-reconcile-1")
|
||||||
|
self.assertTrue(item["downloaded"])
|
||||||
|
self.assertEqual(item["downloaded_dub_episodes"], ["1", "2"])
|
||||||
|
self.assertEqual(item["downloaded_sub_episodes"], [])
|
||||||
|
|
||||||
|
def test_reconcile_watchlist_downloaded_files_clears_missing_files(self):
|
||||||
|
self.seed_watchlist_item(
|
||||||
|
downloaded_sub_episodes_json=APP.encode_episode_values(["1"]),
|
||||||
|
downloaded_dub_episodes_json=APP.encode_episode_values(["1", "2"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = APP.reconcile_watchlist_downloaded_files({"download_dir": "/tmp/missing-download-root", "mode": "sub", "quality": "best"})
|
||||||
|
|
||||||
|
self.assertEqual(result["changed"], 1)
|
||||||
|
item = APP.WATCHLIST.get("show-reconcile-1")
|
||||||
|
self.assertFalse(item["downloaded"])
|
||||||
|
self.assertEqual(item["downloaded_sub_episodes"], [])
|
||||||
|
self.assertEqual(item["downloaded_dub_episodes"], [])
|
||||||
|
|
||||||
|
def test_reconcile_watchlist_downloaded_files_marks_movie_present_when_file_exists(self):
|
||||||
|
self.seed_watchlist_item(
|
||||||
|
show_id="show-movie-1",
|
||||||
|
title="Movie Reconcile",
|
||||||
|
media_type="movie",
|
||||||
|
downloaded=False,
|
||||||
|
auto_download_name="Movie Reconcile",
|
||||||
|
auto_download_series="1",
|
||||||
|
auto_download_offset=None,
|
||||||
|
downloaded_sub_episodes_json=None,
|
||||||
|
downloaded_dub_episodes_json=None,
|
||||||
|
)
|
||||||
|
with tempfile.TemporaryDirectory() as temp_root:
|
||||||
|
movie_dir = Path(temp_root) / "movie" / "Movie Reconcile"
|
||||||
|
movie_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(movie_dir / "Movie Reconcile.mp4").write_bytes(b"movie")
|
||||||
|
|
||||||
|
result = APP.reconcile_watchlist_downloaded_files({"download_dir": temp_root, "mode": "sub", "quality": "best"})
|
||||||
|
|
||||||
|
self.assertEqual(result["changed"], 1)
|
||||||
|
item = APP.WATCHLIST.get("show-movie-1")
|
||||||
|
self.assertTrue(item["downloaded"])
|
||||||
|
|
||||||
|
|
||||||
class WatchlistQueuedRefreshRecoveryTests(unittest.TestCase):
|
class WatchlistQueuedRefreshRecoveryTests(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
with APP.WATCHLIST._connect() as conn:
|
with APP.WATCHLIST._connect() as conn:
|
||||||
@@ -3641,6 +3738,30 @@ class HandlerRouteTests(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_watchlist_reconcile_post_merges_partial_payload_with_saved_config(self):
|
||||||
|
body = b'{"download_dir":"/tmp/override"}'
|
||||||
|
handler = DummyHandler("/api/config/watchlist/reconcile-downloads", body=body, content_length=len(body))
|
||||||
|
payload = {"message": "ok", "total": 2, "changed": 1, "items": []}
|
||||||
|
handler.handler_context = mock.Mock(
|
||||||
|
get_config_snapshot=mock.Mock(
|
||||||
|
return_value={
|
||||||
|
"download_dir": "/tmp/example",
|
||||||
|
"mode": "sub",
|
||||||
|
"quality": "best",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
reconcile_watchlist_downloaded_files=mock.Mock(return_value=payload),
|
||||||
|
)
|
||||||
|
APP.Handler.do_POST(handler)
|
||||||
|
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
||||||
|
handler.handler_context.reconcile_watchlist_downloaded_files.assert_called_once_with(
|
||||||
|
{
|
||||||
|
"download_dir": "/tmp/override",
|
||||||
|
"mode": "sub",
|
||||||
|
"quality": "best",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
def test_generic_handler_exception_skips_traceback_when_debug_disabled(self):
|
def test_generic_handler_exception_skips_traceback_when_debug_disabled(self):
|
||||||
handler = DummyHandler("/api/config")
|
handler = DummyHandler("/api/config")
|
||||||
with mock.patch.object(http_handler, "debug_enabled", return_value=False), mock.patch.object(
|
with mock.patch.object(http_handler, "debug_enabled", return_value=False), mock.patch.object(
|
||||||
|
|||||||
Reference in New Issue
Block a user