Phase 2: Docker and OIDC auth

This commit is contained in:
2026-05-04 13:50:53 +02:00
parent 47baee854b
commit 4226628d5a
71 changed files with 9722 additions and 1347 deletions
@@ -0,0 +1,232 @@
"""In-process background queue for outbound user emails.
The queue keeps SMTP delivery off the request path so message composition
returns quickly and the rest of the API remains responsive while the worker
thread performs the blocking SMTP call.
"""
from __future__ import annotations
import logging
import queue
import threading
import time
import uuid
from dataclasses import dataclass, field
from typing import Any
from media_library_viewer_api.services.mailer import EmailAttachment, describe_smtp_error, send_email_message
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class QueuedEmailMessage:
"""A queued outbound email request."""
request_id: str
settings: Any
recipients: list[str]
subject: str
html_body: str
text_body: str
attachments: list[EmailAttachment] = field(default_factory=list)
created_at: float = field(default_factory=time.time)
class MailQueue:
"""Single-worker in-process queue for SMTP delivery."""
def __init__(self) -> None:
self._queue: queue.Queue[QueuedEmailMessage | None] = queue.Queue()
self._thread: threading.Thread | None = None
self._stop_event = threading.Event()
self._lock = threading.Lock()
self._pending_count = 0
self._active_request_id: str | None = None
self._last_request_id: str | None = None
self._last_result: str | None = None
self._last_error = ""
self._last_error_at: float | None = None
self._last_success_at: float | None = None
self._last_activity_at: float | None = None
self._sent_count = 0
self._failed_count = 0
def start(self) -> None:
"""Start the worker thread if it is not already running."""
with self._lock:
if self._thread and self._thread.is_alive():
return
self._stop_event.clear()
self._thread = threading.Thread(target=self._run, name="mail-queue-worker", daemon=True)
self._thread.start()
logger.info("Mail queue worker started")
def stop(self, timeout: float = 5.0) -> None:
"""Stop the worker thread and wait briefly for shutdown."""
with self._lock:
thread = self._thread
if not thread:
return
self._stop_event.set()
self._queue.put(None)
thread.join(timeout=timeout)
if thread.is_alive():
logger.warning("Mail queue worker did not stop within %.1fs", timeout)
else:
logger.info("Mail queue worker stopped")
with self._lock:
if self._thread is thread:
self._thread = None
def enqueue(
self,
*,
settings: Any,
recipients: list[str],
subject: str,
html_body: str,
text_body: str = "",
attachments: list[EmailAttachment] | None = None,
) -> str:
"""Queue an outbound email and return a request identifier."""
request_id = uuid.uuid4().hex
message = QueuedEmailMessage(
request_id=request_id,
settings=settings,
recipients=list(recipients),
subject=subject,
html_body=html_body,
text_body=text_body,
attachments=list(attachments or []),
)
with self._lock:
self._pending_count += 1
self._last_request_id = request_id
self._last_result = "queued"
self._last_activity_at = time.time()
self._queue.put(message)
logger.info(
"Queued email request_id=%s recipients=%s attachments=%s subject=%s",
request_id,
len(message.recipients),
len(message.attachments),
subject,
)
return request_id
def status(self) -> dict[str, Any]:
"""Return a snapshot of the queue state for health/status endpoints."""
with self._lock:
worker_running = bool(self._thread and self._thread.is_alive())
stop_requested = self._stop_event.is_set()
pending_count = self._pending_count
active_request_id = self._active_request_id
last_request_id = self._last_request_id
last_result = self._last_result
last_error = self._last_error
last_error_at = self._last_error_at
last_success_at = self._last_success_at
last_activity_at = self._last_activity_at
sent_count = self._sent_count
failed_count = self._failed_count
if not worker_running:
state = "stopped" if stop_requested else "error"
elif active_request_id or pending_count > 0:
state = "busy"
elif last_result == "failed" and last_error:
state = "error"
else:
state = "idle"
return {
"state": state,
"worker_running": worker_running,
"stop_requested": stop_requested,
"pending_count": pending_count,
"active_request_id": active_request_id,
"last_request_id": last_request_id,
"last_result": last_result,
"last_error": last_error,
"last_error_at": last_error_at,
"last_success_at": last_success_at,
"last_activity_at": last_activity_at,
"sent_count": sent_count,
"failed_count": failed_count,
}
def _run(self) -> None:
while not self._stop_event.is_set():
try:
message = self._queue.get(timeout=0.5)
except queue.Empty:
continue
try:
if message is None:
continue
with self._lock:
self._pending_count = max(0, self._pending_count - 1)
self._active_request_id = message.request_id
self._last_request_id = message.request_id
self._last_result = "sending"
self._last_activity_at = time.time()
logger.info(
"Mail queue sending request_id=%s recipients=%s attachments=%s subject=%s",
message.request_id,
len(message.recipients),
len(message.attachments),
message.subject,
)
result: dict[str, Any] = send_email_message(
message.settings,
recipients=message.recipients,
subject=message.subject,
html_body=message.html_body,
text_body=message.text_body,
attachments=message.attachments,
)
with self._lock:
self._active_request_id = None
self._last_result = "sent"
self._last_success_at = time.time()
self._last_activity_at = self._last_success_at
self._last_error = ""
self._last_error_at = None
self._sent_count += 1
logger.info(
"Mail queue sent request_id=%s mode=%s auth_user=%s recipient_count=%s attachment_count=%s",
message.request_id,
(result.get("selected_mode") or {}).get("label", "<unknown>"),
result.get("authenticated_as") or "<none>",
result.get("recipient_count", 0),
result.get("attachment_count", 0),
)
except Exception as exc:
friendly_error = describe_smtp_error(exc)
with self._lock:
self._active_request_id = None
self._last_result = "failed"
self._last_error = friendly_error
self._last_error_at = time.time()
self._last_activity_at = self._last_error_at
self._failed_count += 1
logger.exception(
"Mail queue delivery failed request_id=%s error=%s",
getattr(message, "request_id", "unknown"),
friendly_error,
)
finally:
self._queue.task_done()
_MAIL_QUEUE = MailQueue()
def get_mail_queue() -> MailQueue:
"""Return the singleton mail queue."""
return _MAIL_QUEUE
@@ -0,0 +1,540 @@
"""SMTP email sending helpers for user communication workflows."""
from __future__ import annotations
import logging
import mimetypes
import socket
import smtplib
import ssl
from dataclasses import dataclass
from email.message import EmailMessage
from email.utils import formataddr
from html.parser import HTMLParser
from typing import Any, Iterable
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class EmailAttachment:
"""Attachment payload passed from the API layer."""
filename: str
content_type: str
data: bytes
class _HTMLToTextParser(HTMLParser):
"""Small HTML-to-text helper for plain-text fallback bodies."""
block_tags = {"p", "div", "section", "article", "header", "footer", "li", "tr", "td", "th", "br"}
def __init__(self) -> None:
super().__init__()
self.parts: list[str] = []
def handle_starttag(self, tag: str, attrs): # type: ignore[override]
if tag.lower() in self.block_tags and self.parts and not self.parts[-1].endswith("\n"):
self.parts.append("\n")
def handle_endtag(self, tag: str) -> None:
if tag.lower() in self.block_tags and self.parts and not self.parts[-1].endswith("\n"):
self.parts.append("\n")
def handle_data(self, data: str) -> None:
if data:
self.parts.append(data)
def text(self) -> str:
return "".join(self.parts)
def html_to_text(html: str) -> str:
"""Convert a small HTML body to readable plain text."""
parser = _HTMLToTextParser()
parser.feed(html or "")
text = parser.text()
lines = [line.rstrip() for line in text.splitlines()]
return "\n".join(line for line in lines if line).strip()
def _from_address(settings: object) -> str:
from_address = str(getattr(settings, "smtp_from_address", "") or "").strip()
if from_address:
return from_address
smtp_username = str(getattr(settings, "smtp_username", "") or "").strip()
if smtp_username:
return smtp_username
raise ValueError("SMTP from address is required (set SMTP_FROM_ADDRESS or SMTP_USERNAME)")
def validate_smtp_settings(settings: object) -> None:
"""Validate that the SMTP configuration is sufficient to send mail."""
smtp_host = str(getattr(settings, "smtp_host", "") or "").strip()
if not smtp_host:
raise ValueError("SMTP host is required")
_from_address(settings)
def _smtp_settings(settings: object) -> dict[str, object]:
smtp_host = str(getattr(settings, "smtp_host", "") or "").strip()
if not smtp_host:
raise ValueError("SMTP host is required")
smtp_port = int(getattr(settings, "smtp_port", 587) or 587)
smtp_username = str(getattr(settings, "smtp_username", "") or "").strip()
smtp_password = str(getattr(settings, "smtp_password", "") or "")
use_tls = bool(getattr(settings, "smtp_use_tls", True))
use_ssl = bool(getattr(settings, "smtp_use_ssl", False))
smtp_timeout = int(getattr(settings, "smtp_timeout", 30) or 30)
return {
"smtp_host": smtp_host,
"smtp_port": smtp_port,
"smtp_username": smtp_username,
"smtp_password": smtp_password,
"use_tls": use_tls,
"use_ssl": use_ssl,
"smtp_timeout": smtp_timeout,
}
def _smtp_mode_label(mode: dict[str, object]) -> str:
transport = "SSL" if mode["use_ssl"] else "STARTTLS" if mode["use_tls"] else "plain SMTP"
return f"{mode['smtp_host']}:{mode['smtp_port']} via {transport}"
def _smtp_mode_candidates(settings: object) -> list[dict[str, Any]]:
base = _smtp_settings(settings)
candidates = [dict(base, mode_label="configured")]
smtp_host = str(base["smtp_host"]).lower()
if "fastmail.com" in smtp_host:
fastmail_ssl = {
**base,
"smtp_port": 465,
"use_tls": False,
"use_ssl": True,
"mode_label": "Fastmail SSL 465",
}
fastmail_tls = {
**base,
"smtp_port": 587,
"use_tls": True,
"use_ssl": False,
"mode_label": "Fastmail STARTTLS 587",
}
for mode in (fastmail_ssl, fastmail_tls):
if not any(
candidate["smtp_port"] == mode["smtp_port"]
and candidate["use_tls"] == mode["use_tls"]
and candidate["use_ssl"] == mode["use_ssl"]
for candidate in candidates
):
candidates.append(mode)
return candidates
def _probe_smtp_connection(mode: dict[str, Any]) -> None:
context = ssl.create_default_context()
smtp_host = str(mode["smtp_host"])
smtp_port = int(mode["smtp_port"])
smtp_username = str(mode["smtp_username"])
smtp_password = str(mode["smtp_password"])
use_tls = bool(mode["use_tls"])
use_ssl = bool(mode["use_ssl"])
smtp_timeout = int(mode["smtp_timeout"])
if use_ssl:
smtp_connection = smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=smtp_timeout)
else:
smtp_connection = smtplib.SMTP(smtp_host, smtp_port, timeout=smtp_timeout)
with smtp_connection as smtp:
if use_tls and not use_ssl:
smtp.ehlo()
smtp.starttls(context=context)
smtp.ehlo()
else:
smtp.ehlo()
if smtp_username:
smtp.login(smtp_username, smtp_password)
smtp.noop()
def _smtp_sender_not_authorized(error: Exception) -> bool:
code = getattr(error, "smtp_code", None)
raw_error = getattr(error, "smtp_error", b"")
if isinstance(raw_error, bytes):
raw_error_text = raw_error.decode(errors="ignore")
else:
raw_error_text = str(raw_error)
text = f"{code} {raw_error_text} {error}".lower()
return code in {551, 553} or "not authorised to send from this header address" in text or "not authorized to send from this header address" in text
def _smtp_attempt_metadata(mode: dict[str, Any]) -> dict[str, Any]:
transport = "SSL" if mode["use_ssl"] else "STARTTLS" if mode["use_tls"] else "plain SMTP"
return {
"label": str(mode.get("mode_label") or _smtp_mode_label(mode)),
"smtp_host": str(mode["smtp_host"]),
"smtp_port": int(mode["smtp_port"]),
"use_tls": bool(mode["use_tls"]),
"use_ssl": bool(mode["use_ssl"]),
"transport": transport,
"auth_user": str(mode.get("smtp_username") or "") or "<none>",
}
def describe_smtp_error(error: Exception) -> str:
"""Convert SMTP failures into operator-friendly messages."""
chain: list[Exception] = []
current: Exception | None = error
while current is not None and current not in chain:
chain.append(current)
current = current.__cause__ if isinstance(current.__cause__, Exception) else None
for item in chain:
text = str(item).strip()
lowered = text.lower()
if isinstance(item, (TimeoutError, socket.timeout)) or "timed out" in lowered:
return (
"SMTP connection timed out while waiting for the server greeting. "
"Check host, port, network access, and SMTP_TIMEOUT."
)
if isinstance(item, smtplib.SMTPAuthenticationError):
return (
"SMTP authentication failed. Check SMTP_USERNAME and SMTP_PASSWORD "
"(Fastmail and similar providers usually require an app password)."
)
if isinstance(item, (smtplib.SMTPDataError, smtplib.SMTPResponseException)):
smtp_code = getattr(item, "smtp_code", None)
if smtp_code in {551, 553} or "not authorised to send from this header address" in lowered or "not authorized to send from this header address" in lowered:
return (
"SMTP server rejected the configured From address. Use an authorized alias "
"for this account or change SMTP_FROM_ADDRESS to a sender the provider allows."
)
if isinstance(item, smtplib.SMTPConnectError):
return "SMTP connection was rejected by the server. Check the host and port."
if isinstance(item, smtplib.SMTPServerDisconnected) and "timed out" in lowered:
return (
"SMTP connection timed out while waiting for the server greeting. "
"Check host, port, network access, and SMTP_TIMEOUT."
)
return f"SMTP delivery failed: {error}"
def test_smtp_connection(settings: object) -> dict[str, object]:
"""Validate SMTP connectivity and authentication without sending a message."""
try:
base = _smtp_settings(settings)
except ValueError as exc:
return {
"status": "error",
"message": str(exc),
"attempts": [],
"selected_mode": None,
"from_address": "",
"from_name": str(getattr(settings, "smtp_from_name", "") or "").strip() or "Media Library Viewer",
"smtp_host": str(getattr(settings, "smtp_host", "") or "").strip(),
"smtp_port": int(getattr(settings, "smtp_port", 587) or 587),
"use_tls": bool(getattr(settings, "smtp_use_tls", True)),
"use_ssl": bool(getattr(settings, "smtp_use_ssl", False)),
"authenticated": False,
}
from_address = _from_address(settings)
from_name = str(getattr(settings, "smtp_from_name", "") or "").strip() or "Media Library Viewer"
attempts: list[dict[str, Any]] = []
last_error = ""
logger.info(
"SMTP test requested from_address=%s auth_user=%s",
from_address,
base["smtp_username"] or "<none>",
)
for mode in _smtp_mode_candidates(settings):
meta = _smtp_attempt_metadata(mode)
logger.info(
"SMTP test attempting label=%s host=%s port=%s transport=%s auth_user=%s",
meta["label"],
meta["smtp_host"],
meta["smtp_port"],
meta["transport"],
meta["auth_user"],
)
try:
_probe_smtp_connection(mode)
attempts.append(
{
"label": meta["label"],
"smtp_host": meta["smtp_host"],
"smtp_port": meta["smtp_port"],
"use_tls": meta["use_tls"],
"use_ssl": meta["use_ssl"],
"status": "ok",
}
)
logger.info(
"SMTP test succeeded label=%s host=%s port=%s transport=%s auth_user=%s",
meta["label"],
meta["smtp_host"],
meta["smtp_port"],
meta["transport"],
meta["auth_user"],
)
return {
"status": "ok",
"message": f"SMTP connection successful using {meta['label']}",
"from_address": from_address,
"from_name": from_name,
"smtp_host": meta["smtp_host"],
"smtp_port": meta["smtp_port"],
"use_tls": meta["use_tls"],
"use_ssl": meta["use_ssl"],
"authenticated": bool(str(mode["smtp_username"])),
"selected_mode": {
"label": meta["label"],
"smtp_host": meta["smtp_host"],
"smtp_port": meta["smtp_port"],
"use_tls": meta["use_tls"],
"use_ssl": meta["use_ssl"],
},
"attempts": attempts,
}
except Exception as exc:
last_error = describe_smtp_error(exc)
attempts.append(
{
"label": meta["label"],
"smtp_host": meta["smtp_host"],
"smtp_port": meta["smtp_port"],
"use_tls": meta["use_tls"],
"use_ssl": meta["use_ssl"],
"status": "failed",
"error": last_error,
}
)
logger.warning(
"SMTP test failed label=%s host=%s port=%s transport=%s auth_user=%s error=%s",
meta["label"],
meta["smtp_host"],
meta["smtp_port"],
meta["transport"],
meta["auth_user"],
last_error,
)
logger.error(
"SMTP test exhausted attempts=%s error=%s",
[attempt["label"] for attempt in attempts],
last_error or "SMTP test failed.",
)
return {
"status": "error",
"message": last_error or "SMTP test failed.",
"from_address": from_address,
"from_name": from_name,
"smtp_host": base["smtp_host"],
"smtp_port": base["smtp_port"],
"use_tls": base["use_tls"],
"use_ssl": base["use_ssl"],
"authenticated": bool(base["smtp_username"]),
"selected_mode": None,
"attempts": attempts,
}
def build_email_message(
settings: object,
recipients: list[str],
subject: str,
html_body: str,
text_body: str,
attachments: Iterable[EmailAttachment] = (),
*,
sender_address: str | None = None,
reply_to_address: str | None = None,
) -> tuple[EmailMessage, str]:
"""Build a MIME email message with HTML and attachments."""
from_address = sender_address or _from_address(settings)
from_name = str(getattr(settings, "smtp_from_name", "") or "").strip() or "Media Library Viewer"
msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = formataddr((from_name, from_address))
msg["To"] = "Undisclosed recipients:;"
msg["Reply-To"] = reply_to_address or from_address
plain_text = text_body.strip() or html_to_text(html_body)
if html_body.strip():
msg.set_content(plain_text or " ")
msg.add_alternative(html_body, subtype="html")
else:
msg.set_content(plain_text or "")
for attachment in attachments:
content_type = attachment.content_type or mimetypes.guess_type(attachment.filename)[0] or "application/octet-stream"
maintype, subtype = content_type.split("/", 1) if "/" in content_type else ("application", "octet-stream")
msg.add_attachment(
attachment.data,
maintype=maintype,
subtype=subtype,
filename=attachment.filename or "attachment",
)
return msg, from_address
def _send_email_via_mode(
mode: dict[str, Any],
message: EmailMessage,
recipients: list[str],
from_address: str,
) -> None:
context = ssl.create_default_context()
smtp_host = str(mode["smtp_host"])
smtp_port = int(mode["smtp_port"])
smtp_username = str(mode["smtp_username"])
smtp_password = str(mode["smtp_password"])
use_tls = bool(mode["use_tls"])
use_ssl = bool(mode["use_ssl"])
smtp_timeout = int(mode["smtp_timeout"])
if use_ssl:
smtp_connection = smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=smtp_timeout)
else:
smtp_connection = smtplib.SMTP(smtp_host, smtp_port, timeout=smtp_timeout)
with smtp_connection as smtp:
if use_tls and not use_ssl:
smtp.ehlo()
smtp.starttls(context=context)
smtp.ehlo()
else:
smtp.ehlo()
if smtp_username:
smtp.login(smtp_username, smtp_password)
smtp.send_message(message, from_addr=from_address, to_addrs=recipients)
def send_email_message(
settings: object,
recipients: list[str],
subject: str,
html_body: str,
text_body: str = "",
attachments: Iterable[EmailAttachment] = (),
) -> dict[str, object]:
"""Send a single outbound email to a recipient list via SMTP BCC."""
if not recipients:
raise ValueError("At least one recipient is required")
attachment_list = list(attachments)
preferred_from_address = _from_address(settings)
smtp_username = str(getattr(settings, "smtp_username", "") or "").strip()
message, from_address = build_email_message(
settings,
recipients,
subject,
html_body,
text_body,
attachment_list,
sender_address=preferred_from_address,
reply_to_address=preferred_from_address,
)
logger.info(
"SMTP send requested subject=%s recipients=%s from_address=%s auth_user=%s attachments=%s",
subject,
len(recipients),
from_address,
smtp_username or "<none>",
len(attachment_list),
)
attempts: list[dict[str, Any]] = []
last_error = ""
for mode in _smtp_mode_candidates(settings):
meta = _smtp_attempt_metadata(mode)
logger.info(
"SMTP send attempting label=%s host=%s port=%s transport=%s auth_user=%s from_address=%s",
meta["label"],
meta["smtp_host"],
meta["smtp_port"],
meta["transport"],
meta["auth_user"],
from_address,
)
try:
_send_email_via_mode(mode, message, recipients, from_address)
attempts.append(
{
"label": meta["label"],
"smtp_host": meta["smtp_host"],
"smtp_port": meta["smtp_port"],
"use_tls": meta["use_tls"],
"use_ssl": meta["use_ssl"],
"status": "ok",
}
)
logger.info(
"SMTP send succeeded label=%s host=%s port=%s transport=%s auth_user=%s from_address=%s",
meta["label"],
meta["smtp_host"],
meta["smtp_port"],
meta["transport"],
meta["auth_user"],
from_address,
)
return {
"from_address": from_address,
"recipient_count": len(recipients),
"attachment_count": len(attachment_list),
"subject": subject,
"authenticated_as": smtp_username or None,
"selected_mode": {
"label": meta["label"],
"smtp_host": meta["smtp_host"],
"smtp_port": meta["smtp_port"],
"use_tls": meta["use_tls"],
"use_ssl": meta["use_ssl"],
},
"attempts": attempts,
}
except Exception as exc:
last_error = describe_smtp_error(exc)
attempts.append(
{
"label": meta["label"],
"smtp_host": meta["smtp_host"],
"smtp_port": meta["smtp_port"],
"use_tls": meta["use_tls"],
"use_ssl": meta["use_ssl"],
"status": "failed",
"error": last_error,
}
)
if _smtp_sender_not_authorized(exc):
logger.warning(
"SMTP send sender rejected label=%s host=%s port=%s transport=%s auth_user=%s from_address=%s error=%s",
meta["label"],
meta["smtp_host"],
meta["smtp_port"],
meta["transport"],
meta["auth_user"],
from_address,
last_error,
)
else:
logger.warning(
"SMTP send failed label=%s host=%s port=%s transport=%s auth_user=%s from_address=%s error=%s",
meta["label"],
meta["smtp_host"],
meta["smtp_port"],
meta["transport"],
meta["auth_user"],
from_address,
last_error,
)
raise RuntimeError(last_error or "SMTP delivery failed")
@@ -7,14 +7,18 @@ through FastAPI to a React frontend without rewriting Jellyfin indexing logic.
from __future__ import annotations
import logging
import sqlite3
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable
from typing import Any, Callable, Iterable
from media_library_viewer_api.clients.jellyfin import JellyfinClient
from media_library_viewer_api.domain.media import display_media_row, normalize_media_item
from media_library_viewer_api.path_utils import resolve_remote_media_path
logger = logging.getLogger(__name__)
# Local generated database. It is ignored by git and can be rebuilt from
# Jellyfin metadata whenever needed.
@@ -42,6 +46,19 @@ SORT_COLUMNS = {
}
def _estimate_remaining_seconds(elapsed_seconds: float, progress: float | None) -> float | None:
if progress is None:
return None
progress = max(0.0, min(1.0, progress))
if progress <= 0.0:
return None
return max(0.0, elapsed_seconds * (1.0 - progress) / progress)
class MediaIndexBuildCancelled(Exception):
"""Raised when a media index build is requested to stop."""
@dataclass(frozen=True)
class MediaIndexStatus:
"""Lightweight status object displayed by the Media tab."""
@@ -51,6 +68,25 @@ class MediaIndexStatus:
updated_at: int | None = None
updated_at_label: str = ""
build_duration_seconds: float | None = None
build_running: bool = False
build_stage: str = ""
build_message: str = ""
build_progress: float | None = None
build_items_processed: int = 0
build_items_total: int = 0
build_current_library: str = ""
build_library_index: int = 0
build_libraries_total: int = 0
build_library_progress: float | None = None
build_library_items_processed: int = 0
build_library_items_total: int = 0
build_elapsed_seconds: float | None = None
build_eta_seconds: float | None = None
build_library_elapsed_seconds: float | None = None
build_library_eta_seconds: float | None = None
build_cancel_requested: bool = False
build_pid: int | None = None
build_error: str = ""
class MediaIndex:
@@ -66,8 +102,10 @@ class MediaIndex:
def connect(self) -> sqlite3.Connection:
"""Open a sqlite connection configured to return Row objects."""
conn = sqlite3.connect(self.db_path)
conn = sqlite3.connect(self.db_path, timeout=30)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=30000")
return conn
def init_schema(self) -> None:
@@ -170,24 +208,68 @@ class MediaIndex:
try:
with self.connect() as conn:
item_count = int(conn.execute("SELECT COUNT(*) FROM media_items").fetchone()[0])
updated_row = conn.execute("SELECT value FROM index_metadata WHERE key='updated_at'").fetchone()
duration_row = conn.execute("SELECT value FROM index_metadata WHERE key='build_duration_seconds'").fetchone()
meta = {
row[0]: row[1]
for row in conn.execute("SELECT key, value FROM index_metadata").fetchall()
}
except sqlite3.Error:
return MediaIndexStatus(exists=False)
updated_at = int(updated_row[0]) if updated_row and str(updated_row[0]).isdigit() else None
updated_at_raw = meta.get("updated_at", "")
updated_at = int(updated_at_raw) if str(updated_at_raw).isdigit() else None
label = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(updated_at)) if updated_at else ""
duration_raw = meta.get("build_duration_seconds")
build_duration = None
if duration_row:
if duration_raw is not None:
try:
build_duration = float(duration_row[0])
build_duration = float(duration_raw)
except (TypeError, ValueError):
build_duration = None
def _bool(key: str, default: bool = False) -> bool:
value = str(meta.get(key, str(default))).strip().lower()
return value in {"1", "true", "yes", "on"}
def _int(key: str, default: int = 0) -> int:
value = meta.get(key, default)
try:
return int(value)
except (TypeError, ValueError):
return default
def _float(key: str) -> float | None:
value = meta.get(key)
if value in (None, ""):
return None
try:
return float(value)
except (TypeError, ValueError):
return None
return MediaIndexStatus(
exists=True,
item_count=item_count,
updated_at=updated_at,
updated_at_label=label,
build_duration_seconds=build_duration,
build_running=_bool("build_running"),
build_stage=str(meta.get("build_stage", "")),
build_message=str(meta.get("build_message", "")),
build_progress=_float("build_progress"),
build_items_processed=_int("build_items_processed"),
build_items_total=_int("build_items_total"),
build_current_library=str(meta.get("build_current_library", "")),
build_library_index=_int("build_library_index"),
build_libraries_total=_int("build_libraries_total"),
build_library_progress=_float("build_library_progress"),
build_library_items_processed=_int("build_library_items_processed"),
build_library_items_total=_int("build_library_items_total"),
build_elapsed_seconds=_float("build_elapsed_seconds"),
build_eta_seconds=_float("build_eta_seconds"),
build_library_elapsed_seconds=_float("build_library_elapsed_seconds"),
build_library_eta_seconds=_float("build_library_eta_seconds"),
build_cancel_requested=_bool("build_cancel_requested"),
build_pid=_int("build_pid") or None,
build_error=str(meta.get("build_error", "")),
)
def query(
@@ -245,18 +327,79 @@ def build_media_index(
libraries: list[dict[str, Any]],
index: MediaIndex | None = None,
page_size: int = 500,
media_root: str = "",
fallback_prefix: str = "",
progress_callback: Callable[[dict[str, Any]], None] | None = None,
should_cancel: Callable[[], bool] | None = None,
) -> int:
"""Fetch Jellyfin pages for all selected libraries and rebuild the index."""
index = index or MediaIndex()
started_at = time.perf_counter()
normalized_rows: list[dict[str, Any]] = []
for library in libraries:
processed_total = 0
expected_total = 0
current_library_name = ""
current_library_index = 0
current_library_processed = 0
current_library_total = 0
current_library_started_at = started_at
def ensure_not_cancelled() -> None:
if should_cancel and should_cancel():
raise MediaIndexBuildCancelled()
def emit(stage: str, message: str) -> None:
if not progress_callback:
return
elapsed_seconds = time.perf_counter() - started_at
library_elapsed_seconds = time.perf_counter() - current_library_started_at
overall_progress = (processed_total / expected_total) if expected_total else None
library_progress = (current_library_processed / current_library_total) if current_library_total else None
progress_callback(
{
"stage": stage,
"message": message,
"processed": processed_total,
"total": expected_total,
"progress": overall_progress,
"elapsed_seconds": elapsed_seconds,
"eta_seconds": _estimate_remaining_seconds(elapsed_seconds, overall_progress),
"library": current_library_name,
"library_index": current_library_index,
"libraries_total": len(libraries),
"library_processed": current_library_processed,
"library_total": current_library_total,
"library_progress": library_progress,
"library_elapsed_seconds": library_elapsed_seconds if current_library_total else None,
"library_eta_seconds": _estimate_remaining_seconds(library_elapsed_seconds, library_progress),
}
)
ensure_not_cancelled()
logger.info("Media index build starting libraries=%s page_size=%s", len(libraries), page_size)
emit("starting", "Starting media index build")
for library_index, library in enumerate(libraries, start=1):
library_id = library.get("Id")
library_name = library.get("Name", "")
current_library_name = library.get("Name", "")
current_library_index = library_index
current_library_processed = 0
current_library_total = 0
current_library_started_at = time.perf_counter()
if not library_id:
continue
ensure_not_cancelled()
logger.info(
"Media index scanning library index=%s/%s name=%s id=%s",
library_index,
len(libraries),
current_library_name or "Library",
library_id,
)
emit("library-starting", f"Scanning {current_library_name or 'Library'}")
start = 0
discovered_library_total = None
while True:
ensure_not_cancelled()
response = client.items(
user_id=user_id,
parent_id=library_id,
@@ -267,12 +410,49 @@ def build_media_index(
sort_by="SortName",
sort_order="Ascending",
)
ensure_not_cancelled()
items = response.get("Items", [])
normalized_rows.extend(normalize_media_item(item, library_id, library_name) for item in items)
if discovered_library_total is None:
discovered_library_total = int(response.get("TotalRecordCount", len(items)))
current_library_total = max(discovered_library_total, 0)
expected_total += current_library_total
normalized_rows.extend(
{
**row,
"path": resolve_remote_media_path(row.get("path", ""), media_root, fallback_prefix),
}
for row in (
normalize_media_item(item, library_id, current_library_name)
for item in items
)
)
processed_total += len(items)
current_library_processed += len(items)
start += len(items)
ensure_not_cancelled()
emit(
"building",
f"{current_library_name or 'Library'}: {current_library_processed} / {current_library_total or '?'} items",
)
logger.debug(
"Media index progress library=%s processed=%s/%s total_processed=%s",
current_library_name or "Library",
current_library_processed,
current_library_total,
processed_total,
)
total = int(response.get("TotalRecordCount", start))
if not items or start >= total:
break
ensure_not_cancelled()
logger.info("Media index finalizing rows=%s", len(normalized_rows))
emit("finalizing", "Writing index to disk")
ensure_not_cancelled()
count = index.replace_items(normalized_rows)
index.set_metadata("build_duration_seconds", f"{time.perf_counter() - started_at:.3f}")
duration = time.perf_counter() - started_at
index.set_metadata("build_duration_seconds", f"{duration:.3f}")
processed_total = count
current_library_processed = current_library_total
emit("completed", f"Indexed {count} items in {duration:.1f}s")
logger.info("Media index build completed count=%s duration=%.2fs", count, duration)
return count