Archived
Add watchlist backup import export
This commit is contained in:
@@ -93,6 +93,7 @@ DOWNLOAD_QUEUE = None
|
||||
WATCHLIST_REFRESH = None
|
||||
WATCHLIST_AUTO_REFRESH = None
|
||||
WATCHLIST_JELLYFIN_SYNC = None
|
||||
WATCHLIST_BACKUP_FORMAT_VERSION = 1
|
||||
|
||||
|
||||
def graph_request(query, variables, timeout=25):
|
||||
@@ -1429,6 +1430,9 @@ class WatchlistStore:
|
||||
conn.row_factory = sqlite3.Row
|
||||
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):
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM watchlist WHERE show_id = ? LIMIT 1",
|
||||
@@ -2234,6 +2238,117 @@ class WatchlistStore:
|
||||
"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=""):
|
||||
normalized_show_id = str(show_id or "").strip()
|
||||
if not normalized_show_id:
|
||||
@@ -2792,6 +2907,16 @@ def get_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):
|
||||
runtime = ensure_runtime()
|
||||
active_config = normalize_config(config or runtime["config"] or {})
|
||||
@@ -3088,6 +3213,7 @@ Handler = build_handler_class(
|
||||
download_watchlist_item=download_watchlist_item,
|
||||
ensure_runtime=ensure_runtime,
|
||||
episode_list=episode_list,
|
||||
export_watchlist_backup=export_watchlist_backup,
|
||||
get_config_snapshot=get_config_snapshot,
|
||||
get_watchlist_homepage_summary=get_watchlist_homepage_summary,
|
||||
get_jellyfin_sync_status=get_jellyfin_sync_status,
|
||||
@@ -3095,6 +3221,7 @@ Handler = build_handler_class(
|
||||
get_watchlist_refresh_status=get_watchlist_refresh_status,
|
||||
http_error=HttpError,
|
||||
index_html=INDEX_HTML,
|
||||
import_watchlist_backup=import_watchlist_backup,
|
||||
queue_html=QUEUE_HTML,
|
||||
reconcile_watchlist_downloaded_files=reconcile_watchlist_downloaded_files,
|
||||
remove_from_watchlist=remove_from_watchlist,
|
||||
|
||||
Reference in New Issue
Block a user