From 3e7146670c1878b6eeb6826cc75e81e88f8e465c Mon Sep 17 00:00:00 2001 From: Jerome Carbel <12296919+SirTerrific@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:35:35 -0400 Subject: [PATCH 1/2] feat: download a backup of the gallery The README tells people to back up before updating without giving them a way to do it, which leaves copying docker volumes by hand as the only option. `GET /api/backup` returns a zip holding the uploads and the database, and settings has a button for it. The database goes through sqlite's own backup API rather than a file copy, so the archive holds a consistent snapshot even if the app is being used while it is taken. The archive is built in a temporary directory that is removed once the response has been sent, whether or not it succeeded. Co-Authored-By: Claude Opus 5 --- app.py | 50 +++++++++++++++++++++++- frontend/app/routes/settings.tsx | 15 +++++++ frontend/app/utils/galleryApi.ts | 4 ++ tests/test_backup.py | 67 ++++++++++++++++++++++++++++++++ 4 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 tests/test_backup.py diff --git a/app.py b/app.py index 806e34d..e9e8268 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,9 @@ import base64 -from flask import Flask, render_template, request, redirect, url_for, flash, send_from_directory, jsonify, Response +import shutil +import sqlite3 +import tempfile +import zipfile +from flask import Flask, after_this_request, render_template, request, redirect, url_for, flash, send_file, send_from_directory, jsonify, Response import os from werkzeug.utils import secure_filename from pathlib import Path @@ -210,6 +214,50 @@ def api_list_images(): return {'images': files} +@app.route('/api/backup', methods=['GET']) +def api_backup(): + """Download a zip of the uploads and the database. + + The README tells people to back up before updating without giving them a way to + do it. The database is copied through sqlite's own backup API so the archive + holds a consistent snapshot even while the app is being used. + """ + stamp = datetime.now().strftime('%Y%m%d-%H%M%S') + tmp_dir = tempfile.mkdtemp(prefix='frametv-backup-') + archive_path = os.path.join(tmp_dir, f'frametv-backup-{stamp}.zip') + + try: + db_snapshot = os.path.join(tmp_dir, 'frametv.db') + source = sqlite3.connect(frametv_db_path) + try: + destination = sqlite3.connect(db_snapshot) + try: + source.backup(destination) + finally: + destination.close() + finally: + source.close() + + upload_folder = app.config['UPLOAD_FOLDER'] + with zipfile.ZipFile(archive_path, 'w', zipfile.ZIP_DEFLATED) as archive: + archive.write(db_snapshot, 'instance/frametv.db') + for filename in sorted(os.listdir(upload_folder)): + full = os.path.join(upload_folder, filename) + if os.path.isfile(full): + archive.write(full, f'uploads/{filename}') + + @after_this_request + def cleanup(response): + shutil.rmtree(tmp_dir, ignore_errors=True) + return response + + return send_file(archive_path, as_attachment=True, download_name=os.path.basename(archive_path)) + except Exception as e: + shutil.rmtree(tmp_dir, ignore_errors=True) + _log_exception('Failed to build the backup archive', e) + return _error_response('Failed to build the backup archive', 500) + + @app.route('/api/images/added_this_month', methods=['GET']) def api_images_added_this_month(): now = datetime.now() diff --git a/frontend/app/routes/settings.tsx b/frontend/app/routes/settings.tsx index 9f5f2f5..03091c1 100644 --- a/frontend/app/routes/settings.tsx +++ b/frontend/app/routes/settings.tsx @@ -4,6 +4,7 @@ import { getTvs, addTv, removeTv, removeAllTvImages, updateTv } from '~/utils/tv import { Input } from '~/components/ui/input'; import { Button } from '~/components/ui/button'; import { getProviders, setProvider, getProvider, deleteProvider } from '~/utils/providerApi'; +import { getBackupUrl } from '~/utils/galleryApi'; import type { ProviderConfig } from '~/utils/providerApi'; @@ -230,6 +231,20 @@ export default function Settings() { )} + {/* Backup */} +
+

Backup

+ + Download a backup + +

+ A zip of your uploads and the database. Take one before updating. +

+
+ {/* Provider Settings */}

External Providers

diff --git a/frontend/app/utils/galleryApi.ts b/frontend/app/utils/galleryApi.ts index 87f1740..285efec 100644 --- a/frontend/app/utils/galleryApi.ts +++ b/frontend/app/utils/galleryApi.ts @@ -25,6 +25,10 @@ export async function fetchImages() { return (await res.json()).images; } +export function getBackupUrl() { + return `${API_BASE}/api/backup`; +} + export async function deleteImage(filename: string) { const res = await fetch(`${API_BASE}/api/images/${encodeURIComponent(filename)}`, { method: 'DELETE', diff --git a/tests/test_backup.py b/tests/test_backup.py new file mode 100644 index 0000000..987d6c2 --- /dev/null +++ b/tests/test_backup.py @@ -0,0 +1,67 @@ +"""Covers the downloadable backup archive. + +Run with: pytest tests/test_backup.py +""" + +import io +import os +import tempfile +import zipfile + +import pytest +from PIL import Image as PILImage + +import app as backend + + +@pytest.fixture +def client(): + backend.app.config["TESTING"] = True + with backend.app.app_context(): + backend.db.drop_all() + backend.db.create_all() + for name in os.listdir(backend.app.config["UPLOAD_FOLDER"]): + path = os.path.join(backend.app.config["UPLOAD_FOLDER"], name) + if os.path.isfile(path): + os.remove(path) + return backend.app.test_client() + + +def upload(client, name): + buf = io.BytesIO() + PILImage.new("RGB", (60, 40), "red").save(buf, format="PNG") + buf.seek(0) + return client.post( + "/api/upload", + data={"file": (buf, name)}, + content_type="multipart/form-data", + ) + + +def test_the_backup_contains_the_database_and_the_uploads(client): + upload(client, "kept.png") + res = client.get("/api/backup") + assert res.status_code == 200 + + with zipfile.ZipFile(io.BytesIO(res.data)) as archive: + names = archive.namelist() + assert "instance/frametv.db" in names + assert "uploads/kept.png" in names + assert archive.read("instance/frametv.db").startswith(b"SQLite format 3") + + +def test_the_backup_is_readable_and_leaves_nothing_behind(client): + """The archive is built in a temp directory that has to be cleaned up after.""" + upload(client, "kept.png") + before = set(os.listdir(tempfile.gettempdir())) + + res = client.get("/api/backup") + assert res.status_code == 200 + with zipfile.ZipFile(io.BytesIO(res.data)) as archive: + assert archive.testzip() is None, "the archive should not be corrupt" + + leftovers = { + name for name in set(os.listdir(tempfile.gettempdir())) - before + if name.startswith("frametv-backup-") + } + assert leftovers == set(), f"temporary files were left behind: {leftovers}" From 2afdf816135b6c21a952575af53d3d56214cef7d Mon Sep 17 00:00:00 2001 From: mrtncode <145050460+mrtncode@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:06:43 +0200 Subject: [PATCH 2/2] Reduce docstring length --- app.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/app.py b/app.py index e9e8268..4aa4ec0 100644 --- a/app.py +++ b/app.py @@ -216,12 +216,7 @@ def api_list_images(): @app.route('/api/backup', methods=['GET']) def api_backup(): - """Download a zip of the uploads and the database. - - The README tells people to back up before updating without giving them a way to - do it. The database is copied through sqlite's own backup API so the archive - holds a consistent snapshot even while the app is being used. - """ + """Download a zip of the uploads and the database.""" stamp = datetime.now().strftime('%Y%m%d-%H%M%S') tmp_dir = tempfile.mkdtemp(prefix='frametv-backup-') archive_path = os.path.join(tmp_dir, f'frametv-backup-{stamp}.zip')