Archived
Add watchlist backup import export
This commit is contained in:
@@ -1,5 +1,11 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.49.6 - 2026-07-21
|
||||||
|
|
||||||
|
- Added Config page watchlist export and import actions for full watchlist database backups.
|
||||||
|
- Backup files include raw `watchlist` rows plus `watchlist_removals`, preserving tracked shows, downloaded state, metadata, auto-download settings, thumbnails, and removed-show history.
|
||||||
|
- Expanded regression coverage for backup export/import behavior and the new HTTP routes.
|
||||||
|
|
||||||
## 0.49.5 - 2026-07-21
|
## 0.49.5 - 2026-07-21
|
||||||
|
|
||||||
- Added a manual Config page action that reconciles watchlist downloaded episode flags against the current filesystem library.
|
- Added a manual Config page action that reconciles watchlist downloaded episode flags against the current filesystem library.
|
||||||
|
|||||||
@@ -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.5`
|
Current version: `0.49.6`
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ Current version: `0.49.5`
|
|||||||
- `/`: 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, manually reconcile watchlist download flags against the filesystem, 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, export/import watchlist backups, manually reconcile watchlist download flags against the filesystem, monitor Jellyfin handoff progress, and view runtime info and the changelog
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
@@ -103,6 +103,7 @@ Docker notes:
|
|||||||
- Successful downloads sync back into the watchlist
|
- Successful downloads sync back into the watchlist
|
||||||
- TV entries move to `Finished` when the selected downloaded mode reaches the expected episode count
|
- TV entries move to `Finished` when the selected downloaded mode reaches the expected episode count
|
||||||
- Movie entries can be treated as complete after download and routed to movie storage
|
- Movie entries can be treated as complete after download and routed to movie storage
|
||||||
|
- Watchlist backups exported from Config include the full watchlist database rows and removed-show history for restore/import
|
||||||
|
|
||||||
### Auto-download
|
### Auto-download
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ DOWNLOAD_QUEUE = None
|
|||||||
WATCHLIST_REFRESH = None
|
WATCHLIST_REFRESH = None
|
||||||
WATCHLIST_AUTO_REFRESH = None
|
WATCHLIST_AUTO_REFRESH = None
|
||||||
WATCHLIST_JELLYFIN_SYNC = None
|
WATCHLIST_JELLYFIN_SYNC = None
|
||||||
|
WATCHLIST_BACKUP_FORMAT_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
def graph_request(query, variables, timeout=25):
|
def graph_request(query, variables, timeout=25):
|
||||||
@@ -1429,6 +1430,9 @@ class WatchlistStore:
|
|||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
def _table_columns_conn(self, conn, table_name):
|
||||||
|
return [str(row["name"]) for row in conn.execute(f"PRAGMA table_info({table_name})").fetchall()]
|
||||||
|
|
||||||
def _exists_conn(self, conn, show_id):
|
def _exists_conn(self, conn, show_id):
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT 1 FROM watchlist WHERE show_id = ? LIMIT 1",
|
"SELECT 1 FROM watchlist WHERE show_id = ? LIMIT 1",
|
||||||
@@ -2234,6 +2238,117 @@ class WatchlistStore:
|
|||||||
"items": items,
|
"items": items,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def export_backup(self):
|
||||||
|
with self.lock, self._connect() as conn:
|
||||||
|
watchlist_columns = self._table_columns_conn(conn, "watchlist")
|
||||||
|
removal_columns = self._table_columns_conn(conn, "watchlist_removals")
|
||||||
|
watchlist_rows = [
|
||||||
|
{column: row[column] for column in watchlist_columns}
|
||||||
|
for row in conn.execute("SELECT * FROM watchlist ORDER BY title COLLATE NOCASE ASC").fetchall()
|
||||||
|
]
|
||||||
|
removal_rows = [
|
||||||
|
{column: row[column] for column in removal_columns}
|
||||||
|
for row in conn.execute("SELECT * FROM watchlist_removals ORDER BY removed_at DESC").fetchall()
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"format": "ani-cli-web-watchlist-backup",
|
||||||
|
"format_version": WATCHLIST_BACKUP_FORMAT_VERSION,
|
||||||
|
"app_version": VERSION,
|
||||||
|
"exported_at": now_iso(),
|
||||||
|
"tables": {
|
||||||
|
"watchlist": {
|
||||||
|
"columns": watchlist_columns,
|
||||||
|
"rows": watchlist_rows,
|
||||||
|
},
|
||||||
|
"watchlist_removals": {
|
||||||
|
"columns": removal_columns,
|
||||||
|
"rows": removal_rows,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def import_backup(self, backup):
|
||||||
|
if not isinstance(backup, dict):
|
||||||
|
raise ValueError("Watchlist backup must be a JSON object.")
|
||||||
|
if backup.get("format") != "ani-cli-web-watchlist-backup":
|
||||||
|
raise ValueError("Backup format is not recognized.")
|
||||||
|
try:
|
||||||
|
format_version = int(backup.get("format_version") or 0)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError("Backup format version is invalid.") from exc
|
||||||
|
if format_version < 1 or format_version > WATCHLIST_BACKUP_FORMAT_VERSION:
|
||||||
|
raise ValueError("Backup format version is not supported by this app version.")
|
||||||
|
tables = backup.get("tables")
|
||||||
|
if not isinstance(tables, dict):
|
||||||
|
raise ValueError("Backup is missing table data.")
|
||||||
|
watchlist_rows = ((tables.get("watchlist") or {}).get("rows") if isinstance(tables.get("watchlist"), dict) else None)
|
||||||
|
removal_rows = (
|
||||||
|
(tables.get("watchlist_removals") or {}).get("rows")
|
||||||
|
if isinstance(tables.get("watchlist_removals"), dict)
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
if not isinstance(watchlist_rows, list):
|
||||||
|
raise ValueError("Backup is missing watchlist rows.")
|
||||||
|
if not isinstance(removal_rows, list):
|
||||||
|
raise ValueError("Backup removal rows must be an array.")
|
||||||
|
now = now_iso()
|
||||||
|
with self.lock, self._connect() as conn:
|
||||||
|
watchlist_columns = self._table_columns_conn(conn, "watchlist")
|
||||||
|
removal_columns = self._table_columns_conn(conn, "watchlist_removals")
|
||||||
|
conn.execute("DELETE FROM watchlist")
|
||||||
|
conn.execute("DELETE FROM watchlist_removals")
|
||||||
|
imported_watchlist = 0
|
||||||
|
for row in watchlist_rows:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
raise ValueError("Backup watchlist rows must be JSON objects.")
|
||||||
|
show_id = str(row.get("show_id") or "").strip()
|
||||||
|
title = str(row.get("title") or "").strip()
|
||||||
|
if not show_id or not title:
|
||||||
|
raise ValueError("Backup watchlist rows must include show_id and title.")
|
||||||
|
values = {column: row.get(column) for column in watchlist_columns}
|
||||||
|
values["show_id"] = show_id
|
||||||
|
values["title"] = title
|
||||||
|
values["category"] = normalize_watchlist_category(values.get("category"))
|
||||||
|
values["downloaded"] = 1 if values.get("downloaded") else 0
|
||||||
|
values["status"] = str(values.get("status") or "tracked")
|
||||||
|
values["status_message"] = str(values.get("status_message") or "Imported from backup.")
|
||||||
|
values["created_at"] = str(values.get("created_at") or now)
|
||||||
|
values["updated_at"] = str(values.get("updated_at") or now)
|
||||||
|
values["sub_count"] = int(values.get("sub_count") or 0)
|
||||||
|
values["dub_count"] = int(values.get("dub_count") or 0)
|
||||||
|
values["expected_count"] = int(values.get("expected_count") or 0)
|
||||||
|
values["media_type"] = normalize_media_type(values.get("media_type"))
|
||||||
|
placeholders = ", ".join("?" for _column in watchlist_columns)
|
||||||
|
column_sql = ", ".join(watchlist_columns)
|
||||||
|
conn.execute(
|
||||||
|
f"INSERT INTO watchlist ({column_sql}) VALUES ({placeholders})",
|
||||||
|
[values.get(column) for column in watchlist_columns],
|
||||||
|
)
|
||||||
|
imported_watchlist += 1
|
||||||
|
imported_removals = 0
|
||||||
|
for row in removal_rows:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
raise ValueError("Backup removal rows must be JSON objects.")
|
||||||
|
show_id = str(row.get("show_id") or "").strip()
|
||||||
|
if not show_id:
|
||||||
|
continue
|
||||||
|
values = {column: row.get(column) for column in removal_columns}
|
||||||
|
values["show_id"] = show_id
|
||||||
|
values["title"] = str(values.get("title") or "").strip()
|
||||||
|
values["removed_at"] = str(values.get("removed_at") or now)
|
||||||
|
placeholders = ", ".join("?" for _column in removal_columns)
|
||||||
|
column_sql = ", ".join(removal_columns)
|
||||||
|
conn.execute(
|
||||||
|
f"INSERT INTO watchlist_removals ({column_sql}) VALUES ({placeholders})",
|
||||||
|
[values.get(column) for column in removal_columns],
|
||||||
|
)
|
||||||
|
imported_removals += 1
|
||||||
|
return {
|
||||||
|
"message": f"Imported {imported_watchlist} watchlist entr{'y' if imported_watchlist == 1 else 'ies'} from backup.",
|
||||||
|
"imported": imported_watchlist,
|
||||||
|
"removals": imported_removals,
|
||||||
|
}
|
||||||
|
|
||||||
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:
|
||||||
@@ -2792,6 +2907,16 @@ def get_jellyfin_sync_status():
|
|||||||
return WATCHLIST_JELLYFIN_SYNC.status()
|
return WATCHLIST_JELLYFIN_SYNC.status()
|
||||||
|
|
||||||
|
|
||||||
|
def export_watchlist_backup():
|
||||||
|
runtime = ensure_runtime()
|
||||||
|
return runtime["watchlist"].export_backup()
|
||||||
|
|
||||||
|
|
||||||
|
def import_watchlist_backup(payload):
|
||||||
|
runtime = ensure_runtime()
|
||||||
|
return runtime["watchlist"].import_backup(payload)
|
||||||
|
|
||||||
|
|
||||||
def reconcile_watchlist_downloaded_files(config=None):
|
def reconcile_watchlist_downloaded_files(config=None):
|
||||||
runtime = ensure_runtime()
|
runtime = ensure_runtime()
|
||||||
active_config = normalize_config(config or runtime["config"] or {})
|
active_config = normalize_config(config or runtime["config"] or {})
|
||||||
@@ -3088,6 +3213,7 @@ Handler = build_handler_class(
|
|||||||
download_watchlist_item=download_watchlist_item,
|
download_watchlist_item=download_watchlist_item,
|
||||||
ensure_runtime=ensure_runtime,
|
ensure_runtime=ensure_runtime,
|
||||||
episode_list=episode_list,
|
episode_list=episode_list,
|
||||||
|
export_watchlist_backup=export_watchlist_backup,
|
||||||
get_config_snapshot=get_config_snapshot,
|
get_config_snapshot=get_config_snapshot,
|
||||||
get_watchlist_homepage_summary=get_watchlist_homepage_summary,
|
get_watchlist_homepage_summary=get_watchlist_homepage_summary,
|
||||||
get_jellyfin_sync_status=get_jellyfin_sync_status,
|
get_jellyfin_sync_status=get_jellyfin_sync_status,
|
||||||
@@ -3095,6 +3221,7 @@ Handler = build_handler_class(
|
|||||||
get_watchlist_refresh_status=get_watchlist_refresh_status,
|
get_watchlist_refresh_status=get_watchlist_refresh_status,
|
||||||
http_error=HttpError,
|
http_error=HttpError,
|
||||||
index_html=INDEX_HTML,
|
index_html=INDEX_HTML,
|
||||||
|
import_watchlist_backup=import_watchlist_backup,
|
||||||
queue_html=QUEUE_HTML,
|
queue_html=QUEUE_HTML,
|
||||||
reconcile_watchlist_downloaded_files=reconcile_watchlist_downloaded_files,
|
reconcile_watchlist_downloaded_files=reconcile_watchlist_downloaded_files,
|
||||||
remove_from_watchlist=remove_from_watchlist,
|
remove_from_watchlist=remove_from_watchlist,
|
||||||
|
|||||||
+59
-1
@@ -624,6 +624,22 @@ CONFIG_HTML = r"""<!doctype html>
|
|||||||
<div class="muted" id="watchlistReconcileStatus">No watchlist filesystem sync run yet.</div>
|
<div class="muted" id="watchlistReconcileStatus">No watchlist filesystem sync run yet.</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="settings settings-main">
|
||||||
|
<div class="toolbar">
|
||||||
|
<div>
|
||||||
|
<h2>Watchlist backup</h2>
|
||||||
|
<p class="muted">Export or restore a complete watchlist database snapshot, including removed-show records.</p>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<button id="exportWatchlistBtn" type="button">Export</button>
|
||||||
|
<button id="importWatchlistBtn" type="button">Import</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input id="importWatchlistFile" type="file" accept="application/json,.json" hidden>
|
||||||
|
<div class="field-hint">Import replaces the current watchlist with the selected backup file.</div>
|
||||||
|
<div class="muted" id="watchlistBackupStatus">No watchlist backup action run yet.</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="settings settings-main">
|
<section class="settings settings-main">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div>
|
<div>
|
||||||
@@ -719,7 +735,8 @@ CONFIG_HTML = r"""<!doctype html>
|
|||||||
},
|
},
|
||||||
jellyfinSyncRunning: false,
|
jellyfinSyncRunning: false,
|
||||||
jellyfinSyncJobId: null,
|
jellyfinSyncJobId: null,
|
||||||
watchlistReconcileRunning: false
|
watchlistReconcileRunning: false,
|
||||||
|
watchlistImportRunning: false
|
||||||
};
|
};
|
||||||
|
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
@@ -765,6 +782,15 @@ CONFIG_HTML = r"""<!doctype html>
|
|||||||
$("watchlistReconcileStatus").textContent = text || "No watchlist filesystem sync run yet.";
|
$("watchlistReconcileStatus").textContent = text || "No watchlist filesystem sync run yet.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setWatchlistBackupStatus(text) {
|
||||||
|
$("watchlistBackupStatus").textContent = text || "No watchlist backup action run yet.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setWatchlistImportButton(running) {
|
||||||
|
$("importWatchlistBtn").disabled = !!running;
|
||||||
|
$("importWatchlistBtn").textContent = running ? "Importing..." : "Import";
|
||||||
|
}
|
||||||
|
|
||||||
function selectedWebhookEvents() {
|
function selectedWebhookEvents() {
|
||||||
return [...document.querySelectorAll(".webhook-event:checked")].map((input) => input.value);
|
return [...document.querySelectorAll(".webhook-event:checked")].map((input) => input.value);
|
||||||
}
|
}
|
||||||
@@ -906,6 +932,35 @@ CONFIG_HTML = r"""<!doctype html>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function exportWatchlistBackup() {
|
||||||
|
setWatchlistBackupStatus("Preparing watchlist backup download...");
|
||||||
|
window.location.href = "/api/config/watchlist/export";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importWatchlistBackup(file) {
|
||||||
|
if (!file) return;
|
||||||
|
state.watchlistImportRunning = true;
|
||||||
|
setWatchlistImportButton(true);
|
||||||
|
setWatchlistBackupStatus("Reading backup file...");
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(await file.text());
|
||||||
|
setWatchlistBackupStatus("Importing backup...");
|
||||||
|
const data = await api("/api/config/watchlist/import", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
setWatchlistBackupStatus(`${data.imported || 0} watchlist entries imported.`);
|
||||||
|
setNotice(data.message || "Watchlist backup imported.");
|
||||||
|
} catch (error) {
|
||||||
|
setWatchlistBackupStatus("Watchlist backup import failed.");
|
||||||
|
setNotice(error.message);
|
||||||
|
} finally {
|
||||||
|
$("importWatchlistFile").value = "";
|
||||||
|
state.watchlistImportRunning = false;
|
||||||
|
setWatchlistImportButton(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function closePathBrowser() {
|
function closePathBrowser() {
|
||||||
state.pathBrowser = {
|
state.pathBrowser = {
|
||||||
targetId: "",
|
targetId: "",
|
||||||
@@ -1053,6 +1108,9 @@ CONFIG_HTML = r"""<!doctype html>
|
|||||||
$("testWebhookBtn").addEventListener("click", testWebhook);
|
$("testWebhookBtn").addEventListener("click", testWebhook);
|
||||||
$("runJellyfinSyncBtn").addEventListener("click", runJellyfinSync);
|
$("runJellyfinSyncBtn").addEventListener("click", runJellyfinSync);
|
||||||
$("reconcileWatchlistDownloadsBtn").addEventListener("click", reconcileWatchlistDownloads);
|
$("reconcileWatchlistDownloadsBtn").addEventListener("click", reconcileWatchlistDownloads);
|
||||||
|
$("exportWatchlistBtn").addEventListener("click", exportWatchlistBackup);
|
||||||
|
$("importWatchlistBtn").addEventListener("click", () => $("importWatchlistFile").click());
|
||||||
|
$("importWatchlistFile").addEventListener("change", (event) => importWatchlistBackup((event.target.files || [])[0]));
|
||||||
$("openChangelogBtn").addEventListener("click", openChangelog);
|
$("openChangelogBtn").addEventListener("click", openChangelog);
|
||||||
$("closeChangelogBtn").addEventListener("click", closeChangelog);
|
$("closeChangelogBtn").addEventListener("click", closeChangelog);
|
||||||
$("pathBrowserCloseBtn").addEventListener("click", closePathBrowser);
|
$("pathBrowserCloseBtn").addEventListener("click", closePathBrowser);
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ class HandlerContext:
|
|||||||
download_watchlist_item: object
|
download_watchlist_item: object
|
||||||
ensure_runtime: object
|
ensure_runtime: object
|
||||||
episode_list: object
|
episode_list: object
|
||||||
|
export_watchlist_backup: object
|
||||||
get_config_snapshot: object
|
get_config_snapshot: object
|
||||||
get_jellyfin_sync_status: object
|
get_jellyfin_sync_status: object
|
||||||
get_watchlist_homepage_summary: object
|
get_watchlist_homepage_summary: object
|
||||||
@@ -65,6 +66,7 @@ class HandlerContext:
|
|||||||
get_watchlist_refresh_status: object
|
get_watchlist_refresh_status: object
|
||||||
http_error: object
|
http_error: object
|
||||||
index_html: str
|
index_html: str
|
||||||
|
import_watchlist_backup: object
|
||||||
queue_html: str
|
queue_html: str
|
||||||
reconcile_watchlist_downloaded_files: object
|
reconcile_watchlist_downloaded_files: object
|
||||||
remove_from_watchlist: object
|
remove_from_watchlist: object
|
||||||
@@ -747,6 +749,12 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
elif parsed.path == "/api/config/jellyfin/sync-status":
|
elif parsed.path == "/api/config/jellyfin/sync-status":
|
||||||
Handler._runtime(self)
|
Handler._runtime(self)
|
||||||
self.json(Handler._context(self).get_jellyfin_sync_status())
|
self.json(Handler._context(self).get_jellyfin_sync_status())
|
||||||
|
elif parsed.path == "/api/config/watchlist/export":
|
||||||
|
Handler._runtime(self)
|
||||||
|
self.json_attachment(
|
||||||
|
Handler._context(self).export_watchlist_backup(),
|
||||||
|
"ani-cli-web-watchlist-backup.json",
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
self.error(HTTPStatus.NOT_FOUND, "Not found")
|
self.error(HTTPStatus.NOT_FOUND, "Not found")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -796,6 +804,9 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if str(merged.get("download_dir") or "").strip():
|
if str(merged.get("download_dir") or "").strip():
|
||||||
Handler.validate_remote_path(self, merged["download_dir"], "download directory")
|
Handler.validate_remote_path(self, merged["download_dir"], "download directory")
|
||||||
self.json(Handler._context(self).reconcile_watchlist_downloaded_files(merged))
|
self.json(Handler._context(self).reconcile_watchlist_downloaded_files(merged))
|
||||||
|
elif parsed.path == "/api/config/watchlist/import":
|
||||||
|
payload = Handler.require_json_object(self, self.body_json())
|
||||||
|
self.json(Handler._context(self).import_watchlist_backup(payload))
|
||||||
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"))
|
||||||
@@ -1014,6 +1025,19 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
data = json.dumps(payload).encode("utf-8")
|
data = json.dumps(payload).encode("utf-8")
|
||||||
self.write_response_bytes(data, status, {"Content-Type": "application/json"})
|
self.write_response_bytes(data, status, {"Content-Type": "application/json"})
|
||||||
|
|
||||||
|
def json_attachment(self, payload, filename, status=HTTPStatus.OK):
|
||||||
|
data = json.dumps(payload, indent=2, sort_keys=True).encode("utf-8")
|
||||||
|
safe_filename = str(filename or "watchlist-backup.json").replace('"', "")
|
||||||
|
self.write_response_bytes(
|
||||||
|
data,
|
||||||
|
status,
|
||||||
|
{
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Content-Disposition": f'attachment; filename="{safe_filename}"',
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def write_response_bytes(self, payload, status, headers):
|
def write_response_bytes(self, payload, status, headers):
|
||||||
try:
|
try:
|
||||||
self.send_response(status)
|
self.send_response(status)
|
||||||
|
|||||||
+121
@@ -72,6 +72,12 @@ class DummyHandler:
|
|||||||
self.json_payload = payload
|
self.json_payload = payload
|
||||||
self.json_status = status
|
self.json_status = status
|
||||||
|
|
||||||
|
def json_attachment(self, payload, filename, status=HTTPStatus.OK):
|
||||||
|
return APP.Handler.json_attachment(self, payload, filename, status=status)
|
||||||
|
|
||||||
|
def write_response_bytes(self, payload, status, headers):
|
||||||
|
return APP.Handler.write_response_bytes(self, payload, status, headers)
|
||||||
|
|
||||||
def error(self, status, message):
|
def error(self, status, message):
|
||||||
self.error_status = status
|
self.error_status = status
|
||||||
self.error_message = message
|
self.error_message = message
|
||||||
@@ -2385,6 +2391,92 @@ class WatchlistFilesystemReconcileTests(unittest.TestCase):
|
|||||||
self.assertTrue(item["downloaded"])
|
self.assertTrue(item["downloaded"])
|
||||||
|
|
||||||
|
|
||||||
|
class WatchlistBackupTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
with APP.WATCHLIST._connect() as conn:
|
||||||
|
conn.execute("DELETE FROM watchlist")
|
||||||
|
conn.execute("DELETE FROM watchlist_removals")
|
||||||
|
|
||||||
|
def seed_watchlist_item(self, **overrides):
|
||||||
|
now = APP.now_iso()
|
||||||
|
item = {
|
||||||
|
"show_id": "show-backup-1",
|
||||||
|
"title": "Backup Show",
|
||||||
|
"category": "finished",
|
||||||
|
"downloaded": True,
|
||||||
|
"status": "updated",
|
||||||
|
"status_message": "Ready.",
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
"last_checked": now,
|
||||||
|
"sub_count": 2,
|
||||||
|
"dub_count": 2,
|
||||||
|
"expected_count": 2,
|
||||||
|
"airing_status": "Finished",
|
||||||
|
"sub_latest_episode": "2",
|
||||||
|
"dub_latest_episode": "2",
|
||||||
|
"animeschedule_route": "backup-route",
|
||||||
|
"animeschedule_title": "Backup Schedule",
|
||||||
|
"anidb_aid": "123",
|
||||||
|
"anidb_title": "Backup AniDB",
|
||||||
|
"media_type": "tv",
|
||||||
|
"auto_download_mode": "dub",
|
||||||
|
"auto_download_quality": "best",
|
||||||
|
"auto_download_name": "Backup Show",
|
||||||
|
"auto_download_source_name": "Backup Search",
|
||||||
|
"auto_download_series": "1",
|
||||||
|
"auto_download_offset": None,
|
||||||
|
"downloaded_sub_episodes_json": APP.encode_episode_values(["1"]),
|
||||||
|
"downloaded_dub_episodes_json": APP.encode_episode_values(["1", "2"]),
|
||||||
|
"thumbnail_path": "show-backup-1.jpg",
|
||||||
|
"thumbnail_checked_at": now,
|
||||||
|
}
|
||||||
|
item.update(overrides)
|
||||||
|
with APP.WATCHLIST.lock, APP.WATCHLIST._connect() as conn:
|
||||||
|
APP.WATCHLIST._upsert_conn(conn, item)
|
||||||
|
|
||||||
|
def test_export_watchlist_backup_includes_raw_watchlist_and_removal_rows(self):
|
||||||
|
self.seed_watchlist_item()
|
||||||
|
with APP.WATCHLIST._connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO watchlist_removals (show_id, title, removed_at) VALUES (?, ?, ?)",
|
||||||
|
("removed-show", "Removed Show", APP.now_iso()),
|
||||||
|
)
|
||||||
|
|
||||||
|
backup = APP.export_watchlist_backup()
|
||||||
|
|
||||||
|
self.assertEqual(backup["format"], "ani-cli-web-watchlist-backup")
|
||||||
|
self.assertEqual(backup["format_version"], 1)
|
||||||
|
self.assertEqual(backup["tables"]["watchlist"]["rows"][0]["show_id"], "show-backup-1")
|
||||||
|
self.assertEqual(backup["tables"]["watchlist"]["rows"][0]["downloaded_dub_episodes_json"], APP.encode_episode_values(["1", "2"]))
|
||||||
|
self.assertEqual(backup["tables"]["watchlist_removals"]["rows"][0]["show_id"], "removed-show")
|
||||||
|
|
||||||
|
def test_import_watchlist_backup_replaces_existing_watchlist_tables(self):
|
||||||
|
self.seed_watchlist_item(show_id="old-show", title="Old Show")
|
||||||
|
backup = APP.export_watchlist_backup()
|
||||||
|
backup["tables"]["watchlist"]["rows"][0]["show_id"] = "new-show"
|
||||||
|
backup["tables"]["watchlist"]["rows"][0]["title"] = "New Show"
|
||||||
|
backup["tables"]["watchlist_removals"]["rows"] = [
|
||||||
|
{"show_id": "removed-new", "title": "Removed New", "removed_at": APP.now_iso()}
|
||||||
|
]
|
||||||
|
|
||||||
|
result = APP.import_watchlist_backup(backup)
|
||||||
|
|
||||||
|
self.assertEqual(result["imported"], 1)
|
||||||
|
with self.assertRaises(KeyError):
|
||||||
|
APP.WATCHLIST.get("old-show")
|
||||||
|
restored = APP.WATCHLIST.get("new-show")
|
||||||
|
self.assertEqual(restored["title"], "New Show")
|
||||||
|
self.assertEqual(restored["downloaded_dub_episodes"], ["1", "2"])
|
||||||
|
with APP.WATCHLIST._connect() as conn:
|
||||||
|
removal = conn.execute("SELECT title FROM watchlist_removals WHERE show_id = ?", ("removed-new",)).fetchone()
|
||||||
|
self.assertEqual(removal["title"], "Removed New")
|
||||||
|
|
||||||
|
def test_import_watchlist_backup_rejects_unrecognized_format(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
APP.import_watchlist_backup({"format": "not-this-app", "format_version": 1, "tables": {}})
|
||||||
|
|
||||||
|
|
||||||
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:
|
||||||
@@ -3762,6 +3854,35 @@ class HandlerRouteTests(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_watchlist_export_route_returns_json_attachment(self):
|
||||||
|
handler = DummyHandler("/api/config/watchlist/export")
|
||||||
|
payload = {"format": "ani-cli-web-watchlist-backup", "tables": {"watchlist": {"rows": []}}}
|
||||||
|
handler.handler_context = mock.Mock(
|
||||||
|
ensure_runtime=mock.Mock(),
|
||||||
|
runtime_state=mock.Mock(return_value={}),
|
||||||
|
export_watchlist_backup=mock.Mock(return_value=payload),
|
||||||
|
)
|
||||||
|
|
||||||
|
APP.Handler.do_GET(handler)
|
||||||
|
|
||||||
|
self.assertEqual(handler.response_status, HTTPStatus.OK)
|
||||||
|
headers = dict(handler.response_headers)
|
||||||
|
self.assertEqual(headers.get("Content-Type"), "application/json")
|
||||||
|
self.assertIn("attachment", headers.get("Content-Disposition", ""))
|
||||||
|
self.assertEqual(json.loads(handler.wfile.getvalue().decode("utf-8")), payload)
|
||||||
|
|
||||||
|
def test_watchlist_import_route_returns_import_summary(self):
|
||||||
|
body = b'{"format":"ani-cli-web-watchlist-backup","format_version":1,"tables":{"watchlist":{"rows":[]}}}'
|
||||||
|
handler = DummyHandler("/api/config/watchlist/import", body=body, content_length=len(body))
|
||||||
|
payload = {"message": "Imported 0 watchlist entries from backup.", "imported": 0, "removals": 0}
|
||||||
|
handler.handler_context = mock.Mock(import_watchlist_backup=mock.Mock(return_value=payload))
|
||||||
|
|
||||||
|
APP.Handler.do_POST(handler)
|
||||||
|
|
||||||
|
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
||||||
|
self.assertEqual(handler.json_payload, payload)
|
||||||
|
handler.handler_context.import_watchlist_backup.assert_called_once()
|
||||||
|
|
||||||
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