Files
wizarr/app/services/library_scanner.py
lostpolaris 260165bc2a Change library deletes to upserts
This fixes a long-standing issue where scanning a library would cause an
unintended cascade delete of the invite_library table and would reset
the associated invite's permissions.
2026-02-09 17:08:30 -06:00

133 lines
5.0 KiB
Python

"""Library scanning service for media servers.
This service handles scanning and synchronizing library metadata from media servers
into the local database during application startup.
This implementation performs an upsert (update existing, insert new) and soft-deletes
(or removes) libraries that no longer exist on the remote server. Libraries that are
referenced by invitations are preserved (disabled) instead of being hard-deleted so
invite<->library associations remain intact.
"""
import logging
logger = logging.getLogger(__name__)
# Timeout for individual server library scans (in seconds)
# This prevents a single unreachable server from blocking startup
LIBRARY_SCAN_TIMEOUT = 15
def scan_all_server_libraries(show_logs: bool = True) -> tuple[int, list[str]]:
"""Scan libraries for all configured media servers.
Args:
show_logs: Whether to output log messages during scanning
Returns:
Tuple of (total_scanned, error_messages)
- total_scanned: Number of libraries successfully scanned
- error_messages: List of error messages for failed scans
"""
from sqlalchemy import inspect
from app.extensions import db
from app.models import Library, MediaServer, invite_libraries
from app.services.media.service import get_client_for_media_server
# Check if the library table exists (in case migrations haven't run yet)
inspector = inspect(db.engine)
if not inspector.has_table("library"):
if show_logs:
logger.info("Library table doesn't exist yet - skipping scan")
raise Exception("Library table not found - run migrations first")
servers = MediaServer.query.all()
total_scanned = 0
errors = []
for server in servers:
try:
client = get_client_for_media_server(server)
libraries_dict = client.libraries() # {external_id: name}
# Normalize to dict if client returned list-like
if not isinstance(libraries_dict, dict):
libraries_dict = {
str(k): str(v)
for k, v in (
libraries_dict.items()
if hasattr(libraries_dict, "items")
else libraries_dict
)
}
# Load existing libraries for this server keyed by external_id
existing_libs = {
lib.external_id: lib
for lib in Library.query.filter_by(server_id=server.id).all()
}
old_count = len(existing_libs)
incoming_ids = set()
updated_count = 0
inserted_count = 0
disabled_count = 0
deleted_count = 0
# Upsert incoming libraries: update existing, insert new
for external_id, name in libraries_dict.items():
incoming_ids.add(str(external_id))
if external_id in existing_libs:
lib = existing_libs[external_id]
# Update mutable attributes while preserving primary key
lib.name = name
lib.enabled = True
updated_count += 1
else:
lib = Library(
external_id=external_id,
name=name,
server_id=server.id,
enabled=True,
)
db.session.add(lib)
inserted_count += 1
total_scanned += 1
# Handle libraries that used to exist but weren't returned by the server
for ext, lib in existing_libs.items():
if ext not in incoming_ids:
# If this library is referenced by any invitation, disable it to preserve associations
referenced = db.session.execute(
invite_libraries.select().where(
invite_libraries.c.library_id == lib.id
)
).first()
if referenced:
if lib.enabled:
lib.enabled = False
disabled_count += 1
else:
# Safe to remove since no invites reference it
db.session.delete(lib)
deleted_count += 1
db.session.commit()
if show_logs:
logger.info(
f"Refreshed {len(libraries_dict)} libraries for {server.name} "
f"(existing={old_count}, updated={updated_count}, inserted={inserted_count}, "
f"disabled={disabled_count}, deleted={deleted_count})"
)
except Exception as server_exc:
# Rollback on error to keep session clean
db.session.rollback()
error_msg = f"Failed to scan libraries for {server.name}: {server_exc}"
errors.append(error_msg)
if show_logs:
logger.warning(error_msg)
return total_scanned, errors