dotfiles/scripts/photoprism-recover-albums.py
2026-09-19 01:35:48 +02:00

480 lines
17 KiB
Python
Executable file

#!/usr/bin/env python3
"""Recover PhotoPrism manual albums and private flags by original file hashes."""
from __future__ import annotations
import argparse
import datetime as dt
import os
import sqlite3
import subprocess
import sys
from collections import defaultdict
from pathlib import Path
from typing import Any
DEFAULT_OLD_DB = Path("/media/large/server-backup/photoprism/index.db")
DEFAULT_CURRENT_DB = Path("/var/lib/photoprism/index.db")
REQUIRED_TABLES = {"albums", "files", "photos", "photos_albums", "versions"}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Recover manual PhotoPrism albums and private photo flags after reimporting "
"files at new paths. Files are matched by SHA-1 content hash, never by path "
"or PhotoPrism UID. The default mode is read-only."
)
)
parser.add_argument("--old-db", type=Path, default=DEFAULT_OLD_DB)
parser.add_argument("--current-db", type=Path, default=DEFAULT_CURRENT_DB)
parser.add_argument(
"--apply",
action="store_true",
help="write the recovered albums and memberships; PhotoPrism must be stopped",
)
parser.add_argument(
"--allow-partial",
action="store_true",
help="apply even when some old album memberships cannot be mapped",
)
parser.add_argument(
"--backup",
type=Path,
help="backup destination used before --apply (default: beside the current database)",
)
parser.add_argument("--service", default="photoprism.service")
return parser.parse_args()
def fail(message: str) -> None:
raise SystemExit(f"error: {message}")
def open_database(path: Path, *, writable: bool) -> sqlite3.Connection:
if not path.is_file():
fail(f"database does not exist: {path}")
mode = "rw" if writable else "ro"
try:
connection = sqlite3.connect(f"file:{path.resolve()}?mode={mode}", uri=True)
except sqlite3.Error as error:
fail(f"cannot open {path}: {error}")
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA busy_timeout = 5000")
return connection
def table_names(connection: sqlite3.Connection) -> set[str]:
return {
row[0]
for row in connection.execute(
"SELECT name FROM sqlite_master WHERE type = 'table'"
)
}
def columns(connection: sqlite3.Connection, table: str) -> list[str]:
return [row[1] for row in connection.execute(f'PRAGMA table_info("{table}")')]
def version(connection: sqlite3.Connection) -> str:
row = connection.execute(
"SELECT version FROM versions ORDER BY id DESC LIMIT 1"
).fetchone()
return str(row[0]) if row else "unknown"
def check_database(connection: sqlite3.Connection, label: str) -> None:
missing = REQUIRED_TABLES - table_names(connection)
if missing:
fail(f"{label} database is missing tables: {', '.join(sorted(missing))}")
result = connection.execute("PRAGMA quick_check").fetchone()[0]
if result != "ok":
fail(f"{label} database failed quick_check: {result}")
def require_compatible_schema(
old: sqlite3.Connection, current: sqlite3.Connection
) -> list[str]:
old_columns = columns(old, "albums")
current_columns = columns(current, "albums")
if set(old_columns) != set(current_columns):
missing_current = sorted(set(old_columns) - set(current_columns))
only_current = sorted(set(current_columns) - set(old_columns))
fail(
"album schemas have different columns; refusing a direct migration\n"
f" missing from current: {', '.join(missing_current) or 'none'}\n"
f" only in current: {', '.join(only_current) or 'none'}"
)
membership_columns = columns(old, "photos_albums")
if set(membership_columns) != set(columns(current, "photos_albums")):
fail("photos_albums schemas have different columns; refusing a direct migration")
required_photo_columns = {"photo_uid", "photo_private", "deleted_at", "updated_at"}
for label, connection in (("backup", old), ("current", current)):
missing = required_photo_columns - set(columns(connection, "photos"))
if missing:
fail(
f"{label} photos table is missing columns: "
+ ", ".join(sorted(missing))
)
return [name for name in old_columns if name != "id"]
def service_state(service: str) -> str:
result = subprocess.run(
["systemctl", "is-active", service],
text=True,
capture_output=True,
check=False,
)
return result.stdout.strip() or "unknown"
def load_photo_map(
old: sqlite3.Connection, current: sqlite3.Connection
) -> tuple[dict[str, str], set[str]]:
current_hashes: dict[str, list[tuple[str, str]]] = defaultdict(list)
for row in current.execute(
"""
SELECT file_hash, file_name, photo_uid
FROM files
WHERE file_root = '/'
AND file_hash IS NOT NULL AND file_hash <> ''
AND photo_uid IS NOT NULL AND photo_uid <> ''
AND COALESCE(file_missing, 0) = 0
AND deleted_at IS NULL
"""
):
current_hashes[str(row["file_hash"])].append(
(str(row["photo_uid"]), Path(str(row["file_name"])).name)
)
candidates: dict[str, set[str]] = defaultdict(set)
for row in old.execute(
"""
SELECT file_hash, file_name, photo_uid
FROM files
WHERE file_root = '/'
AND file_hash IS NOT NULL AND file_hash <> ''
AND photo_uid IS NOT NULL AND photo_uid <> ''
AND COALESCE(file_missing, 0) = 0
AND deleted_at IS NULL
"""
):
matches = current_hashes.get(str(row["file_hash"]), ())
hash_uids = {uid for uid, _ in matches}
if len(hash_uids) > 1:
old_name = Path(str(row["file_name"])).name
name_uids = {uid for uid, name in matches if name == old_name}
if len(name_uids) == 1:
hash_uids = name_uids
candidates[str(row["photo_uid"])].update(hash_uids)
mapped = {
old_uid: next(iter(current_uids))
for old_uid, current_uids in candidates.items()
if len(current_uids) == 1
}
ambiguous = {
old_uid for old_uid, current_uids in candidates.items() if len(current_uids) > 1
}
return mapped, ambiguous
def recovery_plan(
old: sqlite3.Connection, current: sqlite3.Connection
) -> dict[str, Any]:
albums = list(
old.execute(
"""
SELECT *
FROM albums
WHERE album_type = 'album' AND deleted_at IS NULL
ORDER BY album_title, album_uid
"""
)
)
memberships = list(
old.execute(
"""
SELECT pa.*
FROM photos_albums AS pa
JOIN albums AS a ON a.album_uid = pa.album_uid
WHERE a.album_type = 'album' AND a.deleted_at IS NULL
"""
)
)
photo_map, ambiguous_photos = load_photo_map(old, current)
mapped_memberships = []
missing_by_album: dict[str, int] = defaultdict(int)
ambiguous_by_album: dict[str, int] = defaultdict(int)
for membership in memberships:
old_photo_uid = str(membership["photo_uid"])
if old_photo_uid in photo_map:
values = dict(membership)
values["photo_uid"] = photo_map[old_photo_uid]
mapped_memberships.append(values)
elif old_photo_uid in ambiguous_photos:
ambiguous_by_album[str(membership["album_uid"])] += 1
else:
missing_by_album[str(membership["album_uid"])] += 1
old_private_uids = {
str(row["photo_uid"])
for row in old.execute(
"""
SELECT photo_uid
FROM photos
WHERE photo_private = 1 AND deleted_at IS NULL
"""
)
}
mapped_private_old = {
old_uid for old_uid in old_private_uids if old_uid in photo_map
}
mapped_private_uids = {photo_map[old_uid] for old_uid in mapped_private_old}
ambiguous_private = len(old_private_uids & ambiguous_photos)
missing_private = len(old_private_uids - mapped_private_old - ambiguous_photos)
current_private_uids = {
str(row["photo_uid"])
for row in current.execute(
"""
SELECT photo_uid
FROM photos
WHERE photo_private = 1 AND deleted_at IS NULL
"""
)
}
private_to_set = mapped_private_uids - current_private_uids
current_albums = {
str(row["album_uid"]): str(row["album_type"])
for row in current.execute(
"SELECT album_uid, album_type FROM albums WHERE album_uid IS NOT NULL"
)
}
conflicts = [
str(album["album_uid"])
for album in albums
if str(album["album_uid"]) in current_albums
and current_albums[str(album["album_uid"])] != "album"
]
if conflicts:
fail(
"manual album UIDs collide with non-album records in the current database: "
+ ", ".join(conflicts)
)
return {
"albums": albums,
"memberships": memberships,
"mapped_memberships": mapped_memberships,
"missing_by_album": missing_by_album,
"ambiguous_by_album": ambiguous_by_album,
"existing_album_uids": {
uid for uid, album_type in current_albums.items() if album_type == "album"
},
"old_private_count": len(old_private_uids),
"mapped_private_count": len(mapped_private_old),
"mapped_private_uids": mapped_private_uids,
"already_private_count": len(mapped_private_uids & current_private_uids),
"private_to_set": private_to_set,
"missing_private": missing_private,
"ambiguous_private": ambiguous_private,
}
def print_plan(plan: dict[str, Any]) -> None:
albums = plan["albums"]
memberships = plan["memberships"]
mapped = plan["mapped_memberships"]
missing_by_album = plan["missing_by_album"]
ambiguous_by_album = plan["ambiguous_by_album"]
album_titles = {str(row["album_uid"]): str(row["album_title"]) for row in albums}
missing = sum(missing_by_album.values())
ambiguous = sum(ambiguous_by_album.values())
print(f"Manual albums in backup: {len(albums)}")
print(f"Already present by UID: {len(plan['existing_album_uids'])}")
print(f"Album memberships in backup: {len(memberships)}")
print(f"Recoverable by content hash: {len(mapped)}")
print(f"Missing current file match: {missing}")
print(f"Ambiguous current photo match: {ambiguous}")
print(f"\nPrivate photos in backup: {plan['old_private_count']}")
print(f"Recoverable by content hash: {plan['mapped_private_count']}")
print(f"Already private in current DB: {plan['already_private_count']}")
print(f"New private flags to restore: {len(plan['private_to_set'])}")
print(f"Missing current file match: {plan['missing_private']}")
print(f"Ambiguous current photo match: {plan['ambiguous_private']}")
affected = set(missing_by_album) | set(ambiguous_by_album)
if affected:
print("\nAlbums with skipped memberships:")
rows = sorted(
(
missing_by_album[uid] + ambiguous_by_album[uid],
album_titles.get(uid, uid),
missing_by_album[uid],
ambiguous_by_album[uid],
)
for uid in affected
)
for total, title, album_missing, album_ambiguous in reversed(rows[-15:]):
print(
f" {title}: {total} skipped "
f"({album_missing} missing, {album_ambiguous} ambiguous)"
)
def default_backup_path(current_db: Path) -> Path:
timestamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
return current_db.with_name(f"{current_db.name}.pre-album-recovery-{timestamp}")
def backup_database(
connection: sqlite3.Connection, source: Path, destination: Path
) -> None:
if destination.exists():
fail(f"backup destination already exists: {destination}")
destination.parent.mkdir(parents=True, exist_ok=True)
backup = sqlite3.connect(destination)
try:
connection.backup(backup)
result = backup.execute("PRAGMA quick_check").fetchone()[0]
if result != "ok":
fail(f"new backup failed quick_check: {result}")
finally:
backup.close()
source_stat = source.stat()
os.chmod(destination, source_stat.st_mode & 0o777)
os.chown(destination, source_stat.st_uid, source_stat.st_gid)
print(f"Current database backup: {destination}")
def apply_recovery(
current: sqlite3.Connection,
album_columns: list[str],
plan: dict[str, Any],
) -> tuple[int, int, int]:
existing = plan["existing_album_uids"]
albums_to_insert = [
row for row in plan["albums"] if str(row["album_uid"]) not in existing
]
placeholders = ", ".join("?" for _ in album_columns)
quoted_columns = ", ".join(f'"{name}"' for name in album_columns)
insert_album = f"INSERT INTO albums ({quoted_columns}) VALUES ({placeholders})"
membership_columns = [
"photo_uid",
"album_uid",
"order",
"hidden",
"missing",
"created_at",
"updated_at",
]
membership_placeholders = ", ".join("?" for _ in membership_columns)
insert_membership = (
'INSERT OR IGNORE INTO photos_albums '
'(photo_uid, album_uid, "order", hidden, missing, created_at, updated_at) '
f"VALUES ({membership_placeholders})"
)
current.execute("BEGIN IMMEDIATE")
try:
current.executemany(
insert_album,
[
tuple(None if name == "created_by" else row[name] for name in album_columns)
for row in albums_to_insert
],
)
before = current.total_changes
current.executemany(
insert_membership,
[
tuple(row[name] for name in membership_columns)
for row in plan["mapped_memberships"]
],
)
inserted_memberships = current.total_changes - before
before = current.total_changes
current.executemany(
"""
UPDATE photos
SET photo_private = 1, updated_at = CURRENT_TIMESTAMP
WHERE photo_uid = ?
AND COALESCE(photo_private, 0) = 0
AND deleted_at IS NULL
""",
[(photo_uid,) for photo_uid in plan["private_to_set"]],
)
restored_private = current.total_changes - before
current.commit()
except Exception:
current.rollback()
raise
return len(albums_to_insert), inserted_memberships, restored_private
def main() -> None:
args = parse_args()
if args.allow_partial and not args.apply:
fail("--allow-partial only has an effect together with --apply")
state = service_state(args.service)
if args.apply and state not in {"inactive", "failed"}:
fail(
f"{args.service} must be inactive before applying (current state: {state}); "
f"stop it first: sudo systemctl stop {args.service}"
)
old = open_database(args.old_db, writable=False)
current = open_database(args.current_db, writable=args.apply)
try:
check_database(old, "backup")
check_database(current, "current")
album_columns = require_compatible_schema(old, current)
old_version = version(old)
current_version = version(current)
print(f"Backup PhotoPrism version: {old_version}")
print(f"Current PhotoPrism version: {current_version}")
if old_version != current_version:
print("warning: versions differ, but the required table schemas are identical")
plan = recovery_plan(old, current)
print_plan(plan)
skipped = (
len(plan["memberships"])
- len(plan["mapped_memberships"])
+ plan["missing_private"]
+ plan["ambiguous_private"]
)
if not args.apply:
print("\nDry run only; no database changes were made.")
return
if not plan["mapped_memberships"] and not plan["mapped_private_uids"]:
fail("no album memberships or private photos can be matched; refusing to apply")
if skipped and not args.allow_partial:
fail(
f"{skipped} memberships cannot be mapped; inspect the report, then rerun "
"with --apply --allow-partial if the partial recovery is acceptable"
)
backup_path = args.backup or default_backup_path(args.current_db)
backup_database(current, args.current_db, backup_path)
inserted_albums, inserted_memberships, restored_private = apply_recovery(
current, album_columns, plan
)
check_database(current, "recovered current")
print(f"Inserted albums: {inserted_albums}")
print(f"Inserted album memberships: {inserted_memberships}")
print(f"Restored private flags: {restored_private}")
print("Recovery committed. Start PhotoPrism and inspect Albums and the private view.")
finally:
current.close()
old.close()
if __name__ == "__main__":
main()