feat(release): publish minimal self-contained Magent source

This commit is contained in:
Magent release tooling
2026-09-19 16:58:12 +12:00
commit 5fa5d45535
272 changed files with 79305 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
"""Shared Sonarr/Radarr configuration helpers."""
from typing import Any
class RootFolderNotFoundError(ValueError):
pass
async def resolve_root_folder_path(client: Any, root_folder: str, service_name: str) -> str:
configured = str(root_folder or "").strip()
if not configured.isdigit():
return configured
folders = await client.get_root_folders()
if isinstance(folders, list):
for folder in folders:
if isinstance(folder, dict) and folder.get("id") == int(configured):
path = str(folder.get("path") or "").strip()
if path:
return path
raise RootFolderNotFoundError(f"{service_name} root folder id {configured} not found")
+647
View File
@@ -0,0 +1,647 @@
"""Encrypted, portable backups and restart-only SQLite restores.
Restore is deliberately a two-step operation: the authenticated request validates
and stages it, then a single backend process applies it before opening the DB.
A durable journal and a private rollback copy protect interrupted installations.
"""
from __future__ import annotations
from contextlib import closing, contextmanager
from datetime import datetime, timezone
import hashlib
import io
import json
import os
from pathlib import Path, PurePosixPath
import re
import secrets
import shutil
import sqlite3
import stat
import tempfile
import threading
import time
from typing import Any, BinaryIO, Iterator
import uuid
import zipfile
import zlib
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
from pydantic import TypeAdapter
from ..config import Settings, settings
from ..db import _db_path
from ..installation_origin import managed_runtime, normalize_application_origin
from ..schema_migrations import MIGRATIONS
from ..secret_storage import SENSITIVE_SETTING_KEYS, decrypt_setting_value, encrypt_setting_value
FORMAT_VERSION = 1
MAX_UPLOAD_BYTES = 32 * 1024 * 1024
MAX_EXPANDED_BYTES = 128 * 1024 * 1024
MAX_ENTRIES = 20_000
MAGIC = b"MAGENT-BACKUP\x00\x01"
_LOCK = threading.Lock()
_ASSET_NAME = re.compile(r"^[A-Za-z0-9_.-]+$")
_TMDB_SIZES = {"w92", "w154", "w185", "w342", "w500", "w780", "original"}
# Host identity, process controls and local file locations belong to the target.
_LOCAL_FIELDS = {
"sqlite_path", "sqlite_journal_mode", "jwt_secret", "settings_encryption_key",
"admin_username", "admin_password", "setup_token", "app_name", "cors_allow_origin",
"auth_cookie_name", "auth_cookie_secure", "auth_cookie_samesite", "auth_cookie_domain",
"auth_state_cookie_name", "jwt_issuer", "jwt_audience", "api_docs_enabled",
"log_file", "magent_application_port", "magent_api_port", "magent_bind_host",
"magent_proxy_trusted_proxies", "magent_proxy_trust_forwarded_headers",
"magent_ssl_bind_enabled", "magent_ssl_certificate_path", "magent_ssl_private_key_path",
"magent_ssl_certificate_pem", "magent_ssl_private_key_pem",
"site_build_number", "site_changelog", "magent_allow_private_notification_targets",
}
class BackupError(ValueError):
"""A safe-to-display backup validation or state error."""
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _assets_root() -> Path:
# Matches the image and branding routers, independently of SQLITE_PATH.
return Path.cwd() / "data"
def _control_root() -> Path:
return Path(_db_path()).absolute().parent / "backups"
def _private_dir(path: Path) -> None:
if path.is_symlink():
raise BackupError("Backup directories must not be symbolic links")
path.mkdir(parents=True, exist_ok=True, mode=0o700)
path.chmod(0o700)
def _write_private(path: Path, content: bytes) -> None:
with path.open("xb") as handle:
path.chmod(0o600)
handle.write(content)
handle.flush()
os.fsync(handle.fileno())
def _write_json(path: Path, data: dict) -> None:
temporary = path.with_name(path.name + ".tmp-" + uuid.uuid4().hex)
try:
_write_private(temporary, json.dumps(data, separators=(",", ":")).encode())
os.replace(temporary, path)
_sync_directory(path.parent)
finally:
temporary.unlink(missing_ok=True)
def _sync_directory(path: Path) -> None:
if os.name != "nt":
descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def _sync_tree(path: Path) -> None:
for parent, _directories, files in os.walk(path, topdown=False):
for filename in files:
with (Path(parent) / filename).open("r+b") as handle:
os.fsync(handle.fileno())
_sync_directory(Path(parent))
@contextmanager
def _exclusive_operation() -> Iterator[None]:
if not _LOCK.acquire(blocking=False):
raise BackupError("Another backup or restore operation is in progress")
handle = None
locked = False
try:
root = _control_root()
_private_dir(root)
handle = (root / "operation.lock").open("a+b")
os.chmod(handle.name, 0o600)
# OS locks are released even if a process crashes; support the dev host too.
if os.name == "nt":
import msvcrt
handle.seek(0)
if not handle.read(1):
handle.write(b"0")
handle.flush()
handle.seek(0)
try:
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
except OSError as exc:
raise BackupError("Another backup or restore operation is in progress") from exc
else:
import fcntl
try:
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError as exc:
raise BackupError("Another backup or restore operation is in progress") from exc
locked = True
yield
finally:
if handle is not None:
if locked:
if os.name == "nt":
import msvcrt
handle.seek(0)
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
handle.close()
_LOCK.release()
def validate_passphrase(passphrase: str) -> None:
if not isinstance(passphrase, str) or not 12 <= len(passphrase) <= 1024:
raise BackupError("Use a backup passphrase between 12 and 1024 characters")
def _key(passphrase: str, salt: bytes) -> bytes:
validate_passphrase(passphrase)
return Scrypt(salt=salt, length=32, n=2**15, r=8, p=1).derive(passphrase.encode("utf-8"))
def _encrypt(content: bytes, passphrase: str) -> bytes:
salt, nonce = os.urandom(16), os.urandom(12)
header = MAGIC + salt + nonce
return header + AESGCM(_key(passphrase, salt)).encrypt(nonce, content, header)
def _decrypt(content: bytes, passphrase: str) -> bytes:
header_size = len(MAGIC) + 28
if len(content) > MAX_UPLOAD_BYTES:
raise BackupError("Backup exceeds the 32 MiB upload limit")
if len(content) < header_size + 16 or not content.startswith(MAGIC):
raise BackupError("This is not a supported encrypted Magent backup")
salt = content[len(MAGIC):len(MAGIC) + 16]
nonce = content[len(MAGIC) + 16:header_size]
try:
return AESGCM(_key(passphrase, salt)).decrypt(nonce, content[header_size:], content[:header_size])
except InvalidTag as exc:
raise BackupError("Incorrect passphrase or damaged backup") from exc
def _database_copy(source: Path, destination: Path) -> None:
if not source.is_file() or source.is_symlink():
raise BackupError("The configured database is unavailable or is a symbolic link")
deadline = time.monotonic() + 60
def progress(_status: int, _remaining: int, _total: int) -> None:
if time.monotonic() > deadline:
raise BackupError("Database is too busy to back up; try again shortly")
with closing(sqlite3.connect(source.as_uri() + "?mode=ro", uri=True)) as src:
with closing(sqlite3.connect(destination)) as dst:
destination.chmod(0o600)
src.backup(dst, pages=256, progress=progress, sleep=0.05)
dst.execute("PRAGMA journal_mode=DELETE")
def _portable_database(path: Path) -> None:
"""Materialize env-backed settings and remove source-specific encryption."""
with closing(sqlite3.connect(path)) as conn, conn:
conn.execute("PRAGMA secure_delete=ON")
# init_db recreates application-owned triggers after restoration; never
# distribute executable schema objects in a data backup.
for (trigger,) in conn.execute("SELECT name FROM sqlite_master WHERE type='trigger'").fetchall():
quoted = str(trigger).replace('"', '""')
conn.execute(f'DROP TRIGGER "{quoted}"')
overrides = dict(conn.execute("SELECT key, value FROM settings"))
for key, default in settings.model_dump().items():
if key in _LOCAL_FIELDS:
continue
value = overrides.get(key)
value = default if value is None else decrypt_setting_value(key, value)
conn.execute(
"INSERT INTO settings(key,value,updated_at) VALUES (?,?,?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at",
(key, "" if value is None else str(value), _now()),
)
for key in _LOCAL_FIELDS:
conn.execute("DELETE FROM settings WHERE key=?", (key,))
# Future secret keys may not yet be exposed through Settings.
for key, value in conn.execute("SELECT key,value FROM settings").fetchall():
if key in SENSITIVE_SETTING_KEYS:
conn.execute("UPDATE settings SET value=? WHERE key=?", (decrypt_setting_value(key, value), key))
conn.commit()
conn.execute("VACUUM")
def _asset_allowed(name: str, include_cache: bool) -> bool:
parts = PurePosixPath(name).parts
if name in {"files/branding/logo.png", "files/branding/favicon.ico"}:
return True
return bool(
include_cache and len(parts) == 5 and parts[:3] == ("files", "artwork", "tmdb")
and parts[3] in _TMDB_SIZES and _ASSET_NAME.fullmatch(parts[4])
and parts[4] not in {".", ".."}
)
def _asset_files(include_cache: bool) -> Iterator[tuple[Path, str]]:
root = _assets_root()
for directory in ("branding", "artwork") if include_cache else ("branding",):
base = root / directory
if not base.exists():
continue
if base.is_symlink() or root.is_symlink():
raise BackupError("Asset directories must not be symbolic links")
for parent, directories, files in os.walk(base, followlinks=False):
if any((Path(parent) / name).is_symlink() for name in directories + files):
raise BackupError("Symbolic links are not supported in backup assets")
for filename in files:
path = Path(parent) / filename
archive_name = "files/" + path.relative_to(root).as_posix()
if _asset_allowed(archive_name, include_cache):
yield path, archive_name
def create_backup(passphrase: str, include_cache: bool = False) -> tuple[bytes, str]:
validate_passphrase(passphrase)
with _exclusive_operation(), tempfile.TemporaryDirectory(prefix="export-", dir=_control_root()) as temporary:
directory = Path(temporary)
directory.chmod(0o700)
database = directory / "database.sqlite3"
_database_copy(Path(_db_path()).absolute(), database)
_portable_database(database)
files = [(database, "database.sqlite3"), *_asset_files(include_cache)]
if len(files) > MAX_ENTRIES - 1 or sum(path.stat().st_size for path, _ in files) > MAX_EXPANDED_BYTES:
raise BackupError("Backup is too large; retry without the artwork cache")
archive_path = directory / "payload.zip"
manifest = {
"format_version": FORMAT_VERSION, "created_at": _now(),
"build": str(settings.site_build_number or "unknown"), "include_cache": include_cache,
"files": {},
}
with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
archive_path.chmod(0o600)
total = 0
for path, name in files:
digest = hashlib.sha256()
size = 0
with path.open("rb") as source, archive.open(name, "w") as destination:
while chunk := source.read(1024 * 1024):
total += len(chunk)
size += len(chunk)
if total > MAX_EXPANDED_BYTES:
raise BackupError("Backup is too large; retry without the artwork cache")
digest.update(chunk)
destination.write(chunk)
manifest["files"][name] = {"bytes": size, "sha256": digest.hexdigest()}
archive.writestr("manifest.json", json.dumps(manifest))
if archive_path.stat().st_size > MAX_UPLOAD_BYTES - 128:
raise BackupError("Backup exceeds the 32 MiB limit; retry without the artwork cache")
encrypted = _encrypt(archive_path.read_bytes(), passphrase)
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
return encrypted, f"magent-backup-{stamp}.magent-backup"
def _validate_database(path: Path, *, verify_settings_encryption: bool = False) -> None:
try:
with closing(sqlite3.connect(path.as_uri() + "?mode=ro", uri=True)) as conn:
conn.execute("PRAGMA trusted_schema=OFF")
deadline = time.monotonic() + 30
conn.set_progress_handler(lambda: int(time.monotonic() > deadline), 10_000)
if conn.execute("PRAGMA integrity_check").fetchall() != [("ok",)]:
raise BackupError("Backup database failed its integrity check")
schema = conn.execute("SELECT type,name,sql FROM sqlite_master").fetchall()
if len(schema) > 500 or any(
kind in {"trigger", "view"} or "VIRTUAL TABLE" in str(sql).upper()
for kind, _name, sql in schema
):
raise BackupError("Backup contains an unsupported database schema")
if conn.execute("PRAGMA foreign_key_check").fetchone() is not None:
raise BackupError("Backup database contains broken references")
required = {
"settings": {"key", "value", "updated_at"},
"users": {"id", "username", "password_hash", "role", "is_blocked", "auth_version"},
"signup_invites": {"id", "code", "enabled"},
"requests_cache": {"request_id", "payload_json"},
"schema_migrations": {"version", "name", "applied_at"},
"password_reset_tokens": {"id", "token_hash"},
}
for table, fields in required.items():
columns = {row[1] for row in conn.execute(f'PRAGMA table_info("{table}")')}
if not fields <= columns:
raise BackupError("Backup does not contain a compatible Magent database")
optional = {
"installation_setup": {"id", "completed", "step", "completed_at"},
"installation_setup_attempts": {"scope", "key_hash", "occurred_at"},
}
table_names = {name for kind, name, _sql in schema if kind == "table"}
for table, fields in optional.items():
if table in table_names:
columns = {row[1] for row in conn.execute(f'PRAGMA table_info("{table}")')}
if not fields <= columns:
raise BackupError("Backup setup state has an incompatible schema")
# An admin can stage a restore only after target initialization. Its
# schema is a trusted reference for *all* runtime columns, including
# versioned migrations that init_db will not rerun on a restored DB.
target = Path(_db_path()).absolute()
if target.is_file() and target != path:
with closing(sqlite3.connect(target.as_uri() + "?mode=ro", uri=True)) as reference:
tables = [row[0] for row in reference.execute("SELECT name FROM sqlite_master WHERE type='table'")]
for table in tables:
if table.startswith("sqlite_") or table in {"installation_setup", "installation_setup_attempts"}:
continue
quoted = str(table).replace('"', '""')
expected = {
row[1]: (row[2].upper(), bool(row[3]), row[5])
for row in reference.execute(f'PRAGMA table_info("{quoted}")')
}
actual = {
row[1]: (row[2].upper(), bool(row[3]), row[5])
for row in conn.execute(f'PRAGMA table_info("{quoted}")')
}
if expected != actual:
raise BackupError("Backup is missing database columns required by this installation")
versions = {int(row[0]) for row in conn.execute("SELECT version FROM schema_migrations")}
if versions != {migration.version for migration in MIGRATIONS}:
raise BackupError("Backup schema is incompatible; restore using the same Magent version")
if not conn.execute(
"SELECT 1 FROM users WHERE role='admin' AND is_blocked=0 AND password_hash IS NOT NULL LIMIT 1"
).fetchone():
raise BackupError("Backup must contain an active administrator account")
values = dict(conn.execute("SELECT key,value FROM settings"))
if _LOCAL_FIELDS.intersection(values):
raise BackupError("Backup contains host-specific configuration")
# Pydantic checks the types of portable settings without reading env values.
for key, value in values.items():
if verify_settings_encryption and key in SENSITIVE_SETTING_KEYS:
value = decrypt_setting_value(key, value)
if key in Settings.model_fields and value not in {None, ""}:
field = Settings.model_fields[key]
TypeAdapter(field.rebuild_annotation()).validate_python(value)
except (sqlite3.DatabaseError, TypeError, ValueError, RuntimeError) as exc:
if isinstance(exc, BackupError):
raise
raise BackupError("Backup database or configuration is invalid") from exc
def _extract_archive(payload: bytes, directory: Path) -> dict[str, Any]:
try:
with zipfile.ZipFile(io.BytesIO(payload)) as archive:
entries = archive.infolist()
if not entries or len(entries) > MAX_ENTRIES:
raise BackupError("Backup contains too many files")
names = [entry.filename for entry in entries]
if len(set(names)) != len(names) or "manifest.json" not in names or "database.sqlite3" not in names:
raise BackupError("Backup manifest is missing or contains duplicate files")
if sum(entry.file_size for entry in entries) > MAX_EXPANDED_BYTES:
raise BackupError("Expanded backup exceeds the 128 MiB limit")
for entry in entries:
parts = PurePosixPath(entry.filename).parts
mode = entry.external_attr >> 16
if (
entry.is_dir() or entry.filename.startswith("/") or "\\" in entry.filename
or str(PurePosixPath(entry.filename)) != entry.filename
or ":" in entry.filename or any(part in {".", ".."} for part in parts)
or (stat.S_IFMT(mode) not in {0, stat.S_IFREG}) or entry.flag_bits & 1
or entry.compress_type not in {zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED}
):
raise BackupError("Backup contains an unsafe archive entry")
if archive.getinfo("manifest.json").file_size > 4 * 1024 * 1024:
raise BackupError("Backup manifest is too large")
manifest = json.loads(archive.read("manifest.json"))
if (
not isinstance(manifest, dict) or manifest.get("format_version") != FORMAT_VERSION
or not isinstance(manifest.get("include_cache"), bool)
or not isinstance(manifest.get("created_at"), str) or len(manifest["created_at"]) > 64
or not isinstance(manifest.get("build"), str) or len(manifest["build"]) > 100
or not isinstance(manifest.get("files"), dict)
or set(manifest["files"]) != set(names) - {"manifest.json"}
):
raise BackupError("Backup manifest is invalid or unsupported")
extracted_bytes = 0
for entry in entries:
name = entry.filename
if name == "manifest.json":
continue
if name != "database.sqlite3" and not _asset_allowed(name, manifest["include_cache"]):
raise BackupError("Backup contains an unsupported file")
expected = manifest["files"][name]
if not isinstance(expected, dict) or expected.get("bytes") != entry.file_size:
raise BackupError("Backup file does not match its manifest")
target = directory.joinpath(*PurePosixPath(name).parts)
_private_dir(target.parent)
digest = hashlib.sha256()
with archive.open(entry) as source, target.open("xb") as destination:
target.chmod(0o600)
while chunk := source.read(1024 * 1024):
extracted_bytes += len(chunk)
if extracted_bytes > MAX_EXPANDED_BYTES:
raise BackupError("Expanded backup exceeds the 128 MiB limit")
digest.update(chunk)
destination.write(chunk)
destination.flush()
os.fsync(destination.fileno())
if digest.hexdigest() != expected.get("sha256"):
raise BackupError("Backup file failed its checksum")
_validate_database(directory / "database.sqlite3")
return manifest
except (zipfile.BadZipFile, KeyError, TypeError, ValueError, RuntimeError, zlib.error) as exc:
if isinstance(exc, BackupError):
raise
raise BackupError("Backup archive is invalid or damaged") from exc
def stage_restore(source: BinaryIO, passphrase: str) -> dict[str, Any]:
validate_passphrase(passphrase)
with _exclusive_operation():
root = _control_root()
pending = root / "pending"
if pending.exists():
raise BackupError("A restore is already staged; cancel it before uploading another")
payload = _decrypt(source.read(MAX_UPLOAD_BYTES + 1), passphrase)
destination_origin = None
if managed_runtime():
from .public_urls import magent_public_url
try:
destination_origin = normalize_application_origin(magent_public_url())
except ValueError:
raise BackupError("Configure a valid destination application address before restoring a backup") from None
with tempfile.TemporaryDirectory(prefix="validate-", dir=root) as temporary:
stage = Path(temporary)
stage.chmod(0o700)
manifest = _extract_archive(payload, stage)
with closing(sqlite3.connect(stage / "database.sqlite3")) as conn, conn:
conn.execute("PRAGMA secure_delete=ON")
for key, value in conn.execute("SELECT key,value FROM settings").fetchall():
if key in SENSITIVE_SETTING_KEYS:
if value and str(value).startswith("enc:v1:"):
raise BackupError("Backup settings are not portable")
conn.execute("UPDATE settings SET value=? WHERE key=?", (encrypt_setting_value(key, value), key))
if destination_origin is not None:
# The backup's hostname must not replace this installation's
# trusted browser origin or change its cookie policy.
conn.execute(
"INSERT INTO settings(key,value,updated_at) VALUES ('magent_application_url',?,?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=excluded.updated_at",
(destination_origin, _now()),
)
# Do not revive reset links or existing browser sessions. Invites remain intact.
conn.execute("DELETE FROM password_reset_tokens")
conn.execute("UPDATE users SET auth_version=?", (secrets.randbelow(2**52) + 1_000_000,))
if not manifest["include_cache"]:
conn.execute("UPDATE artwork_cache_status SET poster_cached=0,backdrop_cached=0")
conn.commit()
# Remove plaintext secret remnants from replaced/free SQLite pages.
conn.execute("VACUUM")
metadata = {key: manifest[key] for key in ("created_at", "build", "include_cache")}
metadata["staged_at"] = _now()
_write_json(stage / "metadata.json", metadata)
# Stage survives reboot; it contains only secrets encrypted for this installation.
os.replace(stage, pending)
_sync_directory(root)
return metadata
def backup_status() -> dict[str, Any]:
root = _control_root()
pending_path = root / "pending" / "metadata.json"
last_path = root / "last-restore.json"
return {
"format_version": FORMAT_VERSION, "max_upload_bytes": MAX_UPLOAD_BYTES,
"max_expanded_bytes": MAX_EXPANDED_BYTES,
"include_cache_default": False,
"pending_restore": json.loads(pending_path.read_text()) if pending_path.is_file() else None,
"last_restore": json.loads(last_path.read_text()) if last_path.is_file() else None,
}
def cancel_restore() -> None:
with _exclusive_operation():
pending = _control_root() / "pending"
if pending.is_symlink():
raise BackupError("Invalid staged restore directory")
if pending.exists():
shutil.rmtree(pending)
def _replace_file(source: Path, target: Path) -> None:
_private_dir(target.parent)
temporary = target.with_name(target.name + ".restore-" + uuid.uuid4().hex)
try:
shutil.copyfile(source, temporary)
temporary.chmod(0o600)
with temporary.open("r+b") as handle:
os.fsync(handle.fileno())
os.replace(temporary, target)
_sync_directory(target.parent)
finally:
temporary.unlink(missing_ok=True)
def _replace_assets(source: Path, target: Path) -> None:
if target.is_symlink():
raise BackupError("Asset directories must not be symbolic links")
if target.exists():
shutil.rmtree(target)
if source.exists():
shutil.copytree(source, target, copy_function=shutil.copyfile)
for parent, _directories, files in os.walk(target):
Path(parent).chmod(0o700)
for filename in files:
(Path(parent) / filename).chmod(0o600)
_sync_tree(target)
if target.parent.exists():
_sync_directory(target.parent)
def _recover(journal: dict, root: Path) -> None:
rollback_name = journal.get("rollback_directory", "")
if not re.fullmatch(r"rollback-[0-9a-f]{32}", rollback_name):
raise BackupError("Restore recovery journal is invalid")
rollback = root / rollback_name
database = Path(_db_path()).absolute()
if journal["had_database"]:
_replace_file(rollback / "database.sqlite3", database)
else:
database.unlink(missing_ok=True)
for suffix in ("-wal", "-shm", "-journal"):
Path(str(database) + suffix).unlink(missing_ok=True)
for name in journal["asset_roots"]:
if name not in {"branding", "artwork"}:
raise BackupError("Restore recovery journal is invalid")
_replace_assets(rollback / "files" / name, _assets_root() / name)
_write_json(root / "last-restore.json", {
"status": "rolled_back", "restored_at": _now(), "rollback_directory": rollback.name,
"message": "An interrupted or failed restore was rolled back automatically.",
})
_write_json(root / "restore-journal.json", {**journal, "phase": "rolled_back"})
pending = root / "pending"
if pending.exists():
shutil.rmtree(pending)
(root / "restore-journal.json").unlink()
_sync_directory(root)
def apply_pending_restore() -> bool:
"""Call once before init_db, with no other backend processes using the DB."""
with _exclusive_operation():
root = _control_root()
journal_path = root / "restore-journal.json"
if journal_path.exists():
journal = json.loads(journal_path.read_text())
if journal.get("phase") in {"complete", "rolled_back"}:
if (root / "pending").exists():
shutil.rmtree(root / "pending")
journal_path.unlink()
_sync_directory(root)
return journal["phase"] == "complete"
_recover(journal, root)
return False
pending = root / "pending"
if not pending.exists():
return False
if pending.is_symlink():
raise BackupError("Invalid staged restore directory")
metadata = json.loads((pending / "metadata.json").read_text())
_validate_database(pending / "database.sqlite3", verify_settings_encryption=True)
database = Path(_db_path()).absolute()
rollback = root / ("rollback-" + uuid.uuid4().hex)
_private_dir(rollback)
# Ensure all disk-space/permission failures in backup happen before replacement.
if database.exists():
_database_copy(database, rollback / "database.sqlite3")
names = ["branding", "artwork"] if metadata["include_cache"] else ["branding"]
# Reject links anywhere before copying or deleting the controlled asset trees.
list(_asset_files(metadata["include_cache"]))
for name in names:
source = _assets_root() / name
if source.exists():
shutil.copytree(source, rollback / "files" / name)
_sync_tree(rollback)
journal = {"rollback_directory": rollback.name, "had_database": database.exists(), "asset_roots": names}
_write_json(journal_path, journal)
try:
for suffix in ("-wal", "-shm", "-journal"):
Path(str(database) + suffix).unlink(missing_ok=True)
_replace_file(pending / "database.sqlite3", database)
for name in names:
_replace_assets(pending / "files" / name, _assets_root() / name)
_write_json(root / "last-restore.json", {
"status": "restored", "restored_at": _now(), "rollback_directory": rollback.name,
"backup_created_at": metadata["created_at"],
})
_write_json(journal_path, {**journal, "phase": "complete"})
except Exception:
_recover(journal, root)
raise
shutil.rmtree(pending)
journal_path.unlink()
_sync_directory(root)
return True
+61
View File
@@ -0,0 +1,61 @@
"""Read title-specific search activity without starting a search or changing monitoring."""
from typing import Any
from ..clients.base import ApiClient
from ..models import RequestType
def _ids(values: Any) -> set[int]:
if not isinstance(values, list):
return set()
return {value for value in values if type(value) is int and value > 0}
def search_status(commands: Any, request_type: RequestType, item_id: int, episodes: Any = None) -> str:
"""Only a matching queued/started search is evidence of current activity.
Completed commands, RSS syncs and library-wide jobs do not establish that this
title is being searched. Episode searches are matched using Sonarr episode IDs.
"""
if not isinstance(commands, list):
return "unavailable"
episode_ids = _ids([
episode.get("id") for episode in (episodes if isinstance(episodes, list) else [])
if isinstance(episode, dict) and episode.get("seriesId", item_id) == item_id
])
queued = False
for command in commands:
if not isinstance(command, dict):
continue
body = command.get("body")
if not isinstance(body, dict):
continue
name = str(command.get("name") or body.get("name") or "").lower()
if request_type == RequestType.movie:
matches = name == "moviessearch" and item_id in _ids(body.get("movieIds"))
else:
matches = (
name in {"seriessearch", "seasonsearch"} and body.get("seriesId") == item_id
) or (
name == "episodesearch" and bool(episode_ids & _ids(body.get("episodeIds")))
)
if not matches or command.get("ended"):
continue
status = str(command.get("status", "")).lower()
if status in {"started", "1"}:
return "searching"
if status in {"queued", "0"}:
queued = True
return "queued" if queued else "idle"
async def read_search_status(
client: ApiClient, request_type: RequestType, item_id: int, episodes: Any = None,
) -> str:
try:
commands = await client.get("/api/v3/command", timeout_seconds=3.0)
except Exception:
# Search telemetry must not turn a healthy library record into an error.
return "unavailable"
return search_status(commands, request_type, item_id, episodes)
+735
View File
@@ -0,0 +1,735 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from datetime import datetime, timezone
from time import perf_counter
from typing import Any, Awaitable, Callable, Dict, List, Optional, Sequence
from urllib.parse import urlparse
import httpx
from ..clients.jellyfin import JellyfinClient
from ..clients.jellyseerr import JellyseerrClient
from ..clients.prowlarr import ProwlarrClient
from ..clients.qbittorrent import QBittorrentClient
from ..clients.radarr import RadarrClient
from ..clients.sonarr import SonarrClient
from ..config import settings as env_settings
from ..db import get_database_diagnostics
from ..network_security import validate_notification_target_url
from ..runtime import get_runtime_settings
from .invite_email import send_test_email, smtp_email_config_ready, smtp_email_delivery_warning
DiagnosticRunner = Callable[[], Awaitable[Dict[str, Any]]]
@dataclass(frozen=True)
class DiagnosticCheck:
key: str
label: str
category: str
description: str
live_safe: bool
configured: bool
config_detail: str
target: Optional[str]
runner: DiagnosticRunner
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _clean_text(value: Any, fallback: str = "") -> str:
if value is None:
return fallback
if isinstance(value, str):
trimmed = value.strip()
return trimmed if trimmed else fallback
return str(value)
def _url_target(url: Optional[str]) -> Optional[str]:
raw = _clean_text(url)
if not raw:
return None
try:
parsed = urlparse(raw)
except Exception:
return raw
host = parsed.hostname or parsed.netloc or raw
if parsed.port:
host = f"{host}:{parsed.port}"
return host
def _host_port_target(host: Optional[str], port: Optional[int]) -> Optional[str]:
resolved_host = _clean_text(host)
if not resolved_host:
return None
if port is None:
return resolved_host
return f"{resolved_host}:{port}"
def _http_error_detail(exc: Exception) -> str:
if isinstance(exc, httpx.HTTPStatusError):
response = exc.response
body = ""
try:
body = response.text.strip()
except Exception:
body = ""
if body:
return f"HTTP {response.status_code}: {body}"
return f"HTTP {response.status_code}"
return str(exc)
def _config_status(detail: str) -> str:
lowered = detail.lower()
if "disabled" in lowered:
return "disabled"
return "not_configured"
def _discord_config_ready(runtime) -> tuple[bool, str]:
if not runtime.magent_notify_enabled or not runtime.magent_notify_discord_enabled:
return False, "Discord notifications are disabled."
webhook_url = _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(runtime.discord_webhook_url)
if webhook_url:
try:
validate_notification_target_url(webhook_url)
except ValueError as exc:
return False, str(exc)
return True, "ok"
return False, "Discord webhook URL is required."
def _telegram_config_ready(runtime) -> tuple[bool, str]:
if not runtime.magent_notify_enabled or not runtime.magent_notify_telegram_enabled:
return False, "Telegram notifications are disabled."
if _clean_text(runtime.magent_notify_telegram_bot_token) and _clean_text(runtime.magent_notify_telegram_chat_id):
return True, "ok"
return False, "Telegram bot token and chat ID are required."
def _webhook_config_ready(runtime) -> tuple[bool, str]:
if not runtime.magent_notify_enabled or not runtime.magent_notify_webhook_enabled:
return False, "Generic webhook notifications are disabled."
webhook_url = _clean_text(runtime.magent_notify_webhook_url)
if webhook_url:
try:
validate_notification_target_url(webhook_url)
except ValueError as exc:
return False, str(exc)
return True, "ok"
return False, "Generic webhook URL is required."
def _push_config_ready(runtime) -> tuple[bool, str]:
if not runtime.magent_notify_enabled or not runtime.magent_notify_push_enabled:
return False, "Push notifications are disabled."
provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
if provider == "ntfy":
push_url = _clean_text(runtime.magent_notify_push_base_url)
if push_url and _clean_text(runtime.magent_notify_push_topic):
try:
validate_notification_target_url(push_url)
except ValueError as exc:
return False, str(exc)
return True, "ok"
return False, "ntfy requires a base URL and topic."
if provider == "gotify":
push_url = _clean_text(runtime.magent_notify_push_base_url)
if push_url and _clean_text(runtime.magent_notify_push_token):
try:
validate_notification_target_url(push_url)
except ValueError as exc:
return False, str(exc)
return True, "ok"
return False, "Gotify requires a base URL and app token."
if provider == "pushover":
if _clean_text(runtime.magent_notify_push_token) and _clean_text(runtime.magent_notify_push_user_key):
return True, "ok"
return False, "Pushover requires an application token and user key."
if provider == "webhook":
push_url = _clean_text(runtime.magent_notify_push_base_url)
if push_url:
try:
validate_notification_target_url(push_url)
except ValueError as exc:
return False, str(exc)
return True, "ok"
return False, "Webhook relay requires a target URL."
if provider == "telegram":
return _telegram_config_ready(runtime)
if provider == "discord":
return _discord_config_ready(runtime)
return False, f"Unsupported push provider: {provider or 'unknown'}"
def _summary_from_results(results: Sequence[Dict[str, Any]]) -> Dict[str, int]:
summary = {
"total": len(results),
"up": 0,
"down": 0,
"degraded": 0,
"not_configured": 0,
"disabled": 0,
}
for result in results:
status = str(result.get("status") or "").strip().lower()
if status in summary:
summary[status] += 1
return summary
async def _run_http_json_get(
url: str,
*,
headers: Optional[Dict[str, str]] = None,
params: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
response = await client.get(url, headers=headers, params=params)
response.raise_for_status()
payload = response.json()
return {"response": payload}
async def _run_http_text_get(url: str) -> Dict[str, Any]:
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
response = await client.get(url)
response.raise_for_status()
body = response.text
return {"response": body, "message": f"HTTP {response.status_code}"}
async def _run_http_post(
url: str,
*,
json_payload: Optional[Dict[str, Any]] = None,
data_payload: Any = None,
params: Optional[Dict[str, Any]] = None,
headers: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]:
validate_notification_target_url(url)
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
response = await client.post(url, json=json_payload, data=data_payload, params=params, headers=headers)
response.raise_for_status()
if not response.content:
return {"message": f"HTTP {response.status_code}"}
content_type = response.headers.get("content-type", "")
if "application/json" in content_type.lower():
try:
return {"response": response.json(), "message": f"HTTP {response.status_code}"}
except Exception:
pass
return {"response": response.text.strip(), "message": f"HTTP {response.status_code}"}
async def _run_database_check() -> Dict[str, Any]:
detail = await asyncio.to_thread(get_database_diagnostics)
integrity = _clean_text(detail.get("integrity_check"), "unknown")
requests_cached = detail.get("row_counts", {}).get("requests_cache", 0) if isinstance(detail, dict) else 0
wal_size_bytes = detail.get("wal_size_bytes", 0) if isinstance(detail, dict) else 0
wal_size_megabytes = round((float(wal_size_bytes or 0) / (1024 * 1024)), 2)
status = "up" if integrity == "ok" else "degraded"
return {
"status": status,
"message": f"SQLite {integrity} · {requests_cached} cached requests · WAL {wal_size_megabytes:.2f} MB",
"detail": detail,
}
async def _run_magent_api_check(runtime) -> Dict[str, Any]:
base_url = _clean_text(runtime.magent_api_url) or f"http://127.0.0.1:{int(runtime.magent_api_port or 8000)}"
result = await _run_http_json_get(f"{base_url.rstrip('/')}/health")
payload = result.get("response")
build_number = payload.get("build") if isinstance(payload, dict) else None
message = "Health endpoint responded"
if build_number:
message = f"Health endpoint responded (build {build_number})"
return {"message": message, "detail": payload}
async def _run_magent_web_check(runtime) -> Dict[str, Any]:
base_url = _clean_text(runtime.magent_application_url) or f"http://127.0.0.1:{int(runtime.magent_application_port or 3000)}"
result = await _run_http_text_get(base_url.rstrip("/"))
body = result.get("response")
if isinstance(body, str) and "<html" in body.lower():
return {"message": "Application page responded", "detail": "html"}
return {"status": "degraded", "message": "Application responded with unexpected content"}
async def _run_seerr_check(runtime) -> Dict[str, Any]:
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
payload = await client.get_status()
version = payload.get("version") if isinstance(payload, dict) else None
message = "Seerr responded"
if version:
message = f"Seerr version {version}"
return {"message": message, "detail": payload}
async def _run_sonarr_check(runtime) -> Dict[str, Any]:
client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
payload = await client.get_system_status()
version = payload.get("version") if isinstance(payload, dict) else None
message = "Sonarr responded"
if version:
message = f"Sonarr version {version}"
return {"message": message, "detail": payload}
async def _run_radarr_check(runtime) -> Dict[str, Any]:
client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
payload = await client.get_system_status()
version = payload.get("version") if isinstance(payload, dict) else None
message = "Radarr responded"
if version:
message = f"Radarr version {version}"
return {"message": message, "detail": payload}
async def _run_prowlarr_check(runtime) -> Dict[str, Any]:
client = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
payload = await client.get_health()
if isinstance(payload, list) and payload:
return {
"status": "degraded",
"message": f"Prowlarr health warnings: {len(payload)}",
"detail": payload,
}
return {"message": "Prowlarr reported healthy", "detail": payload}
async def _run_qbittorrent_check(runtime) -> Dict[str, Any]:
client = QBittorrentClient(
runtime.qbittorrent_base_url,
runtime.qbittorrent_username,
runtime.qbittorrent_password,
)
version = await client.get_app_version()
message = "qBittorrent responded"
if isinstance(version, str) and version:
message = f"qBittorrent version {version}"
return {"message": message, "detail": version}
async def _run_jellyfin_check(runtime) -> Dict[str, Any]:
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
payload = await client.get_system_info()
version = payload.get("Version") if isinstance(payload, dict) else None
message = "Jellyfin responded"
if version:
message = f"Jellyfin version {version}"
return {"message": message, "detail": payload}
async def _run_email_check(recipient_email: Optional[str] = None) -> Dict[str, Any]:
result = await send_test_email(recipient_email=recipient_email)
recipient = _clean_text(result.get("recipient_email"), "configured recipient")
warning = _clean_text(result.get("warning"))
if warning:
return {
"status": "degraded",
"message": f"SMTP relay accepted a test for {recipient}, but delivery is not guaranteed.",
"detail": result,
}
return {"message": f"Test email sent to {recipient}", "detail": result}
async def _run_discord_check(runtime) -> Dict[str, Any]:
webhook_url = _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(runtime.discord_webhook_url)
payload = {
"content": f"{env_settings.app_name} diagnostics ping\nBuild {env_settings.site_build_number or 'unknown'}",
}
result = await _run_http_post(webhook_url, json_payload=payload)
return {"message": "Discord webhook accepted ping", "detail": result.get("response")}
async def _run_telegram_check(runtime) -> Dict[str, Any]:
bot_token = _clean_text(runtime.magent_notify_telegram_bot_token)
chat_id = _clean_text(runtime.magent_notify_telegram_chat_id)
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
payload = {
"chat_id": chat_id,
"text": f"{env_settings.app_name} diagnostics ping\nBuild {env_settings.site_build_number or 'unknown'}",
}
result = await _run_http_post(url, json_payload=payload)
return {"message": "Telegram ping accepted", "detail": result.get("response")}
async def _run_webhook_check(runtime) -> Dict[str, Any]:
webhook_url = _clean_text(runtime.magent_notify_webhook_url)
payload = {
"type": "diagnostics.ping",
"application": env_settings.app_name,
"build": env_settings.site_build_number,
"checked_at": _now_iso(),
}
result = await _run_http_post(webhook_url, json_payload=payload)
return {"message": "Webhook accepted ping", "detail": result.get("response")}
async def _run_push_check(runtime) -> Dict[str, Any]:
provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
message = f"{env_settings.app_name} diagnostics ping"
build_suffix = f"Build {env_settings.site_build_number or 'unknown'}"
if provider == "ntfy":
base_url = _clean_text(runtime.magent_notify_push_base_url)
topic = _clean_text(runtime.magent_notify_push_topic)
result = await _run_http_post(
f"{base_url.rstrip('/')}/{topic}",
data_payload=f"{message}\n{build_suffix}",
headers={"Content-Type": "text/plain; charset=utf-8"},
)
return {"message": "ntfy push accepted", "detail": result.get("response")}
if provider == "gotify":
base_url = _clean_text(runtime.magent_notify_push_base_url)
token = _clean_text(runtime.magent_notify_push_token)
result = await _run_http_post(
f"{base_url.rstrip('/')}/message",
json_payload={"title": env_settings.app_name, "message": build_suffix, "priority": 5},
params={"token": token},
)
return {"message": "Gotify push accepted", "detail": result.get("response")}
if provider == "pushover":
token = _clean_text(runtime.magent_notify_push_token)
user_key = _clean_text(runtime.magent_notify_push_user_key)
device = _clean_text(runtime.magent_notify_push_device)
payload = {
"token": token,
"user": user_key,
"message": f"{message}\n{build_suffix}",
"title": env_settings.app_name,
}
if device:
payload["device"] = device
result = await _run_http_post("https://api.pushover.net/1/messages.json", data_payload=payload)
return {"message": "Pushover push accepted", "detail": result.get("response")}
if provider == "webhook":
base_url = _clean_text(runtime.magent_notify_push_base_url)
payload = {
"type": "diagnostics.push",
"application": env_settings.app_name,
"build": env_settings.site_build_number,
"checked_at": _now_iso(),
}
result = await _run_http_post(base_url, json_payload=payload)
return {"message": "Push webhook accepted", "detail": result.get("response")}
if provider == "telegram":
return await _run_telegram_check(runtime)
if provider == "discord":
return await _run_discord_check(runtime)
raise RuntimeError(f"Unsupported push provider: {provider}")
def _build_diagnostic_checks(recipient_email: Optional[str] = None) -> List[DiagnosticCheck]:
runtime = get_runtime_settings()
seerr_target = _url_target(runtime.jellyseerr_base_url)
jellyfin_target = _url_target(runtime.jellyfin_base_url)
sonarr_target = _url_target(runtime.sonarr_base_url)
radarr_target = _url_target(runtime.radarr_base_url)
prowlarr_target = _url_target(runtime.prowlarr_base_url)
qbittorrent_target = _url_target(runtime.qbittorrent_base_url)
application_target = _url_target(runtime.magent_application_url) or _host_port_target("127.0.0.1", runtime.magent_application_port)
api_target = _url_target(runtime.magent_api_url) or _host_port_target("127.0.0.1", runtime.magent_api_port)
smtp_target = _host_port_target(runtime.magent_notify_email_smtp_host, runtime.magent_notify_email_smtp_port)
discord_target = _url_target(runtime.magent_notify_discord_webhook_url) or _url_target(runtime.discord_webhook_url)
telegram_target = "api.telegram.org" if _clean_text(runtime.magent_notify_telegram_bot_token) else None
webhook_target = _url_target(runtime.magent_notify_webhook_url)
push_provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
push_target = None
if push_provider == "pushover":
push_target = "api.pushover.net"
elif push_provider == "telegram":
push_target = telegram_target or "api.telegram.org"
elif push_provider == "discord":
push_target = discord_target or "discord.com"
else:
push_target = _url_target(runtime.magent_notify_push_base_url)
email_ready, email_detail = smtp_email_config_ready()
email_warning = smtp_email_delivery_warning()
discord_ready, discord_detail = _discord_config_ready(runtime)
telegram_ready, telegram_detail = _telegram_config_ready(runtime)
push_ready, push_detail = _push_config_ready(runtime)
webhook_ready, webhook_detail = _webhook_config_ready(runtime)
checks = [
DiagnosticCheck(
key="magent-web",
label="Magent application",
category="Application",
description="Checks that the frontend application URL is responding.",
live_safe=True,
configured=True,
config_detail="ok",
target=application_target,
runner=lambda runtime=runtime: _run_magent_web_check(runtime),
),
DiagnosticCheck(
key="magent-api",
label="Magent API",
category="Application",
description="Checks the Magent API health endpoint.",
live_safe=True,
configured=True,
config_detail="ok",
target=api_target,
runner=lambda runtime=runtime: _run_magent_api_check(runtime),
),
DiagnosticCheck(
key="database",
label="SQLite database",
category="Application",
description="Runs SQLite integrity_check against the current Magent database.",
live_safe=True,
configured=True,
config_detail="ok",
target="sqlite",
runner=_run_database_check,
),
DiagnosticCheck(
key="seerr",
label="Seerr",
category="Media services",
description="Checks Seerr API reachability and version.",
live_safe=True,
configured=bool(runtime.jellyseerr_base_url and runtime.jellyseerr_api_key),
config_detail="Seerr URL and API key are required.",
target=seerr_target,
runner=lambda runtime=runtime: _run_seerr_check(runtime),
),
DiagnosticCheck(
key="jellyfin",
label="Jellyfin",
category="Media services",
description="Checks Jellyfin system info with the configured API key.",
live_safe=True,
configured=bool(runtime.jellyfin_base_url and runtime.jellyfin_api_key),
config_detail="Jellyfin URL and API key are required.",
target=jellyfin_target,
runner=lambda runtime=runtime: _run_jellyfin_check(runtime),
),
DiagnosticCheck(
key="sonarr",
label="Sonarr",
category="Media services",
description="Checks Sonarr system status with the configured API key.",
live_safe=True,
configured=bool(runtime.sonarr_base_url and runtime.sonarr_api_key),
config_detail="Sonarr URL and API key are required.",
target=sonarr_target,
runner=lambda runtime=runtime: _run_sonarr_check(runtime),
),
DiagnosticCheck(
key="radarr",
label="Radarr",
category="Media services",
description="Checks Radarr system status with the configured API key.",
live_safe=True,
configured=bool(runtime.radarr_base_url and runtime.radarr_api_key),
config_detail="Radarr URL and API key are required.",
target=radarr_target,
runner=lambda runtime=runtime: _run_radarr_check(runtime),
),
DiagnosticCheck(
key="prowlarr",
label="Prowlarr",
category="Media services",
description="Checks Prowlarr health and flags warnings as degraded.",
live_safe=True,
configured=bool(runtime.prowlarr_base_url and runtime.prowlarr_api_key),
config_detail="Prowlarr URL and API key are required.",
target=prowlarr_target,
runner=lambda runtime=runtime: _run_prowlarr_check(runtime),
),
DiagnosticCheck(
key="qbittorrent",
label="qBittorrent",
category="Media services",
description="Checks qBittorrent login and app version.",
live_safe=True,
configured=bool(
runtime.qbittorrent_base_url and runtime.qbittorrent_username and runtime.qbittorrent_password
),
config_detail="qBittorrent URL, username, and password are required.",
target=qbittorrent_target,
runner=lambda runtime=runtime: _run_qbittorrent_check(runtime),
),
DiagnosticCheck(
key="email",
label="SMTP email",
category="Notifications",
description="Sends a live test email using the configured SMTP provider.",
live_safe=False,
configured=email_ready,
config_detail=email_warning or email_detail,
target=smtp_target,
runner=lambda recipient_email=recipient_email: _run_email_check(recipient_email),
),
DiagnosticCheck(
key="discord",
label="Discord webhook",
category="Notifications",
description="Posts a live test message to the configured Discord webhook.",
live_safe=False,
configured=discord_ready,
config_detail=discord_detail,
target=discord_target,
runner=lambda runtime=runtime: _run_discord_check(runtime),
),
DiagnosticCheck(
key="telegram",
label="Telegram",
category="Notifications",
description="Sends a live test message to the configured Telegram chat.",
live_safe=False,
configured=telegram_ready,
config_detail=telegram_detail,
target=telegram_target,
runner=lambda runtime=runtime: _run_telegram_check(runtime),
),
DiagnosticCheck(
key="push",
label="Push/mobile provider",
category="Notifications",
description="Sends a live test message through the configured push provider.",
live_safe=False,
configured=push_ready,
config_detail=push_detail,
target=push_target,
runner=lambda runtime=runtime: _run_push_check(runtime),
),
DiagnosticCheck(
key="webhook",
label="Generic webhook",
category="Notifications",
description="Posts a live test payload to the configured generic webhook.",
live_safe=False,
configured=webhook_ready,
config_detail=webhook_detail,
target=webhook_target,
runner=lambda runtime=runtime: _run_webhook_check(runtime),
),
]
return checks
async def _execute_check(check: DiagnosticCheck) -> Dict[str, Any]:
if not check.configured:
return {
"key": check.key,
"label": check.label,
"category": check.category,
"description": check.description,
"target": check.target,
"live_safe": check.live_safe,
"configured": False,
"status": _config_status(check.config_detail),
"message": check.config_detail,
"checked_at": _now_iso(),
"duration_ms": 0,
}
started = perf_counter()
checked_at = _now_iso()
try:
payload = await check.runner()
status = _clean_text(payload.get("status"), "up")
message = _clean_text(payload.get("message"), "Check passed")
detail = payload.get("detail")
return {
"key": check.key,
"label": check.label,
"category": check.category,
"description": check.description,
"target": check.target,
"live_safe": check.live_safe,
"configured": True,
"status": status,
"message": message,
"detail": detail,
"checked_at": checked_at,
"duration_ms": round((perf_counter() - started) * 1000, 1),
}
except httpx.HTTPError as exc:
return {
"key": check.key,
"label": check.label,
"category": check.category,
"description": check.description,
"target": check.target,
"live_safe": check.live_safe,
"configured": True,
"status": "down",
"message": _http_error_detail(exc),
"checked_at": checked_at,
"duration_ms": round((perf_counter() - started) * 1000, 1),
}
except Exception as exc:
return {
"key": check.key,
"label": check.label,
"category": check.category,
"description": check.description,
"target": check.target,
"live_safe": check.live_safe,
"configured": True,
"status": "down",
"message": str(exc),
"checked_at": checked_at,
"duration_ms": round((perf_counter() - started) * 1000, 1),
}
def get_diagnostics_catalog() -> Dict[str, Any]:
checks = _build_diagnostic_checks()
items = []
for check in checks:
items.append(
{
"key": check.key,
"label": check.label,
"category": check.category,
"description": check.description,
"live_safe": check.live_safe,
"target": check.target,
"configured": check.configured,
"config_status": "configured" if check.configured else _config_status(check.config_detail),
"config_detail": "Ready to test." if check.configured else check.config_detail,
}
)
categories = sorted({item["category"] for item in items})
return {
"checks": items,
"categories": categories,
"generated_at": _now_iso(),
}
async def run_diagnostics(keys: Optional[Sequence[str]] = None, recipient_email: Optional[str] = None) -> Dict[str, Any]:
checks = _build_diagnostic_checks(recipient_email=recipient_email)
selected = {str(key).strip().lower() for key in (keys or []) if str(key).strip()}
if selected:
checks = [check for check in checks if check.key.lower() in selected]
results = await asyncio.gather(*(_execute_check(check) for check in checks))
return {
"results": results,
"summary": _summary_from_results(results),
"checked_at": _now_iso(),
}
+25
View File
@@ -0,0 +1,25 @@
from typing import Any
def label_episode_downloads(torrents: list[dict], queue: Any) -> list[dict]:
"""Join by collector download ID, never by fuzzy title matching.
A pack shares one transfer percentage; do not pretend its episodes have
individually measured progress.
"""
records = queue.get("records", []) if isinstance(queue, dict) else queue
labels: dict[str, set[str]] = {}
for row in records if isinstance(records, list) else []:
episode = row.get("episode") or {}
season, number = episode.get("seasonNumber"), episode.get("episodeNumber")
if isinstance(season, int) and isinstance(number, int):
key = str(row.get("downloadId") or "").lower()
labels.setdefault(key, set()).add(f"S{season:02d}E{number:02d}")
for torrent in torrents:
episodes = sorted(labels.get(str(torrent.get("hash") or "").lower(), set()))
torrent["episodeLabels"] = episodes
torrent["episodeLabel"] = (
" · ".join(episodes) + (" — shared download progress" if len(episodes) > 1 else "")
if episodes else None
)
return torrents
+197
View File
@@ -0,0 +1,197 @@
"""Reviewed consolidation of accounts sharing a verified Jellyfin ID, entirely within Magent."""
import asyncio
import json
from contextlib import closing
from datetime import datetime, timezone
from fastapi import HTTPException
from .. import db
from ..feature_access import FEATURES
from . import identity_review as review
from .jellyfin_identity import source_key
NAME_REFERENCES = {
'signup_invites': ('created_by',),
'portal_items': ('created_by_username', 'assignee_username'),
'portal_comments': ('author_username',),
'portal_item_activity': ('actor_username',),
'platform_issues': ('reporter_username',),
'platform_issue_events': ('author_username',),
'requests_cache': ('requested_by', 'requested_by_norm'),
}
def account_state(conn, ids):
conn.row_factory = db.sqlite3.Row
placeholders = ','.join('?' for _ in ids)
return {table: [dict(row) for row in conn.execute(
f'SELECT * FROM {table} WHERE {column} IN ({placeholders}) ORDER BY {column}', ids)]
for table, column in [('users', 'id'), ('user_feature_permissions', 'user_id'),
('email_recap_subscriptions', 'user_id'), ('newsletter_subscriptions', 'user_id')]}
def identity_group(report, target):
identity = target['candidate_jellyfin_id']
return [row for row in report['rows'] if identity and row['candidate_jellyfin_id'] == identity]
def build_preview(report, local, runtime, state, user_id, keep_id=None):
target = next((row for row in report['rows'] if row['user']['id'] == user_id), None)
if not target:
raise HTTPException(404, 'This Magent account no longer exists. Run the check again.')
group = identity_group(report, target)
ids = {row['user']['id'] for row in group}
if len(ids) < 2:
raise HTTPException(409, 'No duplicate identity group remains. Run the account check again.')
jf_id = target['candidate_jellyfin_id']
source = source_key(runtime.jellyfin_base_url)
owned = {link['local_user_id'] for link in local['links'] if link['source'] == source and review.normalized_id(link['jellyfin_user_id']) == jf_id}
recommended = min(ids, key=lambda identity: (identity not in owned, identity))
keep_id = keep_id or recommended
if keep_id not in ids:
raise HTTPException(400, 'Choose an account from this duplicate group to keep.')
problems = []
if any(report['services'].get(service) != 'available' for service in ('jellyfin', 'seerr', 'jellystat')):
problems.append('Restore all three media-service connections before consolidating accounts.')
if not target['jellyfin'] or target['jellystat']['state'] != 'matched' or len(target['seerr']) != 1:
problems.append('One Jellyfin identity and one Seerr account must be verified against Jellystat.')
seerr_id = target['seerr'][0]['id'] if len(target['seerr']) == 1 else None
for row in group:
if row['basis'] not in {'confirmed_id', 'stored_jellyfin_id', 'stored_seerr_id'}:
problems.append('Every account needs a stored Jellyfin or Seerr ID; names alone cannot authorize consolidation.')
if any('different accounts' in issue or 'multiple distinct Jellyfin' in issue for issue in row['issues']):
problems.append('A name and stored identity disagree. Resolve that mapping before consolidation.')
if row['user']['role'] != 'user' or row['user']['auth_provider'] not in {'jellyfin', 'jellyseerr'}:
problems.append('Only non-admin Jellyfin or Seerr accounts can use duplicate consolidation.')
if not jf_id or row['candidate_jellyfin_id'] != jf_id or row['user']['jellyseerr_user_id'] not in (None, seerr_id):
problems.append('These rows do not all resolve to the same Jellyfin and Seerr identity.')
for link in local['links']:
if link['local_user_id'] in ids:
if link['source'] != source or review.normalized_id(link['jellyfin_user_id']) != jf_id:
problems.append('A duplicate has a different saved Jellyfin identity or server.')
elif link['source'] == source and review.normalized_id(link['jellyfin_user_id']) == jf_id:
problems.append('Another account or orphaned reservation owns this Jellyfin identity.')
for item in local['confirmations']:
if item['local_user_id'] in ids:
if (item['jellyfin_server_id'] != report['server_id'] or item['jellyfin_user_id'] != jf_id
or item['jellyfin_source'] != source or item['seerr_source'] != source_key(runtime.jellyseerr_base_url)
or item['seerr_user_id'] != seerr_id):
problems.append('A saved confirmation points to a different identity or server.')
elif item['jellyfin_server_id'] == report['server_id'] and item['jellyfin_user_id'] == jf_id:
problems.append('Another confirmation owns this identity.')
if any(row['user']['id'] not in ids and (row['candidate_jellyfin_id'] == jf_id or
(seerr_id is not None and row['user']['jellyseerr_user_id'] == seerr_id)) for row in report['rows']):
problems.append('An account outside this identity group also claims the identity.')
accounts = [account for account in state['users'] if account['id'] in ids]
kept = next(account for account in accounts if account['id'] == keep_id)
overrides = {(entry['user_id'], entry['feature']): bool(entry['enabled']) for entry in state['user_feature_permissions']}
features = {key: all(bool(account['invite_management_enabled']) if key == 'invites' else
overrides.get((account['id'], key), key != 'ignore_profile_limits') for account in accounts) for key in FEATURES}
expiries = [account['expires_at'] for account in accounts if account['expires_at']]
try:
expiry = min(expiries, key=lambda value: db._parse_datetime_value(value).timestamp()) if expiries else None
except (ValueError, TypeError, AttributeError):
expiry = kept['expires_at']
problems.append('An expiry date is invalid. Correct it before repairing duplicates.')
proposed = {'id': keep_id, 'username': target['jellyfin']['name'] if target['jellyfin'] else kept['username'],
'email': kept['email'], 'profile_id': kept['profile_id'], 'expires_at': expiry,
'is_blocked': any(account['is_blocked'] for account in accounts),
'auto_search_enabled': all(account['auto_search_enabled'] for account in accounts),
'features': features, 'jellyfin_user_id': jf_id, 'seerr_user_id': seerr_id}
public = [{key: account.get(key) for key in ('id', 'username', 'email', 'profile_id', 'last_login_at', 'created_at')}
for account in accounts]
return {'accounts': public, 'keep_id': keep_id, 'recommended_id': recommended, 'proposed': proposed,
'issues': sorted(set(problems)), 'can_confirm': not problems,
'revision': review.digest([report['revision'], state, keep_id, proposed])}
async def prepare(user_id, keep_id=None):
report, local, runtime = await review.review_identities()
target = next((row for row in local['users'] if row['id'] == user_id), None)
if not target:
raise HTTPException(404, 'Account not found.')
report_target = next(row for row in report['rows'] if row['user']['id'] == user_id)
ids = sorted(row['user']['id'] for row in identity_group(report, report_target))
with closing(db._connect()) as conn:
conn.execute('BEGIN')
if review.digest(review.snapshot(conn)) != review.digest(local):
raise HTTPException(409, 'Accounts changed during the check. Preview again.')
state = account_state(conn, ids)
return build_preview(report, local, runtime, state, user_id, keep_id), report, local, runtime, state
def consolidate(preview, report, local, runtime, state, admin):
if not preview['can_confirm']:
raise HTTPException(409, 'This duplicate group cannot be consolidated. Review the listed conflicts.')
ids = sorted(account['id'] for account in state['users'])
keep = preview['keep_id']
removed = [identity for identity in ids if identity != keep]
values = preview['proposed']
now = datetime.now(timezone.utc).isoformat()
with closing(db._connect()) as conn, conn:
conn.execute('BEGIN IMMEDIATE')
if (review.digest(review.snapshot(conn)) != review.digest(local)
or review.digest(account_state(conn, ids)) != review.digest(state)
or review.config_digest(review.get_runtime_settings()) != review.config_digest(runtime)):
raise HTTPException(409, 'Accounts, permissions or subscriptions changed. Preview again before saving.')
for table in ('email_recap_deliveries', 'newsletter_deliveries'):
if conn.execute(f"SELECT 1 FROM {table} WHERE user_id IN ({','.join('?' for _ in ids)}) AND state='sending'", ids).fetchone():
raise HTTPException(409, 'An account email is currently being sent. Wait for delivery to finish, then preview again.')
archive = {**state, 'links': [entry for entry in local['links'] if entry['local_user_id'] in ids],
'confirmations': [entry for entry in local['confirmations'] if entry['local_user_id'] in ids],
'proposed': values}
conn.execute('INSERT INTO user_duplicate_repairs(kept_user_id,archive_json,repaired_by,repaired_at) VALUES(?,?,?,?)',
(keep, json.dumps(archive, sort_keys=True), admin['username'], now))
names = {account['username'] for account in state['users']}
tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
for table, columns in NAME_REFERENCES.items():
if table not in tables:
continue
for column in columns:
old_values = {review.name_key(name) for name in names} if column == 'requested_by_norm' else names
for name in old_values:
new_value = review.name_key(values['username']) if column == 'requested_by_norm' else values['username']
conn.execute(f'UPDATE {table} SET {column}=? WHERE {column}=? COLLATE BINARY', (new_value, name))
activity = [dict(row) for row in conn.execute('SELECT * FROM user_activity') if row['username'] in names]
for entry in activity:
conn.execute('DELETE FROM user_activity WHERE id=?', (entry['id'],))
for entry in activity:
conn.execute('''INSERT INTO user_activity(username,ip,user_agent,first_seen_at,last_seen_at,hit_count)
VALUES(?,?,?,?,?,?) ON CONFLICT(username,ip,user_agent) DO UPDATE SET
first_seen_at=MIN(first_seen_at,excluded.first_seen_at),last_seen_at=MAX(last_seen_at,excluded.last_seen_at),
hit_count=hit_count+excluded.hit_count''', (values['username'], entry['ip'], entry['user_agent'], entry['first_seen_at'], entry['last_seen_at'], entry['hit_count']))
for name in names:
conn.execute('DELETE FROM password_reset_tokens WHERE username=? COLLATE NOCASE', (name,))
for identity in removed:
# Duplicate subscriptions are not inherited. Preserve delivery history and cancel outstanding work.
for table in ('email_recap_deliveries', 'newsletter_deliveries'):
conn.execute(f"UPDATE {table} SET state='cancelled',detail='Duplicate account consolidated.' WHERE user_id=? AND state IN ('queued','retry','preparing')", (identity,))
conn.execute(f'UPDATE {table} SET user_id=? WHERE user_id=?', (keep, identity))
conn.execute('DELETE FROM jellyfin_user_links WHERE local_user_id=?', (identity,))
conn.execute('DELETE FROM user_identity_confirmations WHERE local_user_id=?', (identity,))
conn.execute('DELETE FROM users WHERE id=?', (identity,))
last_login = max((account['last_login_at'] for account in state['users'] if account['last_login_at']), default=None)
conn.execute('''UPDATE users SET auth_provider='jellyfin',username=?,jellyseerr_user_id=?,is_blocked=?,auto_search_enabled=?,
invite_management_enabled=?,expires_at=?,last_login_at=? WHERE id=?''',
(values['username'], values['seerr_user_id'], values['is_blocked'], values['auto_search_enabled'],
values['features']['invites'], values['expires_at'], last_login, keep))
for feature, enabled in values['features'].items():
if feature != 'invites':
conn.execute('''INSERT INTO user_feature_permissions VALUES(?,?,?)
ON CONFLICT(user_id,feature) DO UPDATE SET enabled=excluded.enabled''', (keep, feature, int(enabled)))
conn.execute('''INSERT INTO jellyfin_user_links VALUES(?,?,?) ON CONFLICT(source,local_user_id)
DO UPDATE SET jellyfin_user_id=excluded.jellyfin_user_id''', (source_key(runtime.jellyfin_base_url), keep, values['jellyfin_user_id']))
conn.execute('DELETE FROM user_identity_confirmations WHERE local_user_id=?', (keep,))
conn.execute('''INSERT INTO user_identity_confirmations VALUES(?,?,?,?,?,?,?,?)''',
(keep, report['server_id'], values['jellyfin_user_id'], source_key(runtime.jellyfin_base_url),
source_key(runtime.jellyseerr_base_url), values['seerr_user_id'], now, admin['username']))
return {'kept_user_id': keep, 'consolidated': len(removed), 'repaired_at': now}
async def repair_duplicates(user_id, keep_id=None, revision=None, admin=None):
preview, report, local, runtime, state = await prepare(user_id, keep_id)
if revision is None:
return preview
if revision != preview['revision']:
raise HTTPException(409, 'The duplicate-account preview changed. Preview again before saving.')
return await asyncio.to_thread(consolidate, preview, report, local, runtime, state, admin)
+33
View File
@@ -0,0 +1,33 @@
"""Shared claim and completion rules for the two durable email queues."""
import uuid
def queue_table(table: str) -> str:
if table not in {"email_recap_deliveries", "newsletter_deliveries"}:
raise ValueError("Unknown email queue")
return table
def claim(conn, table: str, now: float) -> dict | None:
table = queue_table(table)
conn.execute(f"""UPDATE {table} SET state='unknown', detail='Delivery interrupted after sending began; check the mail server.', updated_at=?
WHERE state='sending' AND lease_until<?""", (now, now))
conn.execute(f"""UPDATE {table} SET state=CASE WHEN attempts>=3 THEN 'failed' ELSE 'retry' END,
next_attempt_at=?, updated_at=?, detail='Email preparation interrupted.'
WHERE state='preparing' AND lease_until<?""", (now, now, now))
row = conn.execute(f"""SELECT * FROM {table} WHERE state IN ('queued', 'retry') AND next_attempt_at<=?
ORDER BY created_at, id LIMIT 1""", (now,)).fetchone()
if not row:
return None
claim_id = uuid.uuid4().hex
conn.execute(f"""UPDATE {table} SET state='preparing', claim=?, lease_until=?,
attempts=attempts+1, updated_at=? WHERE id=?""", (claim_id, now + 1800, now, row["id"]))
return dict(conn.execute(f"SELECT * FROM {table} WHERE id=?", (row["id"],)).fetchone())
def finish(conn, table: str, delivery: dict, state: str, detail: str, now: float, delay: int = 0):
table = queue_table(table)
conn.execute(f"""UPDATE {table} SET state=?, detail=?, updated_at=?, next_attempt_at=?, lease_until=NULL
WHERE id=? AND claim=? AND state IN ('preparing', 'sending')""",
(state, detail, now, now + delay, delivery["id"], delivery["claim"]))
+297
View File
@@ -0,0 +1,297 @@
"""Opt-in monthly recaps. Scheduling and delivery are safe to run in multiple workers."""
import asyncio
import logging
import os
import time
import uuid
from datetime import datetime, timezone
from urllib.parse import urlencode
from .. import db
from ..clients.jellystat import HistoryLimitError, JellystatError
from ..runtime import get_runtime_settings
from . import recap_email as mail, recap_store as store
from .invite_email import smtp_email_config_ready
from .jellyfin_identity import linked_user_id, source_key
from .monthly_reports import get_monthly_report, month_periods
logger = logging.getLogger(__name__)
class RecapError(Exception):
def __init__(self, detail: str, status: int = 409):
self.detail, self.status = detail, status
super().__init__(detail)
def worker_enabled() -> bool:
return os.environ.get("BACKGROUND_TASKS_ENABLED", "true").lower() != "false"
def delivery_ready() -> tuple[bool, str]:
config = store.settings()
if not config["public_url"]:
return False, "Set the application URL in Hosting & proxy for email links."
ready, detail = smtp_email_config_ready()
if not ready:
return False, detail
runtime = get_runtime_settings()
if not runtime.jellystat_base_url or not runtime.jellystat_api_key:
return False, "Connect Jellystat to generate viewing recaps."
if not worker_enabled():
return False, "Background automation is paused on this server."
return True, "Email delivery is configured."
def current_account(user: dict) -> dict:
account = db.get_user_by_username(user.get("username", ""))
if not account or account.get("is_blocked") or account.get("is_expired"):
raise RecapError("This account cannot receive viewing recaps.", 403)
return account
def binding_matches(sub: dict, account: dict) -> bool:
runtime = get_runtime_settings()
return bool(account and not account.get("is_blocked") and not account.get("is_expired")
and mail.valid_email(account.get("email"))
and account["email"].strip().casefold() == sub["email"].strip().casefold()
and source_key(runtime.jellyfin_base_url) == sub["identity_source"]
and linked_user_id(account["username"], runtime.jellyfin_base_url) == sub["identity_id"])
def active_subscription(account: dict) -> dict | None:
sub = store.subscription(account["id"])
if sub and sub["state"] != "off" and not binding_matches(sub, account):
store.disable(account["id"])
sub = store.subscription(account["id"])
return sub
def preferences(user: dict) -> dict:
account = current_account(user)
sub = active_subscription(account)
config = store.settings()
ready, detail = delivery_ready()
runtime = get_runtime_settings()
linked = bool(linked_user_id(account["username"], runtime.jellyfin_base_url))
email = mail.valid_email(account.get("email"))
state = sub["state"] if sub else "off"
if state == "pending" and sub["confirmation_expires"] <= time.time():
state = "expired"
return {"state": state, "email": account.get("email"), "can_subscribe": ready and linked and bool(email),
"detail": detail if not ready else "Save a valid email address in your profile." if not email else
"Your Jellyfin account needs a saved identity link." if not linked else "Your monthly story, in your inbox.",
"automatic_monthly": bool(sub["automatic_monthly"]) if sub else False,
"can_send": ready and state == "enabled", "deliveries": store.personal_history(account["id"]),
"schedule_enabled": config["enabled"], "next_send_at": config["next_send_at"],
"day": config["day"], "hour": config["hour"], "timezone": "UTC",
"resend_after": (sub["requested_at"] + 300) if sub else None}
async def subscribe(user: dict, automatic_monthly: bool | None = None) -> dict:
account = current_account(user)
preference = preferences(user)
automatic = preference['automatic_monthly'] if automatic_monthly is None else automatic_monthly
if preference["state"] == "enabled":
store.set_automatic(account['id'], automatic)
return preferences(user)
if not preference["can_subscribe"]:
raise RecapError(preference["detail"])
config = store.settings()
runtime = get_runtime_settings()
try:
token = store.request_confirmation(account, source_key(runtime.jellyfin_base_url),
linked_user_id(account["username"], runtime.jellyfin_base_url), time.time(), automatic)
except ValueError as exc:
raise RecapError(str(exc), 429) from exc
url = f"{config['public_url']}/email-recaps#" + urlencode({"action": "confirm", "token": token})
rendered = mail.render_confirmation(account["username"], url)
try:
await asyncio.to_thread(mail.send_email, account["email"].strip(), rendered,
mail.message_id(uuid.uuid4().hex, config["public_url"]))
except mail.DeliveryError as exc:
raise RecapError("Could not confirm delivery of the verification email. Check your inbox; you can request another in five minutes.", 502) from exc
return {**preferences(user), "message": "Check your inbox and confirm within 24 hours to enable personal report emails."}
def token_action(token: str, action: str, *, apply: bool = False) -> dict:
sub = store.token_subscription(token, action)
if not sub:
raise RecapError("This email link is invalid or has already been used. Open Profile to manage your recaps.", 410)
if action == "unsubscribe":
if apply:
store.disable(sub["user_id"])
return {"action": action, "state": "off" if apply or sub["state"] == "off" else "ready"}
account = db.get_user_by_id(sub["user_id"])
if (sub["state"] != "pending" or sub["confirmation_expires"] <= time.time()
or not binding_matches(sub, account)):
raise RecapError("This confirmation has expired or your account details changed. Request a new link from Profile.", 410)
if apply and not store.confirm(sub, time.time()):
raise RecapError("This confirmation is no longer available. Request a new link from Profile.", 410)
return {"action": action, "state": "enabled" if apply else "ready"}
def completed_month(month: str | None) -> str:
try:
period = month_periods(month, datetime.now(timezone.utc))
except ValueError as exc:
raise RecapError(str(exc), 422) from exc
if period["is_partial"]:
raise RecapError("Choose a completed month for an email recap.", 422)
return period["month"]
async def illustrated_recap(report, account, public_url, unsubscribe_url, *, preview=False, **kwargs):
"""Embed only signed artwork from this account's report; missing art is optional."""
import base64
import re
from .insights_artwork import get_artwork
runtime = get_runtime_settings()
images = []
report = {**report, "top_titles": [dict(row) for row in report.get("top_titles", [])]}
async def picture(index, row):
match = re.fullmatch(r"/insights/artwork/([a-f0-9]{32})\?token=([0-9]+\.[a-f0-9]{64})", row.get("artwork_url") or "")
if not match:
return
try:
data, mime = await get_artwork(account, runtime, *match.groups())
cid = f"recap-title-{index}@magent"
row["email_artwork"] = f"data:{mime};base64,{base64.b64encode(data).decode()}" if preview else f"cid:{cid}"
images.append({"cid": cid, "data": data, "subtype": mime.split("/")[1]})
except Exception:
pass # An unavailable poster must never prevent a personal report.
await asyncio.gather(*(picture(i, row) for i, row in enumerate(report["top_titles"][:3])))
rendered = mail.render_recap(report, account["username"], public_url, unsubscribe_url, **kwargs)
if not preview:
rendered["inline_images"] = images
return rendered
async def preview(user: dict, month: str | None) -> dict:
account = current_account(user)
selected = completed_month(month)
config = store.settings()
if not config["public_url"]:
raise RecapError("Set the application URL in Hosting & proxy before previewing an email.")
try:
report = await asyncio.wait_for(get_monthly_report(account, selected), timeout=180)
except HistoryLimitError as exc:
raise RecapError("This report exceeds Jellystat's history limit. No partial recap was generated.", 422) from exc
except (JellystatError, TimeoutError) as exc:
raise RecapError("Your report is temporarily unavailable. Please try again shortly.", 502) from exc
if report["state"] != "ready":
raise RecapError("Connect Jellystat and link your Jellyfin account to preview your recap.")
return {"month": selected, "email": account.get("email"), **await illustrated_recap(
report, account, config["public_url"], config["public_url"] + "/profile#monthly-recaps", preview=True)}
def queue_test(user: dict, month: str | None, request_id: str) -> dict:
account = current_account(user)
ready, detail = delivery_ready()
if not ready:
raise RecapError(detail)
sub = active_subscription(account)
if not sub or sub["state"] != "enabled":
raise RecapError("Turn on email recaps and confirm your email in Profile before sending a personal test.")
selected = completed_month(month)
try:
delivery_id = store.enqueue_test(sub, selected, request_id, store.settings()["public_url"], time.time())
except ValueError as exc:
raise RecapError(str(exc), 429) from exc
return {"id": delivery_id, "message": "Test queued for your confirmed email. Check delivery history for the result."}
def eligible_delivery(delivery: dict) -> tuple[dict, dict]:
account = db.get_user_by_id(delivery["user_id"])
from ..feature_access import permissions
if not account or not permissions(account)["stats"]:
raise mail.DeliveryCancelled()
sub = active_subscription(account) if account else None
config = store.settings()
ready, _ = delivery_ready()
if (not ready or not sub or sub["state"] != "enabled" or sub["version"] != delivery["subscription_version"]
or sub["email"] != delivery["email"] or not binding_matches(sub, account)
or config["public_url"] != delivery["public_url"]
or (delivery["kind"] == "scheduled" and (not config["enabled"] or not sub["automatic_monthly"]))):
raise mail.DeliveryCancelled()
return account, sub
async def process_delivery(delivery: dict) -> None:
state, detail, delay = "failed", "Could not prepare the recap. Check the report and email settings.", 0
try:
account, sub = eligible_delivery(delivery)
report = await asyncio.wait_for(get_monthly_report(account, delivery["month"]), timeout=180)
if report["state"] != "ready" or (report["is_partial"] and delivery["kind"] != "on_demand"):
raise mail.DeliveryError("failed", "A complete personal report is not available.")
unsubscribe = f"{delivery['public_url']}/email-recaps#" + urlencode({"action": "unsubscribe", "token": sub["unsubscribe_token"]})
rendered = await illustrated_recap(report, account, delivery["public_url"], unsubscribe, test=delivery["kind"] == "test", requested=delivery["kind"] == "on_demand")
def before_data():
eligible_delivery(delivery)
if not store.begin_sending(delivery, time.time()):
raise mail.DeliveryCancelled()
await asyncio.to_thread(mail.send_email, delivery["email"], rendered,
mail.message_id(delivery["id"], delivery["public_url"]), before_data)
state, detail = "sent", "Accepted by the mail server."
except mail.DeliveryCancelled:
state, detail = "cancelled", "Consent, account details or email configuration changed."
except HistoryLimitError:
state, detail = "failed", "Jellystat's history limit was reached. No partial recap was sent."
except (JellystatError, TimeoutError):
state, detail = "retry", "Viewing history is temporarily unavailable."
except mail.DeliveryError as exc:
state, detail = exc.state, exc.detail
except Exception as exc:
# Do not expose provider errors or private report content in history/logs.
logger.error("recap delivery error id=%s type=%s", delivery["id"], type(exc).__name__)
row = store.read_one("SELECT state FROM email_recap_deliveries WHERE id=?", (delivery["id"],))
if row and row["state"] == "sending":
state, detail = "unknown", "Delivery outcome is unknown; check the mail server."
if state == "retry":
if delivery["attempts"] >= 3:
state, detail = "failed", detail + " Stopped after three attempts."
else:
delay = 300 if delivery["attempts"] == 1 else 1800
store.finish(delivery, state, detail, time.time(), delay)
async def run_once() -> None:
store.enqueue_due(datetime.now(timezone.utc))
for _ in range(10):
delivery = store.claim_delivery(time.time())
if not delivery:
break
await process_delivery(delivery)
async def run_email_recap_loop() -> None:
while True:
try:
await run_once()
except Exception as exc:
logger.error("email recap worker failed type=%s", type(exc).__name__)
await asyncio.sleep(30)
def queue_personal(user: dict, month: str | None, request_id: str) -> dict:
account = current_account(user)
ready, detail = delivery_ready()
if not ready:
raise RecapError(detail)
sub = active_subscription(account)
if not sub or sub['state'] != 'enabled':
raise RecapError('Confirm your profile email in email preferences before emailing a report.')
try:
selected = month_periods(month, datetime.now(timezone.utc))['month']
except ValueError as exc:
raise RecapError(str(exc), 422) from exc
try:
delivery_id = store.enqueue_test(sub, selected, request_id, store.settings()['public_url'], time.time(), 'on_demand')
except ValueError as exc:
raise RecapError(str(exc), 429) from exc
return {'id': delivery_id, 'message': 'Your report is queued for your confirmed profile email. Delivery status appears below.'}
+369
View File
@@ -0,0 +1,369 @@
"""Admin-reviewed account links. Live IDs are authoritative; names only suggest candidates."""
import asyncio
import copy
import hashlib
import json
import re
import sqlite3
import httpx
from collections import defaultdict
from contextlib import closing
from datetime import datetime, timezone
from fastapi import HTTPException
from .. import db
from ..clients.jellyfin import JellyfinClient
from ..clients.jellyseerr import JellyseerrClient
from ..clients.jellystat import JellystatClient
from ..runtime import get_runtime_settings
from .jellyfin_identity import source_key
MAX_USERS = 3000
CONFIG_KEYS = ("jellyfin_base_url", "jellyfin_api_key", "jellyseerr_base_url",
"jellyseerr_api_key", "jellystat_base_url", "jellystat_api_key")
def normalized_id(value):
value = str(value or "").lower().replace("-", "")
return value if re.fullmatch(r"[a-f0-9]{32}", value) else None
def name_key(value):
return str(value or "").strip().casefold()
def digest(value):
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
def config_digest(runtime):
return digest([getattr(runtime, key, None) for key in CONFIG_KEYS])
def snapshot(conn):
conn.row_factory = sqlite3.Row
return {
"users": [dict(row) for row in conn.execute(
"SELECT id, username, role, auth_provider, jellyseerr_user_id FROM users ORDER BY id")],
"links": [dict(row) for row in conn.execute(
"SELECT source, local_user_id, jellyfin_user_id FROM jellyfin_user_links ORDER BY source, local_user_id")],
"confirmations": [dict(row) for row in conn.execute(
"SELECT * FROM user_identity_confirmations ORDER BY local_user_id")],
# Detect settings changes between checking services and committing the reviewed links.
"config_revision": digest([tuple(row) for row in conn.execute(
"SELECT key, value FROM settings WHERE key IN (" + ",".join("?" for _ in CONFIG_KEYS) + ") ORDER BY key", CONFIG_KEYS)]),
}
def read_snapshot():
with closing(db._connect()) as conn:
return snapshot(conn)
async def jellyfin_directory(runtime):
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if not client.configured():
return {"state": "not_configured", "users": []}
try:
users, server = await asyncio.gather(client.get_users(), client.get_system_info())
server_id = normalized_id(server.get("Id")) if isinstance(server, dict) else None
if not server_id or not isinstance(users, list) or len(users) > MAX_USERS:
raise ValueError()
clean = []
seen = set()
for user in users:
user_id = normalized_id(user.get("Id"))
if not user_id or user_id in seen or normalized_id(user.get("ServerId")) != server_id:
raise ValueError()
seen.add(user_id)
clean.append({"id": user_id, "name": str(user.get("Name") or "")[:200]})
return {"state": "available", "server_id": server_id, "users": sorted(clean, key=lambda row: row["id"])}
except Exception:
return {"state": "unavailable", "users": []}
async def seerr_directory(runtime):
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if not client.base_url or not client.api_key:
return {"state": "not_configured", "users": []}
try:
users = []
seen = set()
expected_total = None
async with asyncio.timeout(20):
for skip in range(0, MAX_USERS, 100):
page = await client.get_users(take=100, skip=skip)
total = page["pageInfo"]["results"]
batch = page["results"]
if type(total) is not int or total < 0 or total > MAX_USERS or not isinstance(batch, list):
raise ValueError()
if expected_total is not None and total != expected_total:
raise ValueError()
expected_total = total
for user in batch:
user_id = user.get("id")
if type(user_id) is not int or user_id <= 0 or user_id in seen:
raise ValueError()
seen.add(user_id)
users.append({"id": user_id, "name": str(user.get("displayName") or user.get("jellyfinUsername") or "")[:200],
"jellyfin_id": normalized_id(user.get("jellyfinUserId"))})
if len(users) == total:
return {"state": "available", "users": sorted(users, key=lambda row: row["id"])}
if len(batch) != 100 or len(users) > total:
raise ValueError()
except Exception:
pass
return {"state": "unavailable", "users": []}
def build_report(local, jellyfin, seerr, jellystat, runtime, selections=None, repair=False):
original = local
selections = selections or {}
if repair:
local = copy.deepcopy(local)
for user in local['users']:
if user['id'] in selections:
user['jellyseerr_user_id'] = None
local['links'] = [link for link in local['links'] if not (
link['local_user_id'] in selections and link['source'] == source_key(runtime.jellyfin_base_url))]
local['confirmations'] = [item for item in local['confirmations'] if item['local_user_id'] not in selections]
if any(user_id not in {user['id'] for user in local['users']} for user_id in selections):
raise HTTPException(404, "This Magent account no longer exists. Run the check again.")
jf_by_id = {row["id"]: row for row in jellyfin["users"]}
jf_by_name = defaultdict(list)
for row in jellyfin["users"]:
jf_by_name[name_key(row["name"])].append(row["id"])
seerr_by_id = {row["id"]: row for row in seerr["users"]}
seerr_by_jf = defaultdict(list)
for row in seerr["users"]:
if row["jellyfin_id"]:
seerr_by_jf[row["jellyfin_id"]].append(row)
current_source = source_key(runtime.jellyfin_base_url)
seerr_source = source_key(runtime.jellyseerr_base_url)
links = {row["local_user_id"]: normalized_id(row["jellyfin_user_id"]) for row in local["links"] if row["source"] == current_source}
confirmed = {row["local_user_id"]: row for row in local["confirmations"]}
local_by_name, local_by_seerr = defaultdict(list), defaultdict(list)
for user in local["users"]:
local_by_name[name_key(user["username"])].append(user["id"])
if user["jellyseerr_user_id"] is not None:
local_by_seerr[user["jellyseerr_user_id"]].append(user["id"])
rows = []
for user in local["users"]:
issues = []
saved = confirmed.get(user["id"])
linked = links.get(user["id"])
stored_seerr = seerr_by_id.get(user["jellyseerr_user_id"])
by_name = jf_by_name.get(name_key(user["username"]), [])
basis = "none"
candidate = None
if saved:
candidate = saved["jellyfin_user_id"]
basis = "confirmed_id"
if saved["jellyfin_server_id"] != jellyfin.get("server_id") or saved["seerr_source"] != seerr_source:
issues.append("The confirmed server or Seerr connection has changed.")
elif linked:
candidate, basis = linked, "stored_jellyfin_id"
elif stored_seerr and stored_seerr["jellyfin_id"]:
candidate, basis = stored_seerr["jellyfin_id"], "stored_seerr_id"
elif user["auth_provider"] == "jellyfin" and len(by_name) == 1:
candidate, basis = by_name[0], "suggested_username"
if repair and user['id'] in selections and any(
item['local_user_id'] == user['id'] and item['jellyfin_server_id'] != jellyfin.get('server_id')
for item in original['confirmations']):
issues.append('The Jellyfin server changed. A server migration requires separate review.')
if user["id"] in selections:
chosen = selections[user["id"]]
if saved and chosen != saved["jellyfin_user_id"]:
issues.append("A confirmed identity cannot be replaced through missing-link resolution.")
candidate, basis = chosen, "admin_selected"
if len(local_by_name[name_key(user["username"])]) > 1:
issues.append("Multiple Magent rows share this username after case and whitespace normalization.")
if len(local_by_seerr.get(user["jellyseerr_user_id"], [])) > 1:
issues.append("Multiple Magent rows share the stored Seerr ID.")
if len(by_name) > 1:
issues.append("This name matches multiple distinct Jellyfin IDs.")
if candidate and by_name and candidate not in by_name:
issues.append("The stored ID and current Jellyfin username point to different accounts.")
if linked and candidate and linked != candidate:
issues.append("The stored Jellyfin link conflicts with the confirmed identity.")
jf = jf_by_id.get(candidate)
if candidate and not jf and jellyfin["state"] == "available":
issues.append("The linked Jellyfin ID is absent from the current server.")
expected_seerr = seerr_by_jf.get(candidate, [])
if len(expected_seerr) > 1:
issues.append("Multiple Seerr users reference the same Jellyfin ID.")
if user["jellyseerr_user_id"] is not None and seerr["state"] == "available" and (
len(expected_seerr) != 1 or expected_seerr[0]["id"] != user["jellyseerr_user_id"]
):
issues.append("The stored Seerr ID does not match Seerr's Jellyfin ID mapping.")
if saved and user["jellyseerr_user_id"] != saved["seerr_user_id"]:
issues.append("The stored Seerr ID has changed since confirmation.")
js = jellystat.get(candidate, {"state": "not_checked"})
rows.append({"user": user, "jellyfin": jf, "candidate_jellyfin_id": candidate,
"stored_jellyfin_id": linked, "seerr": expected_seerr, "jellystat": js,
"basis": basis, "issues": issues, "confirmed_at": saved["confirmed_at"] if saved else None,
"can_confirm": False, "state": "unlinked"})
candidates = defaultdict(list)
for row in rows:
if row["candidate_jellyfin_id"]:
candidates[row["candidate_jellyfin_id"]].append(row)
for row in rows:
candidate = row["candidate_jellyfin_id"]
if len(candidates.get(candidate, [])) > 1:
row["issues"].append("Multiple Magent accounts resolve to this Jellyfin ID.")
# Also protect IDs already reserved by a link/confirmation whose local user was deleted.
if any(link["local_user_id"] != row["user"]["id"] and link["source"] == current_source
and normalized_id(link["jellyfin_user_id"]) == candidate for link in local["links"]) or any(
item["local_user_id"] != row["user"]["id"] and item["jellyfin_server_id"] == jellyfin.get("server_id")
and item["jellyfin_user_id"] == candidate for item in local["confirmations"]):
row["issues"].append("This Jellyfin ID is already reserved by another Magent account.")
if row["issues"]:
row["state"] = "conflict"
elif jellyfin["state"] != "available" or seerr["state"] != "available" or (candidate and row["jellystat"]["state"] in {"unavailable", "not_configured"}):
row["state"] = "unavailable"
elif not row["jellyfin"] or not row["seerr"] or row["jellystat"]["state"] != "matched":
row["state"] = "unlinked"
elif row["confirmed_at"] and row["stored_jellyfin_id"] == candidate:
row["state"] = "confirmed"
else:
row["state"] = "ready"
row["can_confirm"] = True
upstream = [{"platform": "Seerr", "id": str(row["id"]), "name": row["name"], "jellyfin_id": row["jellyfin_id"],
"detail": "No current Jellyfin account has this ID."} for row in seerr["users"]
if row["jellyfin_id"] not in jf_by_id and jellyfin["state"] == "available"]
upstream += [{"platform": "Jellyfin", "id": row["id"], "name": row["name"], "jellyfin_id": row["id"],
"detail": "No Magent account resolves to this ID."} for row in jellyfin["users"] if row["id"] not in candidates]
services = {"jellyfin": jellyfin["state"], "seerr": seerr["state"],
"jellystat": "not_configured" if not runtime.jellystat_base_url or not runtime.jellystat_api_key else
"not_checked" if not jellystat else
"unavailable" if any(r["state"] == "unavailable" for r in jellystat.values()) else "available"}
report = {"server_id": jellyfin.get("server_id"), "services": services, "rows": rows, "upstream": upstream,
"jellyfin_users": jellyfin["users"], "seerr_users": seerr["users"],
"counts": {"magent": len(rows), "jellyfin": len(jellyfin["users"]), "seerr": len(seerr["users"]),
"jellystat_checked": sum(r["state"] in {"matched", "missing"} for r in jellystat.values()),
**{state: sum(row["state"] == state for row in rows) for state in ("ready", "confirmed", "conflict", "unlinked", "unavailable")}}}
report["revision"] = digest([report, digest(original), config_digest(runtime), repair])
report["checked_at"] = datetime.now(timezone.utc).isoformat()
return report
async def review_identities(selections=None, repair=False):
runtime = await asyncio.to_thread(get_runtime_settings)
local, jf, seerr = await asyncio.gather(asyncio.to_thread(read_snapshot), jellyfin_directory(runtime), seerr_directory(runtime))
if len(local["users"]) > MAX_USERS:
raise HTTPException(422, "The identity check supports up to 3,000 Magent accounts.")
ids = {row["id"] for row in jf["users"]}
ids.update(row["jellyfin_id"] for row in seerr["users"] if row["jellyfin_id"])
ids.update(normalized_id(row["jellyfin_user_id"]) for row in local["links"])
ids.discard(None)
if len(ids) > MAX_USERS:
raise HTTPException(422, "There are too many upstream IDs for one identity check.")
stats_client = JellystatClient(runtime.jellystat_base_url, runtime.jellystat_api_key)
js = await stats_client.check_user_ids(sorted(ids)) if stats_client.configured() else {key: {"state": "not_configured"} for key in ids}
return build_report(local, jf, seerr, js, runtime, selections, repair), local, runtime
def save_confirmations(report, local, runtime, user_ids, admin, repair=False):
rows = {row["user"]["id"]: row for row in report["rows"]}
if any(user_id not in rows or not rows[user_id]["can_confirm"] for user_id in user_ids):
raise HTTPException(409, "Some selected accounts cannot be confirmed. Run the check again and review the conflicts.")
now = datetime.now(timezone.utc).isoformat()
try:
with closing(db._connect()) as conn, conn:
conn.execute("BEGIN IMMEDIATE")
if digest(snapshot(conn)) != digest(local) or config_digest(get_runtime_settings()) != config_digest(runtime):
raise HTTPException(409, "Accounts or settings changed during confirmation. Run the check again.")
for user_id in user_ids:
row = rows[user_id]
jf_id = row["jellyfin"]["id"]
seerr_id = row["seerr"][0]["id"]
conn.execute("""INSERT INTO jellyfin_user_links (source, local_user_id, jellyfin_user_id) VALUES (?, ?, ?)
ON CONFLICT(source,local_user_id) DO UPDATE SET jellyfin_user_id=excluded.jellyfin_user_id""",
(source_key(runtime.jellyfin_base_url), user_id, jf_id))
conn.execute("UPDATE users SET jellyseerr_user_id=? WHERE id=?", (seerr_id, user_id))
conn.execute("""INSERT INTO user_identity_confirmations
(local_user_id,jellyfin_server_id,jellyfin_user_id,jellyfin_source,seerr_source,seerr_user_id,confirmed_at,confirmed_by)
VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(local_user_id) DO UPDATE SET
jellyfin_source=excluded.jellyfin_source,confirmed_at=excluded.confirmed_at,confirmed_by=excluded.confirmed_by""",
(user_id, report["server_id"], jf_id, source_key(runtime.jellyfin_base_url), source_key(runtime.jellyseerr_base_url),
seerr_id, now, admin["username"]))
if repair:
before_user = next(user for user in local['users'] if user['id'] == user_id)
before = {'seerr_user_id': before_user['jellyseerr_user_id'],
'links': [link for link in local['links'] if link['local_user_id'] == user_id],
'confirmation': next((item for item in local['confirmations'] if item['local_user_id'] == user_id), None)}
conn.execute("""UPDATE user_identity_confirmations SET jellyfin_server_id=?,jellyfin_user_id=?,
jellyfin_source=?,seerr_source=?,seerr_user_id=? WHERE local_user_id=?""",
(report['server_id'], jf_id, source_key(runtime.jellyfin_base_url),
source_key(runtime.jellyseerr_base_url), seerr_id, user_id))
conn.execute("""INSERT INTO user_identity_repairs
(local_user_id,before_json,after_json,repaired_at,repaired_by) VALUES (?,?,?,?,?)""",
(user_id, json.dumps(before, sort_keys=True), json.dumps({
'jellyfin_server_id': report['server_id'], 'jellyfin_user_id': jf_id,
'seerr_user_id': seerr_id}, sort_keys=True), now, admin['username']))
except sqlite3.IntegrityError as exc:
raise HTTPException(409, "An identity is already linked to another account. Run the check again.") from exc
return {"confirmed": len(user_ids), "confirmed_at": now}
async def confirm_identities(revision, user_ids, admin):
report, local, runtime = await review_identities()
if report["revision"] != revision:
raise HTTPException(409, "The identity check has changed. Run it again before confirming accounts.")
return await asyncio.to_thread(save_confirmations, report, local, runtime, user_ids, admin)
async def resolve_identity(user_id, jellyfin_user_id, revision=None, admin=None):
report, local, runtime = await review_identities({user_id: jellyfin_user_id})
if revision is not None:
if report["revision"] != revision:
raise HTTPException(409, "Accounts or service mappings changed. Check the selected account again before saving.")
return await asyncio.to_thread(save_confirmations, report, local, runtime, [user_id], admin)
return {"revision": report["revision"], "server_id": report["server_id"],
"row": next(row for row in report["rows"] if row["user"]["id"] == user_id)}
async def repair_identity(user_id, jellyfin_user_id, revision=None, admin=None, create_seerr=False):
report, local, runtime = await review_identities({user_id: jellyfin_user_id}, repair=True)
row = next(row for row in report['rows'] if row['user']['id'] == user_id)
importing = bool(create_seerr and row['state'] == 'unlinked' and row['jellyfin']
and not row['seerr'] and row['jellystat']['state'] == 'matched'
and report['services']['seerr'] == 'available')
if importing and any(name_key(account['name']) == name_key(row['jellyfin']['name'])
for account in report['seerr_users']):
importing = False
row['issues'].append('A Seerr account already has this name. Review its existing link before importing.')
report['revision'] = digest([report['revision'], create_seerr])
if revision is not None:
if report['revision'] != revision:
raise HTTPException(409, 'The repair preview changed. Check the selected account again.')
if importing:
if digest(await asyncio.to_thread(read_snapshot)) != digest(local) or config_digest(get_runtime_settings()) != config_digest(runtime):
raise HTTPException(409, 'Accounts or settings changed. Preview the repair again.')
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
try:
await client.post('/api/v1/user/import-from-jellyfin', payload={'jellyfinUserIds': [jellyfin_user_id]})
except (httpx.HTTPError, ValueError) as exc:
raise HTTPException(502, 'The Seerr import could not be verified. Run a fresh check before trying again; an account may already have been imported.') from exc
# Upstream and SQLite cannot share a transaction. Reconcile using live IDs;
# never delete an imported account if the local save is blocked or interrupted.
refreshed, _, fresh_runtime = await review_identities({user_id: jellyfin_user_id}, repair=True)
if config_digest(fresh_runtime) != config_digest(runtime):
raise HTTPException(409, 'Seerr import completed but settings changed. Check accounts again before saving Magent links.')
try:
return await asyncio.to_thread(save_confirmations, refreshed, local, runtime, [user_id], admin, True)
except HTTPException as exc:
raise HTTPException(409, 'Seerr import completed, but Magent links could not be saved. Run another check to review the imported account. No account was deleted.') from exc
return await asyncio.to_thread(save_confirmations, report, local, runtime, [user_id], admin, True)
before = next(user for user in local['users'] if user['id'] == user_id)
linked = next((link['jellyfin_user_id'] for link in local['links'] if link['local_user_id'] == user_id
and link['source'] == source_key(runtime.jellyfin_base_url)), None)
row['can_confirm'] = row['can_confirm'] or importing
return {'revision': report['revision'], 'server_id': report['server_id'], 'row': row,
'action': 'import_seerr' if importing else 'repair_magent',
'before': {'jellyfin_user_id': linked, 'seerr_user_id': before['jellyseerr_user_id']},
'seerr_users': report['seerr_users'],
'scope': ('Import this single Jellyfin account into Seerr, then verify and save Magent links. Existing Seerr accounts stay unchanged.' if importing else 'Repair Magent links only. Jellyfin and Jellystat IDs and Seerr accounts stay unchanged.')}
+243
View File
@@ -0,0 +1,243 @@
import asyncio
import hashlib
import json
import math
import sqlite3
import time
from collections import defaultdict
from contextlib import closing
from datetime import datetime, timedelta, timezone
from .. import db
from ..clients.jellyfin import JellyfinClient
from ..clients.jellystat import JellystatClient, JellystatError
from ..runtime import get_runtime_settings
from .jellyfin_identity import link_user, linked_user_id
from .insights_artwork import item_id as artwork_item_id, with_artwork
_cache: dict[tuple, tuple[float, dict]] = {}
CACHE_SECONDS = 60
HARDWARE = {"amf": "AMD AMF", "qsv": "Intel Quick Sync", "nvenc": "NVIDIA NVENC",
"v4l2m2m": "V4L2", "vaapi": "VAAPI", "videotoolbox": "Apple VideoToolbox", "rkmpp": "Rockchip MPP"}
HARDWARE_ENUM = {0: "none", 1: "amf", 2: "qsv", 3: "nvenc", 4: "v4l2m2m", 5: "vaapi", 6: "videotoolbox", 7: "rkmpp"}
def add_transcoding(row, duration, media_type, totals, hardware, audio_codecs):
# Jellystat can retain stale transcoding metadata after a switch to DirectPlay.
method = row.get("PlayMethod")
if method not in {"Transcode", "DirectStream"}:
return
info = row.get("TranscodingInfo")
if isinstance(info, str):
try:
info = json.loads(info)
except ValueError:
info = None
info = info if isinstance(info, dict) else {}
video_present = media_type in {"movie", "episode"} or bool(info.get("VideoCodec"))
if method == "Transcode" and video_present:
if info.get("IsVideoDirect") is False:
totals["video_minutes"] += duration
value = info.get("HardwareAccelerationType")
value = HARDWARE_ENUM.get(value) if type(value) is int else str(value or "").strip().lower()
if value in HARDWARE:
totals["hardware_video_minutes"] += duration
hardware[HARDWARE[value]] += duration
elif value == "none":
totals["software_video_minutes"] += duration
else:
totals["unknown_hardware_minutes"] += duration
elif info.get("IsVideoDirect") is not True:
totals["unknown_video_minutes"] += duration
if info.get("IsAudioDirect") is False:
totals["audio_minutes"] += duration
codec = str(info.get("AudioCodec") or "Unknown").upper()[:30]
audio_codecs[codec] += duration
elif info.get("IsAudioDirect") is not True:
totals["unknown_audio_minutes"] += duration
def _date(value) -> datetime:
try:
result = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
return result.replace(tzinfo=timezone.utc) if result.tzinfo is None else result.astimezone(timezone.utc)
except (ValueError, TypeError) as exc:
raise JellystatError("Jellystat returned an invalid history date") from exc
def _duration(value) -> float:
try:
result = float(value or 0)
if not math.isfinite(result) or result < 0:
raise ValueError()
return result
except (ValueError, TypeError, OverflowError) as exc:
raise JellystatError("Jellystat returned an invalid playback duration") from exc
async def resolve_identity(user: dict, runtime) -> str | None:
identity = await asyncio.to_thread(linked_user_id, user["username"], runtime.jellyfin_base_url)
if identity:
return identity
if user.get("auth_provider") != "jellyfin":
return None
# Bootstrap existing Jellyfin accounts from the canonical server, using exact names.
# Local accounts and email-prefix matches cannot claim a Jellyfin identity.
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if not client.configured():
return None
try:
users = await client.get_users()
except Exception as exc:
raise JellystatError("Could not resolve the linked Jellyfin account") from exc
matches = [entry for entry in users if isinstance(entry, dict)
and str(entry.get("Name") or "").strip().casefold() == user["username"].strip().casefold()] if isinstance(users, list) else []
if len(matches) != 1 or not matches[0].get("Id"):
return None
await asyncio.to_thread(link_user, user["username"], str(matches[0]["Id"]), runtime.jellyfin_base_url)
return await asyncio.to_thread(linked_user_id, user["username"], runtime.jellyfin_base_url)
def request_summary(user: dict, start: datetime, end: datetime, *, end_exclusive: bool = False) -> dict:
operator = "<" if end_exclusive else "<="
clause = f"julianday(created_at) >= julianday(?) AND julianday(created_at) {operator} julianday(?)"
params = [start.isoformat(), end.isoformat()]
if user.get("jellyseerr_user_id") is not None:
clause += " AND requested_by_id = ?"
params.append(user["jellyseerr_user_id"])
else:
clause += " AND requested_by_id IS NULL AND lower(trim(requested_by)) = ?"
params.append(user["username"].strip().lower())
with closing(db._connect()) as conn, conn:
conn.row_factory = sqlite3.Row
counts = conn.execute(f"""SELECT COUNT(*) AS total,
COALESCE(SUM(media_type = 'movie'), 0) AS movies,
COALESCE(SUM(media_type = 'tv'), 0) AS tv,
COALESCE(SUM(status = 1), 0) AS pending,
COALESCE(SUM(status = 2), 0) AS approved,
COALESCE(SUM(status = 3), 0) AS declined FROM requests_cache WHERE {clause}""", params).fetchone()
recent = conn.execute(f"""SELECT request_id, title, media_type, status FROM requests_cache
WHERE {clause} ORDER BY created_at DESC LIMIT 5""", params).fetchall()
return {**dict(counts), "recent": [dict(row) for row in recent]}
def summarize(history: list, libraries: list, start: datetime, end: datetime, *, end_exclusive: bool = False) -> dict:
library_types = {str(row.get("Id")): str(row.get("CollectionType") or "").lower() for row in libraries}
daily_seconds = defaultdict(float)
weekdays = [0.0] * 7
media_minutes = defaultdict(float)
longest_play = 0.0
clients = defaultdict(float)
methods = defaultdict(float)
transcoding = dict.fromkeys(("video_minutes", "audio_minutes", "hardware_video_minutes", "software_video_minutes",
"unknown_hardware_minutes", "unknown_video_minutes", "unknown_audio_minutes"), 0.0)
hardware, audio_codecs = defaultdict(float), defaultdict(float)
titles = {}
movie_ids, episode_ids, seen = set(), set(), set()
recent = []
seconds = 0.0
for row in history:
row_id = str(row.get("Id") or "")
if not row_id:
raise JellystatError("Jellystat returned history without an activity ID")
if row_id in seen:
continue
seen.add(row_id)
date = _date(row.get("ActivityDateInserted"))
# Defend against older upstream versions ignoring the range filter.
if date < start or (date >= end if end_exclusive else date > end):
continue
duration = _duration(row.get("PlaybackDuration"))
if duration <= 0:
continue
item_id = str(row.get("NowPlayingItemId") or row_id)
episode_id = row.get("EpisodeId")
library_type = library_types.get(str(row.get("ParentId")), "")
media_type = "episode" if episode_id else "movie" if library_type == "movies" else "other"
add_transcoding(row, duration / 60, media_type, transcoding, hardware, audio_codecs)
if media_type == "episode":
episode_ids.add(str(episode_id))
elif media_type == "movie":
movie_ids.add(item_id)
weekdays[date.weekday()] += duration / 60
media_minutes[media_type] += duration / 60
longest_play = max(longest_play, duration / 60)
seconds += duration
daily_seconds[date.date().isoformat()] += duration
client = str(row.get("Client") or "Unknown player")[:200]
clients[client] += duration
method = str(row.get("PlayMethod") or "Unknown")
method = {"DirectPlay": "Direct play", "DirectStream": "Direct stream", "Transcode": "Transcode"}.get(method, "Other")
methods[method] += duration
name = str(row.get("NowPlayingItemName") or "Untitled")[:500]
series = str(row.get("SeriesName") or "")[:500]
title = titles.setdefault(item_id, {"title": series or name, "type": "series" if episode_id else media_type, "minutes": 0, "plays": 0, "artwork_item_id": artwork_item_id(row.get("NowPlayingItemId"))})
title["minutes"] += duration / 60
title["plays"] += 1
recent.append({"id": row_id, "title": name, "series": series, "type": media_type,
"episode": f"S{row.get('SeasonNumber', '?')} · E{row.get('EpisodeNumber', '?')}" if episode_id else None,
"minutes": round(duration / 60, 1), "played_at": date.isoformat(), "client": client,
"method": method, "artwork_item_id": artwork_item_id(row.get("NowPlayingItemId"))})
last_date = (end - timedelta(microseconds=1)).date() if end_exclusive and end > start else end.date()
count = (last_date - start.date()).days + 1
daily = [{"date": (start.date() + timedelta(days=i)).isoformat(),
"minutes": round(daily_seconds.get((start.date() + timedelta(days=i)).isoformat(), 0) / 60, 2)} for i in range(count)]
active_days = {day for day, duration in daily_seconds.items() if duration >= 60}
longest = run = 0
for day in daily:
run = run + 1 if day["date"] in active_days else 0
longest = max(longest, run)
current = 0
cursor = last_date if last_date.isoformat() in active_days else last_date - timedelta(days=1)
while cursor.isoformat() in active_days:
current += 1
cursor -= timedelta(days=1)
top = sorted(titles.values(), key=lambda row: (-row["minutes"], row["title"]))[:6]
for row in top:
row["minutes"] = round(row["minutes"], 1)
return {"summary": {"minutes": round(seconds / 60, 1), "plays": len(recent), "movies": len(movie_ids),
"episodes": len(episode_ids), "active_days": len(active_days),
"current_streak": current, "longest_streak": longest},
"patterns": {"average_play_minutes": round(seconds / 60 / len(recent), 1) if recent else 0,
"longest_play_minutes": round(longest_play, 1),
"weekend_percent": round(sum(weekdays[5:]) / (seconds / 60) * 100, 1) if seconds else 0,
"weekdays": [{"name": name, "minutes": round(weekdays[i], 1)} for i, name in enumerate(("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"))],
"media": [{"name": name, "minutes": round(media_minutes[key], 1)} for key, name in (("movie", "Movies"), ("episode", "TV episodes"), ("other", "Other media"))]},
"daily": daily, "top_titles": top,
"clients": [{"name": name, "minutes": round(value / 60, 1)} for name, value in sorted(clients.items(), key=lambda pair: -pair[1])[:6]],
"methods": [{"name": name, "minutes": round(value / 60, 1)} for name, value in sorted(methods.items(), key=lambda pair: -pair[1])],
"transcoding": {**{name: round(value, 1) for name, value in transcoding.items()},
"hardware": [{"name": name, "minutes": round(value, 1)} for name, value in sorted(hardware.items(), key=lambda pair: -pair[1])],
"audio_codecs": [{"name": name, "minutes": round(value, 1)} for name, value in sorted(audio_codecs.items(), key=lambda pair: -pair[1])],
"gpu_busy_minutes": None},
"recent": sorted(recent, key=lambda row: row["played_at"], reverse=True)[:20]}
async def get_insights(user: dict, days: int) -> dict:
runtime = await asyncio.to_thread(get_runtime_settings)
end = datetime.now(timezone.utc)
start = end - timedelta(days=days)
requests = await asyncio.to_thread(request_summary, user, start, end)
base = {"source": "Jellystat", "days": days, "timezone": "UTC", "requests": requests,
"is_admin": user.get("role") == "admin", "summary": None}
client = JellystatClient(runtime.jellystat_base_url, runtime.jellystat_api_key)
if not client.configured():
return {**base, "state": "not_configured"}
identity = await resolve_identity(user, runtime)
if not identity:
return {**base, "state": "unlinked"}
key = (runtime.jellystat_base_url, hashlib.sha256(runtime.jellystat_api_key.encode()).hexdigest(),
runtime.jellyfin_base_url, identity, days)
cached = _cache.get(key)
if cached and cached[0] > time.monotonic():
return {**base, **with_artwork(cached[1], user, runtime)}
history, libraries = await client.get_user_history(identity, start, end)
data = {**summarize(history, libraries, start, end), "state": "ready", "updated_at": end.isoformat(),
"period_start": start.isoformat(), "period_end": end.isoformat()}
for expired in [key for key, value in _cache.items() if value[0] <= time.monotonic()]:
_cache.pop(expired, None)
if len(_cache) >= 128:
_cache.pop(next(iter(_cache)))
_cache[key] = (time.monotonic() + CACHE_SECONDS, data)
return {**base, **with_artwork(data, user, runtime)}
+101
View File
@@ -0,0 +1,101 @@
"""Private Jellyfin thumbnails for items returned in a user's own viewing history."""
import asyncio
import hashlib
import hmac
import re
import time
from collections import OrderedDict
import httpx
from fastapi import HTTPException
from ..config import settings
TOKEN_SECONDS = 3600
MAX_IMAGE_BYTES = 1024 * 1024
MAX_CACHE_BYTES = 16 * 1024 * 1024
_cache = OrderedDict()
_downloads = asyncio.Semaphore(6)
def item_id(value):
value = str(value or "").replace("-", "").lower()
return value if re.fullmatch(r"[a-f0-9]{32}", value) else None
def source(runtime):
return hashlib.sha256(f"{runtime.jellyfin_base_url}|{runtime.jellyfin_api_key}".encode()).hexdigest()
def signature(user, runtime, media_id, expires):
message = f"insights-artwork\n{user['username']}\n{source(runtime)}\n{media_id}\n{expires}"
return hmac.new(settings.jwt_secret.encode(), message.encode(), hashlib.sha256).hexdigest()
def with_artwork(data, user, runtime):
expires = int(time.time()) + TOKEN_SECONDS
result = {**data}
for field in ("recent", "top_titles"):
rows = []
for play in data.get(field, []):
row = {**play}
media_id = row.pop("artwork_item_id", None)
row["artwork_url"] = None
if item_id(media_id) and settings.jwt_secret and runtime.jellyfin_base_url and runtime.jellyfin_api_key:
token = f"{expires}.{signature(user, runtime, media_id, expires)}"
row["artwork_url"] = f"/insights/artwork/{media_id}?token={token}"
rows.append(row)
result[field] = rows
return result
def verify_artwork_token(user, runtime, media_id, token):
if not settings.jwt_secret or not re.fullmatch(r"[a-f0-9]{32}", media_id):
raise HTTPException(404, "Artwork unavailable")
if not re.fullmatch(r"[0-9]{1,12}\.[a-f0-9]{64}", token):
raise HTTPException(403, "Artwork link is invalid or expired")
try:
expires_text, supplied = token.split(".", 1)
expires = int(expires_text)
except (ValueError, TypeError):
raise HTTPException(403, "Artwork link is invalid or expired") from None
now = int(time.time())
if expires < now or expires > now + TOKEN_SECONDS or not hmac.compare_digest(supplied, signature(user, runtime, media_id, expires)):
raise HTTPException(403, "Artwork link is invalid or expired")
async def get_artwork(user, runtime, media_id, token):
verify_artwork_token(user, runtime, media_id, token)
if not runtime.jellyfin_base_url or not runtime.jellyfin_api_key:
raise HTTPException(404, "Artwork unavailable")
key = (source(runtime), media_id)
async with _downloads:
cached = _cache.get(key)
if cached and cached[0] > time.monotonic():
_cache.move_to_end(key)
return cached[1], cached[2]
try:
async with httpx.AsyncClient(timeout=8.0) as client:
async with client.stream("GET", f"{runtime.jellyfin_base_url.rstrip('/')}/Items/{media_id}/Images/Primary",
headers={"X-Emby-Token": runtime.jellyfin_api_key},
params={"maxWidth": 120, "maxHeight": 180, "quality": 85, "format": "Webp"}) as response:
response.raise_for_status()
content_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower()
if content_type not in {"image/jpeg", "image/png", "image/webp"}:
raise ValueError()
content = bytearray()
async for chunk in response.aiter_bytes():
content.extend(chunk)
if len(content) > MAX_IMAGE_BYTES:
raise ValueError()
if not content:
raise ValueError()
except (httpx.HTTPError, ValueError) as exc:
raise HTTPException(404, "Artwork unavailable") from exc
for expired in [entry for entry, value in _cache.items() if value[0] <= time.monotonic()]:
_cache.pop(expired, None)
while _cache and (len(_cache) >= 128 or sum(len(value[1]) for value in _cache.values()) + len(content) > MAX_CACHE_BYTES):
_cache.popitem(last=False)
_cache[key] = (time.monotonic() + 600, bytes(content), content_type)
return bytes(content), content_type
File diff suppressed because it is too large Load Diff
+477
View File
@@ -0,0 +1,477 @@
from __future__ import annotations
import asyncio
from datetime import datetime, timedelta, timezone
from html import escape
import json
import logging
from typing import Any, Dict, Optional
from ..config import settings as env_settings
from ..db import (
add_portal_item_activity,
get_portal_item,
get_user_by_username,
list_portal_item_activity,
list_portal_items,
update_portal_item,
)
from ..runtime import get_runtime_settings
from .invite_email import resolve_user_delivery_email, send_generic_email
from .snapshot import build_snapshot
from .media_repair import evaluate_media_repair
logger = logging.getLogger(__name__)
_SYSTEM_USER = "Magent"
_MEDIA_REPAIR_STARTED_EVENTS = {"replacement_started", "missing_search_started"}
def _now() -> datetime:
return datetime.now(timezone.utc)
def _parse_datetime(value: Any) -> Optional[datetime]:
if not isinstance(value, str) or not value.strip():
return None
try:
parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
except ValueError:
return None
return parsed.replace(tzinfo=parsed.tzinfo or timezone.utc).astimezone(timezone.utc)
def _metadata(item: Dict[str, Any]) -> Dict[str, Any]:
raw = item.get("metadata_json")
if not isinstance(raw, str) or not raw.strip():
return {}
try:
parsed = json.loads(raw)
except (TypeError, ValueError):
return {}
return parsed if isinstance(parsed, dict) else {}
def issue_resolution_state(item: Dict[str, Any]) -> Dict[str, Any]:
state = _metadata(item).get("resolutionConfirmation")
return dict(state) if isinstance(state, dict) else {}
def _metadata_with_resolution(item: Dict[str, Any], state: Dict[str, Any]) -> str:
metadata = _metadata(item)
metadata["resolutionConfirmation"] = state
return json.dumps(metadata, separators=(",", ":"), sort_keys=True)
def _interval_delta(value: int, unit: str) -> timedelta:
safe_value = max(1, min(int(value), 365))
normalized_unit = str(unit or "days").strip().lower()
if normalized_unit == "weeks":
return timedelta(weeks=safe_value)
if normalized_unit == "months":
return timedelta(days=30 * safe_value)
return timedelta(days=safe_value)
def _workflow_settings() -> tuple[int, int, str]:
runtime = get_runtime_settings()
attempts = max(0, min(int(runtime.issue_confirmation_contact_attempts or 0), 10))
interval_value = max(1, min(int(runtime.issue_confirmation_interval_value or 1), 365))
interval_unit = str(runtime.issue_confirmation_interval_unit or "days").strip().lower()
if interval_unit not in {"days", "weeks", "months"}:
interval_unit = "days"
return attempts, interval_value, interval_unit
def _app_url() -> str:
runtime = get_runtime_settings()
for value in (runtime.magent_application_url, runtime.magent_proxy_base_url, env_settings.cors_allow_origin):
candidate = str(value or "").strip()
if candidate:
return candidate.rstrip("/")
return f"http://localhost:{int(runtime.magent_application_port or 3000)}"
def _issue_url(item_id: int) -> str:
return f"{_app_url()}/portal/issues?item={item_id}"
def _activity(
item_id: int,
event_type: str,
message: str,
*,
actor_username: str = _SYSTEM_USER,
actor_role: str = "system",
metadata: Optional[Dict[str, Any]] = None,
) -> None:
add_portal_item_activity(
item_id,
event_type=event_type,
actor_username=actor_username,
actor_role=actor_role,
message=message,
metadata_json=json.dumps(metadata, separators=(",", ":"), sort_keys=True) if metadata else None,
)
def _activity_metadata(entry: Dict[str, Any]) -> Dict[str, Any]:
raw = entry.get("metadata_json")
if not isinstance(raw, str) or not raw.strip():
return {}
try:
parsed = json.loads(raw)
except (TypeError, ValueError):
return {}
return parsed if isinstance(parsed, dict) else {}
def _repair_tracking(item_id: int) -> tuple[Dict[str, Any], list[Dict[str, Any]]]:
activity = list_portal_item_activity(item_id, limit=500)
for entry in reversed(activity):
# A rejected repair must not be proposed again simply because the same
# replacement file is still present. Wait for a NEW repair attempt.
if str(entry.get("event_type") or "") == "resolution_rejected":
return {}, activity
if str(entry.get("event_type") or "") not in _MEDIA_REPAIR_STARTED_EVENTS:
continue
tracking = _activity_metadata(entry).get("repairTracking")
if isinstance(tracking, dict):
return dict(tracking), activity
return {}, activity
async def _media_repair_evidence(tracking: Dict[str, Any]) -> Dict[str, Any]:
snapshot = await build_snapshot(str(tracking.get("requestId") or ""))
raw = snapshot.raw if isinstance(snapshot.raw, dict) else {}
jellyfin = dict(raw.get("jellyfin") or {})
jellyfin["found"] = jellyfin.get("catalogFound", jellyfin.get("found"))
return await evaluate_media_repair(
tracking, (raw.get("arr") or {}).get("item"), jellyfin,
episodes=(raw.get("arr") or {}).get("episodes"),
)
def _close_issue(
item: Dict[str, Any],
*,
reason: str,
confirmed: bool,
actor_username: str = _SYSTEM_USER,
actor_role: str = "system",
) -> Dict[str, Any]:
now = _now().isoformat()
state = issue_resolution_state(item)
state.update(
{
"status": "confirmed" if confirmed else "auto_closed",
"confirmedAt": now if confirmed else state.get("confirmedAt"),
"closedAt": now,
"nextContactAt": None,
"closedReason": reason,
}
)
updated = update_portal_item(
int(item["id"]),
status="closed",
issue_resolved_at=now,
metadata_json=_metadata_with_resolution(item, state),
)
if not updated:
raise RuntimeError("Issue could not be closed")
_activity(
int(item["id"]),
"resolution_confirmed" if confirmed else "issue_auto_closed",
reason,
actor_username=actor_username,
actor_role=actor_role,
)
return updated
async def _contact_reporter(item: Dict[str, Any]) -> Dict[str, Any]:
maximum, interval_value, interval_unit = _workflow_settings()
state = issue_resolution_state(item)
attempts = max(0, int(state.get("attemptsSent") or 0))
if maximum <= 0:
return _close_issue(
item,
reason="Issue closed automatically because reporter confirmation emails are disabled.",
confirmed=False,
)
if attempts >= maximum:
return _close_issue(
item,
reason=f"Issue closed automatically after {attempts} confirmation email attempt(s) without a response.",
confirmed=False,
)
attempt_number = attempts + 1
reporter = get_user_by_username(str(item.get("created_by_username") or ""))
recipient = resolve_user_delivery_email(reporter)
issue_url = f"{_app_url()}/issues/confirm/{int(item['id'])}"
sent = False
delivery_error: Optional[str] = None
if recipient:
subject = f"Ready to try again? Magent issue #{item['id']}"
body_text = (
"Your repair looks ready to test.\n\n"
f"{item.get('title') or 'Your reported issue'}\n\n"
"Please try the affected content in Jellyfin. Is it fixed?\n\n"
f"YES — it works: {issue_url}#yes\n"
f"NO — still broken: {issue_url}#no\n\n"
"Confirm your answer in Magent. You may need to sign in first.\n"
"Yes closes the report. No keeps it open for another look.\n\n"
f"Reminder {attempt_number} of {maximum}. If we do not hear back after the reminder period, this report will close automatically."
)
body_html = (
'<div style="background:#111113;padding:24px 12px;font-family:Arial,sans-serif;color:#f4f4f5;">'
'<table role="presentation" style="max-width:560px;width:100%;margin:auto;background:#202023;border:1px solid #45454d;border-radius:18px;"><tr><td style="padding:28px;">'
'<p style="margin:0 0 24px;color:#c7baff;font-weight:bold;letter-spacing:2px;">MAGENT</p>'
'<h1 style="font-size:32px;line-height:1.2;margin:0 0 16px;color:#fff;">Ready to try again?</h1>'
'<p style="font-size:17px;line-height:1.6;color:#e4e4e7;">Your repair looks ready to test. Give the affected content a try, then let us know:</p>'
f'<p style="padding:16px;background:#131315;border-radius:10px;color:#fff;">{escape(str(item.get("title") or "Your reported issue"))}</p>'
'<h2 style="font-size:26px;color:#fff;margin:24px 0 16px;">Is it fixed?</h2>'
f'<a href="{escape(issue_url)}#yes" style="display:block;text-align:center;padding:20px;margin-bottom:12px;border-radius:12px;background:#b4f4d2;color:#10261b;text-decoration:none;font-size:24px;font-weight:bold;">YES — it works</a>'
f'<a href="{escape(issue_url)}#no" style="display:block;text-align:center;padding:20px;border-radius:12px;background:#ffc1c5;color:#391318;text-decoration:none;font-size:24px;font-weight:bold;">NO — still broken</a>'
'<p style="font-size:14px;line-height:1.6;color:#dedee3;">Confirm your answer in Magent. You may need to sign in first.<br>Yes closes the report. No keeps it open for another look.</p>'
f'<p style="font-size:12px;line-height:1.6;color:#b9b9c3;">Reminder {attempt_number} of {maximum} · Issue #{int(item["id"])}<br>If we do not hear back after the reminder period, this report will close automatically.</p>'
'</td></tr></table></div>'
)
try:
await send_generic_email(
recipient_email=recipient,
subject=subject,
body_text=body_text,
body_html=body_html,
)
sent = True
except Exception as exc:
delivery_error = str(exc)
logger.exception("issue confirmation email failed item_id=%s attempt=%s", item.get("id"), attempt_number)
else:
delivery_error = "No email address is stored for the reporter."
now = _now()
state.update(
{
"status": "awaiting_confirmation",
"attemptsSent": attempt_number,
"maximumAttempts": maximum,
"lastContactAt": now.isoformat(),
"nextContactAt": (now + _interval_delta(interval_value, interval_unit)).isoformat(),
"intervalValue": interval_value,
"intervalUnit": interval_unit,
"lastDeliverySucceeded": sent,
"lastDeliveryError": delivery_error,
}
)
updated = update_portal_item(
int(item["id"]),
metadata_json=_metadata_with_resolution(item, state),
)
if not updated:
raise RuntimeError("Issue confirmation schedule could not be saved")
if sent:
message = f"Confirmation email {attempt_number} of {maximum} was sent to the reporter."
else:
message = f"Confirmation email {attempt_number} of {maximum} could not be delivered."
_activity(
int(item["id"]),
"confirmation_email_sent" if sent else "confirmation_email_failed",
message,
metadata={
"attempt": attempt_number,
"maximum": maximum,
"nextContactAt": state["nextContactAt"],
"deliveryError": delivery_error,
},
)
return updated
async def begin_issue_confirmation(
item_id: int,
*,
actor_username: str,
actor_role: str,
) -> Dict[str, Any]:
item = get_portal_item(item_id)
if not item or str(item.get("kind") or "").lower() != "issue":
raise ValueError("Issue not found")
now = _now().isoformat()
maximum, interval_value, interval_unit = _workflow_settings()
state = {
"status": "awaiting_confirmation",
"startedAt": now,
"attemptsSent": 0,
"maximumAttempts": maximum,
"lastContactAt": None,
"nextContactAt": now,
"intervalValue": interval_value,
"intervalUnit": interval_unit,
"confirmedAt": None,
"closedAt": None,
}
updated = update_portal_item(
item_id,
status="awaiting_confirmation",
issue_resolved_at=None,
metadata_json=_metadata_with_resolution(item, state),
)
if not updated:
raise RuntimeError("Issue confirmation workflow could not be started")
_activity(
item_id,
"resolution_proposed",
"The issue was marked fixed and sent to the reporter for confirmation.",
actor_username=actor_username,
actor_role=actor_role,
metadata={"maximumAttempts": maximum, "intervalValue": interval_value, "intervalUnit": interval_unit},
)
return await _contact_reporter(updated)
def respond_to_issue_confirmation(
item_id: int,
*,
resolved: bool,
actor_username: str,
actor_role: str,
) -> Dict[str, Any]:
item = get_portal_item(item_id)
if not item or str(item.get("kind") or "").lower() != "issue":
raise ValueError("Issue not found")
if str(item.get("status") or "").lower() != "awaiting_confirmation":
raise ValueError("This issue is not waiting for resolution confirmation")
if resolved:
return _close_issue(
item,
reason="The reporter confirmed that the issue is fixed.",
confirmed=True,
actor_username=actor_username,
actor_role=actor_role,
)
now = _now().isoformat()
state = issue_resolution_state(item)
state.update(
{
"status": "reported_still_broken",
"reporterResponseAt": now,
"nextContactAt": None,
"closedAt": None,
}
)
updated = update_portal_item(
item_id,
status="in_progress",
issue_resolved_at=None,
metadata_json=_metadata_with_resolution(item, state),
)
if not updated:
raise RuntimeError("Issue could not be reopened")
_activity(
item_id,
"resolution_rejected",
"The reporter said the issue is still happening. The issue was returned to In progress.",
actor_username=actor_username,
actor_role=actor_role,
)
return updated
async def process_active_media_repairs() -> Dict[str, int]:
items = list_portal_items(kind="issue", status="in_progress", limit=500)
result = {"checked": 0, "waiting": 0, "completed": 0, "failed": 0}
for item in items:
tracking, activity = _repair_tracking(int(item["id"]))
if not tracking:
continue
result["checked"] += 1
try:
evidence = await _media_repair_evidence(tracking)
if evidence.get("complete"):
_activity(
int(item["id"]),
"repair_verified",
str(evidence.get("message") or "Magent verified the repaired media in Jellyfin."),
metadata={
"requestId": tracking.get("requestId"),
"actionId": tracking.get("actionId"),
},
)
await begin_issue_confirmation(
int(item["id"]),
actor_username=_SYSTEM_USER,
actor_role="system",
)
result["completed"] += 1
continue
result["waiting"] += 1
if evidence.get("phase") == "indexing" and not any(
str(entry.get("event_type") or "") == "repair_imported"
for entry in activity
):
_activity(
int(item["id"]),
"repair_imported",
str(evidence.get("message") or "The repaired file was imported and is waiting for Jellyfin."),
metadata={
"requestId": tracking.get("requestId"),
"actionId": tracking.get("actionId"),
},
)
except Exception:
result["failed"] += 1
logger.exception("automatic media repair check failed item_id=%s", item.get("id"))
return result
async def process_due_issue_confirmations(now: Optional[datetime] = None) -> Dict[str, int]:
current = (now or _now()).astimezone(timezone.utc)
items = list_portal_items(kind="issue", status="awaiting_confirmation", limit=500)
result = {"checked": len(items), "contacted": 0, "closed": 0, "failed": 0}
maximum, _, _ = _workflow_settings()
for item in items:
state = issue_resolution_state(item)
due_at = _parse_datetime(state.get("nextContactAt"))
if due_at and due_at > current:
continue
try:
attempts = max(0, int(state.get("attemptsSent") or 0))
if maximum <= 0 or attempts >= maximum:
_close_issue(
item,
reason=(
"Issue closed automatically because reporter confirmation emails are disabled."
if maximum <= 0
else f"Issue closed automatically after {attempts} confirmation email attempt(s) without a response."
),
confirmed=False,
)
result["closed"] += 1
else:
await _contact_reporter(item)
result["contacted"] += 1
except Exception:
result["failed"] += 1
logger.exception("issue confirmation processing failed item_id=%s", item.get("id"))
return result
async def run_issue_confirmation_loop() -> None:
while True:
try:
repair_result = await process_active_media_repairs()
if repair_result["completed"] or repair_result["failed"]:
logger.info("automatic media repair sweep complete result=%s", repair_result)
result = await process_due_issue_confirmations()
if result["contacted"] or result["closed"] or result["failed"]:
logger.info("issue confirmation sweep complete result=%s", result)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("issue confirmation sweep failed")
await asyncio.sleep(60)
+51
View File
@@ -0,0 +1,51 @@
"""Stable Jellyfin identities for private, user-scoped integrations."""
import hashlib
from contextlib import closing
from .. import db
def source_key(base_url: str | None) -> str:
return hashlib.sha256(str(base_url or "").strip().rstrip("/").encode()).hexdigest()
def linked_user_id(username: str, base_url: str | None) -> str | None:
user = db.get_user_by_username(username)
if not user or not base_url:
return None
with closing(db._connect()) as conn, conn:
row = conn.execute(
"SELECT jellyfin_user_id FROM jellyfin_user_links WHERE source = ? AND local_user_id = ?",
(source_key(base_url), user["id"]),
).fetchone()
return row[0] if row else None
def link_user(username: str, jellyfin_user_id: str, base_url: str | None) -> None:
"""Use only verified login or canonical Jellyfin user sync, never playback names."""
user = db.get_user_by_username(username)
if not user or not jellyfin_user_id or not base_url:
return
with closing(db._connect()) as conn, conn:
if conn.execute("SELECT 1 FROM user_identity_confirmations WHERE local_user_id = ?", (user["id"],)).fetchone():
# Reviewed identities are updated only through the admin confirmation workflow.
return
# A renamed or re-created account must not silently take over an existing identity.
conn.execute(
"INSERT OR IGNORE INTO jellyfin_user_links (source, local_user_id, jellyfin_user_id) VALUES (?, ?, ?)",
(source_key(base_url), user["id"], str(jellyfin_user_id)),
)
def user_for_identity(jellyfin_user_id: str, base_url: str | None):
"""Resolve a verified upstream login to its existing local account."""
if not jellyfin_user_id or not base_url:
return None
with closing(db._connect()) as conn:
rows = conn.execute("SELECT local_user_id FROM jellyfin_user_links WHERE source=? AND lower(replace(jellyfin_user_id,'-',''))=?",
(source_key(base_url), str(jellyfin_user_id).replace('-', '').lower())).fetchall()
if len(rows) > 1:
from fastapi import HTTPException
raise HTTPException(409, 'Multiple accounts claim this Jellyfin ID. Ask an administrator to repair the links.')
return db.get_user_by_id(rows[0][0]) if rows else None
+115
View File
@@ -0,0 +1,115 @@
import logging
from collections import Counter
from contextlib import closing
from .. import db
from .jellyfin_identity import source_key
from .identity_review import normalized_id, name_key
from fastapi import HTTPException
from ..clients.jellyfin import JellyfinClient
from ..db import (
create_user_if_missing,
get_user_by_username,
set_user_auth_provider,
set_user_jellyseerr_id,
)
from ..runtime import get_runtime_settings
from .jellyfin_identity import link_user
from .user_cache import (
extract_jellyseerr_user_email,
get_cached_jellyseerr_users,
save_jellyfin_users_cache,
)
logger = logging.getLogger(__name__)
async def sync_jellyfin_users() -> int:
runtime = get_runtime_settings()
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if not client.configured():
raise HTTPException(status_code=400, detail="Jellyfin not configured")
users = await client.get_users()
if not isinstance(users, list):
return 0
save_jellyfin_users_cache(users)
# Jellyfin is the canonical source for local user objects; Seerr IDs are
# matched as enrichment when possible.
jellyseerr_users = get_cached_jellyseerr_users()
imported = 0
name_counts = Counter(name_key(row.get('Name')) for row in users if isinstance(row, dict))
with closing(db._connect()) as conn:
links = [dict(zip(('local_id', 'jf_id'), row)) for row in conn.execute(
'SELECT local_user_id,jellyfin_user_id FROM jellyfin_user_links WHERE source=?', (source_key(runtime.jellyfin_base_url),))]
for user in users:
if not isinstance(user, dict):
continue
name, jf_id = user.get('Name'), normalized_id(user.get('Id'))
if not name or not jf_id or name_counts[name_key(name)] != 1:
continue
matches = [row for row in (jellyseerr_users or []) if normalized_id(row.get('jellyfinUserId')) == jf_id]
if len(matches) > 1:
continue
matched = matches[0] if matches else None
matched_id = matched.get('id') if matched else None
owners = [row['local_id'] for row in links if normalized_id(row['jf_id']) == jf_id]
if len(owners) > 1:
continue
existing = db.get_user_by_id(owners[0]) if owners else None
if not existing and matched_id is not None:
candidates = [row for row in db.get_all_users() if row.get('jellyseerr_user_id') == matched_id]
if len(candidates) > 1:
continue
existing = candidates[0] if candidates else None
if not existing:
existing = get_user_by_username(name)
if existing:
existing_links = [normalized_id(row['jf_id']) for row in links if row['local_id'] == existing['id']]
if existing_links and any(value != jf_id for value in existing_links):
continue
if existing.get('role') == 'admin' or existing.get('auth_provider') == 'local':
continue
canonical = existing['username']
# Never overwrite a stored Seerr identity on name evidence.
if existing.get('jellyseerr_user_id') not in (None, matched_id):
continue
set_user_auth_provider(canonical, 'jellyfin')
else:
canonical = name
if create_user_if_missing(canonical, 'jellyfin-user', auth_provider='jellyfin',
jellyseerr_user_id=matched_id, email=extract_jellyseerr_user_email(matched)):
imported += 1
if matched_id is not None:
set_user_jellyseerr_id(canonical, matched_id)
link_user(canonical, jf_id, runtime.jellyfin_base_url)
return imported
async def run_daily_jellyfin_sync() -> None:
while True:
delay = _seconds_until_midnight()
await _sleep_seconds(delay)
try:
imported = await sync_jellyfin_users()
logger.info("Jellyfin daily sync complete: imported=%s", imported)
except HTTPException as exc:
logger.warning("Jellyfin daily sync skipped: %s", exc.detail)
except Exception:
logger.exception("Jellyfin daily sync failed")
def _seconds_until_midnight() -> float:
from datetime import datetime, timedelta
now = datetime.now()
next_midnight = (now + timedelta(days=1)).replace(
hour=0, minute=0, second=0, microsecond=0
)
return max((next_midnight - now).total_seconds(), 0.0)
async def _sleep_seconds(delay: float) -> None:
import asyncio
await asyncio.sleep(delay)
+55
View File
@@ -0,0 +1,55 @@
"""Manual collector decisions and short-lived, request-bound selection receipts."""
from datetime import datetime, timedelta, timezone
import hashlib
import jwt
from fastapi import HTTPException
from ..config import settings
def can_override(user):
return user.get('role') == 'admin' or (user.get('features') or {}).get('ignore_profile_limits') is True
def decision(item):
reasons = [str(r) for r in (item.get('rejections') or [])]
accepted = (item.get('approved') is True and not reasons and not item.get('rejected')
and not item.get('temporarilyRejected') and item.get('downloadAllowed') is not False)
# Unknown/operational rejections remain blocked. This permission only relaxes profile limits.
profile_only = bool(reasons) and all(any(term in reason.lower() for term in (
'quality profile', 'not wanted in profile', 'custom format', 'minimum score',
'quality is not', 'quality for', 'language', 'maximum size', 'minimum size',
'larger than', 'smaller than', 'size limit', 'release profile',
)) for reason in reasons)
override = not accepted and profile_only and item.get('downloadAllowed') is not False and not item.get('temporarilyRejected')
return accepted, override, reasons
def source_id(url):
return hashlib.sha256(str(url).rstrip('/').encode()).hexdigest()
def issue_selection(release, request_id, user, source, item_id):
return jwt.encode({'aud': 'manual-release', 'sub': user['username'], 'request': str(request_id),
'source': source_id(source), 'item': item_id, 'guid': release['guid'],
'indexer': release['indexerId'], 'title': release.get('title'),
'override': release['requiresOverride'], 'rejections': release['rejections'],
'exp': datetime.now(timezone.utc) + timedelta(minutes=10)},
settings.jwt_secret, algorithm='HS256')
def verify_selection(payload, request_id, user, source, item_id):
try:
receipt = jwt.decode(payload.get('selectionToken', ''), settings.jwt_secret,
algorithms=['HS256'], audience='manual-release')
except jwt.InvalidTokenError as exc:
raise HTTPException(409, 'This release selection expired or is invalid. Search again before downloading.') from exc
if (receipt.get('sub') != user.get('username') or receipt.get('request') != str(request_id)
or receipt.get('source') != source_id(source) or receipt.get('item') != item_id
or receipt.get('guid') != payload.get('guid') or receipt.get('indexer') != payload.get('indexerId')):
raise HTTPException(409, 'This release does not belong to this account and request. Search again.')
if receipt.get('override'):
if not can_override(user):
raise HTTPException(403, 'Ignore profile limits is disabled for your account.')
if payload.get('ignoreProfileLimits') is not True:
raise HTTPException(400, 'Explicitly confirm ignoring the profile limits for this release.')
return receipt
+162
View File
@@ -0,0 +1,162 @@
from __future__ import annotations
import json
from datetime import datetime
from typing import Any, Dict
from ..clients.jellyfin import JellyfinClient
from ..clients.sonarr import SonarrClient
from ..runtime import get_runtime_settings
def current_cycle_torrents(torrents: Any, cycle: str | None) -> list[Dict[str, Any]]:
"""Old seeding jobs are not proof of a replacement download.
A same-hash retry is valid when it is downloading again or was added anew.
Without a completion/add timestamp, a completed legacy job cannot prove that.
"""
rows = [item for item in torrents if isinstance(item, dict)] if isinstance(torrents, list) else []
if not cycle:
return rows
cutoff = datetime.fromisoformat(cycle).timestamp()
def belongs(item: Dict[str, Any]) -> bool:
try:
progress = float(item.get("progress", 0))
completed = float(item.get("completion_on") or 0)
added = float(item.get("added_on") or 0)
except (TypeError, ValueError):
return False
return progress < 1 or max(completed, added) >= cutoff
return [item for item in rows if belongs(item)]
def _positive_ints(value: Any) -> list[int]:
if not isinstance(value, list):
return []
return [
int(item)
for item in value
if isinstance(item, int) and not isinstance(item, bool) and item > 0
]
def _media_signature(item: Any) -> Dict[str, str]:
if not isinstance(item, dict):
return {}
result: Dict[str, str] = {}
for key in ("Id", "Etag", "Path", "DateCreated", "MediaSources"):
value = item.get(key)
if isinstance(value, (dict, list)):
result[key] = json.dumps(value, separators=(",", ":"), sort_keys=True)
elif value is not None and str(value).strip():
result[key] = str(value).strip()
return result
def _signature_changed(current: Dict[str, str], baseline: Dict[str, Any]) -> bool:
previous = _media_signature(baseline)
if not previous:
return True
return any(current.get(key) and current.get(key) != value for key, value in previous.items())
async def evaluate_media_repair(
tracking: Dict[str, Any], arr_item: Any, jellyfin: Dict[str, Any],
*, episodes: Any = None,
) -> Dict[str, Any]:
request_id = str(tracking.get("requestId") or "").strip()
action_id = str(tracking.get("actionId") or "").strip()
media_type = str(tracking.get("mediaType") or "").strip().lower()
collector_id = tracking.get("collectorId")
if not request_id.isdigit() or media_type not in {"movie", "tv"} or not isinstance(collector_id, int):
return {"complete": False, "phase": "invalid", "message": "Repair tracking information is incomplete."}
jellyfin_item = jellyfin.get("item")
original_file_ids = set(_positive_ints(tracking.get("originalFileIds")))
baselines = tracking.get("jellyfinBaseline")
baselines = [item for item in baselines if isinstance(item, dict)] if isinstance(baselines, list) else []
found_at_start = tracking.get("jellyfinFoundAtStart") is True
if not isinstance(arr_item, dict) or arr_item.get("id") != collector_id:
return {"complete": False, "phase": "collecting", "message": "Waiting for the correct collector record."}
if media_type == "movie":
movie_file = arr_item.get("movieFile") if isinstance(arr_item, dict) else None
current_file_id = movie_file.get("id") if isinstance(movie_file, dict) else None
imported = arr_item.get("hasFile") is not False and isinstance(current_file_id, int) and current_file_id > 0 and current_file_id not in original_file_ids
if not imported:
return {"complete": False, "phase": "collecting", "message": "Waiting for Radarr to import the repaired movie file."}
if not jellyfin.get("found") or not isinstance(jellyfin_item, dict):
return {"complete": False, "phase": "indexing", "message": "Radarr imported the repaired movie. Waiting for Jellyfin to index it."}
current_signature = _media_signature(jellyfin_item)
if action_id == "replace_media" and found_at_start:
if not baselines or not _signature_changed(current_signature, baselines[0]):
return {"complete": False, "phase": "indexing", "message": "Radarr imported the repaired movie. Waiting for Jellyfin to refresh the existing title."}
return {"complete": True, "phase": "complete", "message": "Radarr imported the repaired movie and Jellyfin has indexed the updated file."}
target_rows = tracking.get("episodes")
targets = [item for item in target_rows if isinstance(item, dict)] if isinstance(target_rows, list) else []
target_ids = {
int(item["id"])
for item in targets
if isinstance(item.get("id"), int) and int(item["id"]) > 0
}
target_pairs = {
(int(item["seasonNumber"]), int(item["episodeNumber"]))
for item in targets
if isinstance(item.get("seasonNumber"), int) and isinstance(item.get("episodeNumber"), int)
}
if not target_ids or not target_pairs:
return {"complete": False, "phase": "invalid", "message": "No exact Sonarr episodes were recorded for this repair."}
runtime = get_runtime_settings()
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
if episodes is None:
episodes = await sonarr.get_episodes(collector_id)
episode_map = {
int(item["id"]): item
for item in episodes
if isinstance(item, dict) and isinstance(item.get("id"), int)
} if isinstance(episodes, list) else {}
imported = all(
episode_id in episode_map
and episode_map[episode_id].get("hasFile") is not False
and (
episode_map[episode_id].get("hasFile") is True
or (
isinstance(episode_map[episode_id].get("episodeFileId"), int)
and episode_map[episode_id]["episodeFileId"] > 0
)
)
and episode_map[episode_id].get("episodeFileId") not in original_file_ids
for episode_id in target_ids
)
if not imported:
return {"complete": False, "phase": "collecting", "message": "Waiting for Sonarr to import every repaired episode."}
jellyfin_series_id = jellyfin_item.get("Id") if isinstance(jellyfin_item, dict) else None
jellyfin_client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if not jellyfin.get("found") or not jellyfin_series_id or not jellyfin_client.configured():
return {"complete": False, "phase": "indexing", "message": "Sonarr imported the repaired episodes. Waiting for Jellyfin to index them."}
jellyfin_episodes = await jellyfin_client.get_series_episodes(str(jellyfin_series_id))
current_by_pair = {
(int(item["ParentIndexNumber"]), int(item["IndexNumber"])): item
for item in jellyfin_episodes
if isinstance(item.get("ParentIndexNumber"), int) and isinstance(item.get("IndexNumber"), int)
}
if not all(pair in current_by_pair for pair in target_pairs):
return {"complete": False, "phase": "indexing", "message": "Sonarr imported the repaired episodes. Waiting for the exact episodes to appear in Jellyfin."}
if action_id == "replace_media" and found_at_start:
baseline_by_pair = {
(int(item["seasonNumber"]), int(item["episodeNumber"])): item
for item in baselines
if isinstance(item.get("seasonNumber"), int) and isinstance(item.get("episodeNumber"), int)
}
if any(pair not in baseline_by_pair for pair in target_pairs):
return {"complete": False, "phase": "indexing", "message": "Sonarr imported the repaired episodes. Waiting for Jellyfin to confirm the existing entries changed."}
if not all(
_signature_changed(_media_signature(current_by_pair[pair]), baseline_by_pair[pair])
for pair in target_pairs
):
return {"complete": False, "phase": "indexing", "message": "Sonarr imported the repaired episodes. Waiting for Jellyfin to refresh every affected episode."}
return {"complete": True, "phase": "complete", "message": "Sonarr imported every repaired episode and Jellyfin has indexed the updated files."}
+149
View File
@@ -0,0 +1,149 @@
"""Personal calendar-month reports built from retained Jellystat history."""
import asyncio
import csv
import hashlib
import io
import re
import time
from datetime import datetime, timezone
from ..clients.jellystat import JellystatClient
from ..runtime import get_runtime_settings
from .insights import request_summary, resolve_identity, summarize
from .insights_artwork import with_artwork
_cache: dict[tuple, tuple[float, dict]] = {}
CACHE_SECONDS = 60
MONTH_COUNT = 24
def shift_month(value: datetime, offset: int) -> datetime:
year, month = divmod(value.year * 12 + value.month - 1 + offset, 12)
return datetime(year, month + 1, 1, tzinfo=timezone.utc)
def month_periods(month: str | None, now: datetime) -> dict:
now = now.astimezone(timezone.utc)
this_month = shift_month(now, 0)
available = [shift_month(this_month, -offset).strftime("%Y-%m") for offset in range(MONTH_COUNT)]
selected = month if month is not None else available[1]
if not re.fullmatch(r"[0-9]{4}-[0-9]{2}", selected) or selected not in available:
raise ValueError("Choose the current month or one of the previous 23 months.")
start = datetime.strptime(selected, "%Y-%m").replace(tzinfo=timezone.utc)
calendar_end = shift_month(start, 1)
end = min(calendar_end, now)
previous_start = shift_month(start, -1)
partial = end < calendar_end
previous_end = min(previous_start + (end - start), start) if partial else start
return {"month": selected, "available_months": available, "timezone": "UTC",
"period_start": start.isoformat(), "period_end": end.isoformat(),
"is_partial": partial, "comparison_month": previous_start.strftime("%Y-%m"),
"comparison_start": previous_start.isoformat(), "comparison_end": previous_end.isoformat(),
"comparison_capped": partial and previous_start + (end - start) > start}
def change(current: float, previous: float) -> dict:
difference = round(current - previous, 1)
percent = round(difference / previous * 100, 1) if previous else 0.0 if not current else None
return {"current": current, "previous": previous, "difference": difference, "percent": percent}
async def get_monthly_report(user: dict, month: str | None = None) -> dict:
now = datetime.now(timezone.utc)
periods = month_periods(month, now)
runtime = await asyncio.to_thread(get_runtime_settings)
base = {**periods, "source": "Jellystat", "is_admin": user.get("role") == "admin", "summary": None}
client = JellystatClient(runtime.jellystat_base_url, runtime.jellystat_api_key)
if not client.configured():
return {**base, "state": "not_configured"}
identity = await resolve_identity(user, runtime)
if not identity:
return {**base, "state": "unlinked"}
# Cache playback only. Request ownership and request statuses are read afresh.
key = (runtime.jellystat_base_url, hashlib.sha256(runtime.jellystat_api_key.encode()).hexdigest(),
runtime.jellyfin_base_url, identity, periods["month"], now.strftime("%Y-%m"))
cached = _cache.get(key)
if cached and cached[0] > time.monotonic():
data = cached[1]
else:
history, libraries = await client.get_user_history(identity,
datetime.fromisoformat(periods["comparison_start"]), datetime.fromisoformat(periods["period_end"]))
current = summarize(history, libraries, datetime.fromisoformat(periods["period_start"]),
datetime.fromisoformat(periods["period_end"]), end_exclusive=True)
previous = summarize(history, libraries, datetime.fromisoformat(periods["comparison_start"]),
datetime.fromisoformat(periods["comparison_end"]), end_exclusive=True)
data = {**periods, **current, "previous_summary": previous["summary"], "updated_at": now.isoformat()}
for expired in [entry for entry, value in _cache.items() if value[0] <= time.monotonic()]:
_cache.pop(expired, None)
if len(_cache) >= 128:
_cache.pop(next(iter(_cache)))
_cache[key] = (time.monotonic() + CACHE_SECONDS, data)
requests, previous_requests = await asyncio.gather(
asyncio.to_thread(request_summary, user, datetime.fromisoformat(data["period_start"]),
datetime.fromisoformat(data["period_end"]), end_exclusive=True),
asyncio.to_thread(request_summary, user, datetime.fromisoformat(data["comparison_start"]),
datetime.fromisoformat(data["comparison_end"]), end_exclusive=True))
changes = {name: change(data["summary"][name], data["previous_summary"][name])
for name in ("minutes", "movies", "episodes", "plays", "active_days", "longest_streak")}
changes["requests"] = change(requests["total"], previous_requests["total"])
return {**base, **with_artwork(data, user, runtime), "state": "ready", "requests": requests,
"previous_requests": {name: value for name, value in previous_requests.items() if name != "recent"},
"changes": changes}
def report_csv(report: dict) -> str:
"""Export normalized data only; protect text cells from spreadsheet formulas."""
output = io.StringIO(newline="")
writer = csv.writer(output)
def row(*cells):
safe = []
for cell in cells:
if isinstance(cell, str) and re.match(r"^[\s\ufeff]*[=+\-@]", cell):
cell = "'" + cell
safe.append(cell)
writer.writerow(safe)
row("Magent monthly viewing report", report["month"])
row("Timezone", "UTC")
row("Period start (inclusive)", report["period_start"])
row("Period end (exclusive)", report["period_end"])
row("Report period", "Month to date" if report["is_partial"] else "Complete calendar month")
row("Comparison start (inclusive)", report["comparison_start"])
row("Comparison end (exclusive)", report["comparison_end"])
row("Generated at", report["updated_at"])
row("Data coverage", "Retained Jellystat history and requests available in Magent; request statuses are current.")
row()
row("Metric", "This period", "Previous period", "Difference", "Change (%)")
labels = {"minutes": "Minutes watched", "movies": "Distinct movies played", "episodes": "Distinct episodes played",
"plays": "Plays", "active_days": "Active days", "longest_streak": "Longest streak (days)", "requests": "Requests made"}
for name, label in labels.items():
value = report["changes"][name]
row(label, value["current"], value["previous"], value["difference"], value["percent"])
row()
row("Date (UTC)", "Minutes watched")
for day in report["daily"]:
row(day["date"], day["minutes"])
row()
row("Most watched title", "Media type", "Minutes", "Plays")
for title in report["top_titles"]:
row(title["title"], title["type"], title["minutes"], title["plays"])
for field, label in (("clients", "Player"), ("methods", "Streaming method")):
row()
row(label, "Playback minutes")
for entry in report[field]:
row(entry["name"], entry["minutes"])
row()
row("Transcoding", "Playback minutes")
for field, label in (("hardware_video_minutes", "GPU-assisted video"), ("audio_minutes", "Audio transcoding"),
("video_minutes", "Video transcoding"), ("software_video_minutes", "Software video"),
("unknown_hardware_minutes", "Video hardware not recorded"),
("unknown_video_minutes", "Video details not recorded"), ("unknown_audio_minutes", "Audio details not recorded")):
row(label, report["transcoding"][field])
row("GPU busy time", "Not recorded; audio/video playback durations can overlap.")
row()
row("Requests", "Count")
for field, label in (("movies", "Movies"), ("tv", "TV shows"), ("pending", "Pending"), ("approved", "Approved"), ("declined", "Declined")):
row(label, report["requests"][field])
return "\ufeff" + output.getvalue()
+202
View File
@@ -0,0 +1,202 @@
"""Bounded Jellyfin arrival snapshots, recipient access checks and email-safe posters."""
import asyncio
import hashlib
import io
import time
from collections import OrderedDict
from datetime import datetime, timezone
import httpx
from PIL import Image
from .insights_artwork import item_id
from .jellyfin_identity import source_key
MAX_ITEMS = 5000
PAGE_SIZE = 200
MAX_TITLES = 60
_posters = OrderedDict()
_poster_lock = asyncio.Semaphore(4)
class CatalogError(Exception):
pass
def date(value) -> datetime | None:
try:
result = datetime.fromisoformat(str(value).replace('Z', '+00:00'))
return result.replace(tzinfo=timezone.utc) if result.tzinfo is None else result.astimezone(timezone.utc)
except (ValueError, TypeError):
return None
async def get_json(client, runtime, path, params=None):
try:
response = await client.get(runtime.jellyfin_base_url.rstrip('/') + path,
headers={'X-Emby-Token': runtime.jellyfin_api_key}, params=params)
response.raise_for_status()
return response.json()
except (httpx.HTTPError, ValueError) as exc:
raise CatalogError('Jellyfin is temporarily unavailable. Please try again.') from exc
def group_arrivals(items: list[dict], start: datetime, end: datetime) -> list[dict]:
groups = {}
seen = set()
for row in items:
identity = item_id(row.get('Id'))
added = date(row.get('DateCreated'))
if (not identity or identity in seen or not added or not start <= added < end
or row.get('LocationType') == 'Virtual' or row.get('IsPlaceHolder')):
continue
kind = row.get('Type')
if kind not in {'Movie', 'Episode'}:
continue
parent = item_id(row.get('SeriesId')) if kind == 'Episode' else identity
if not parent:
continue
seen.add(identity)
title = str((row.get('SeriesName') if kind == 'Episode' else row.get('Name')) or '').strip()
if not title:
continue
entry = groups.setdefault(parent, {'id': parent, 'type': 'series' if kind == 'Episode' else 'movie',
'title': title[:250], 'year': row.get('ProductionYear') if kind == 'Movie' else None,
'overview': str(row.get('Overview') or '')[:500] if kind == 'Movie' else '',
'added_at': added.isoformat(), 'has_artwork': False, 'items': [], 'selected': False, 'featured': False})
entry['added_at'] = max(entry['added_at'], added.isoformat())
entry['has_artwork'] |= bool(row.get('SeriesPrimaryImageTag') if kind == 'Episode' else (row.get('ImageTags') or {}).get('Primary'))
entry['items'].append({'id': identity, 'season': row.get('ParentIndexNumber') if kind == 'Episode' else None,
'number': row.get('IndexNumber') if kind == 'Episode' else None})
return sorted(groups.values(), key=lambda row: (row['added_at'], row['id']), reverse=True)
async def collect(runtime, start: datetime, end: datetime, limit: int = 12) -> dict:
if not runtime.jellyfin_base_url or not runtime.jellyfin_api_key:
raise CatalogError('Connect Jellyfin before collecting new arrivals.')
rows, seen = [], set()
exhausted = False
async with httpx.AsyncClient(timeout=20) as client:
info = await get_json(client, runtime, '/System/Info')
server_id = item_id(info.get('Id')) if isinstance(info, dict) else None
if not server_id:
raise CatalogError('Jellyfin did not return its server identity.')
for offset in range(0, MAX_ITEMS, PAGE_SIZE):
payload = await get_json(client, runtime, '/Items', {'Recursive': 'true', 'IncludeItemTypes': 'Movie,Episode',
'SortBy': 'DateCreated,SortName', 'SortOrder': 'Descending', 'Fields': 'DateCreated,Overview',
'EnableUserData': 'false', 'IsMissing': 'false', 'IsPlaceHolder': 'false', 'Limit': PAGE_SIZE, 'StartIndex': offset})
if not isinstance(payload, dict) or not isinstance(payload.get('Items'), list):
raise CatalogError('Jellyfin returned an incomplete arrival list.')
page = payload['Items']
total = payload.get('TotalRecordCount')
if not isinstance(total, int) or total < offset + len(page):
raise CatalogError('Jellyfin returned an incomplete arrival count.')
for row in page:
if not isinstance(row, dict) or not item_id(row.get('Id')) or not date(row.get('DateCreated')):
raise CatalogError('Jellyfin returned an arrival without a valid identity or added date.')
identity = item_id(row['Id'])
if identity in seen:
raise CatalogError('The library changed during collection. Refresh arrivals to try again.')
seen.add(identity)
if rows and date(row['DateCreated']) > date(rows[-1]['DateCreated']):
raise CatalogError('The library changed during collection. Refresh arrivals to try again.')
rows.append(row)
if (not page or len(page) < PAGE_SIZE) and offset + len(page) < total:
raise CatalogError('Jellyfin returned an incomplete arrival page.')
if not page or any(date(row['DateCreated']) < start for row in page) or offset + len(page) >= total:
exhausted = True
break
if not exhausted:
raise CatalogError('More than 5,000 recent items were found. Choose a shorter arrival period; no partial edition was created.')
titles = group_arrivals(rows, start, end)
total = len(titles)
titles = titles[:MAX_TITLES]
for index, title in enumerate(titles):
title['selected'] = index < limit
return {'source': source_key(runtime.jellyfin_base_url), 'server_id': server_id,
'period_start': start.isoformat(), 'period_end': end.isoformat(), 'total_titles': total, 'titles': titles}
async def for_recipient(runtime, content: dict, jellyfin_id: str) -> dict:
"""Scope every ID lookup to a view Jellyfin permits this user to browse.
Jellyfin 10.11's AddUserToQuery skips its default library filter when ItemIds
is present. UserId alone is insufficient; ParentId supplies the allowed scope.
"""
if not item_id(jellyfin_id):
raise CatalogError('The recipient does not have a valid Jellyfin identity.')
selected = [entry for entry in content['titles'] if entry['selected']]
ids = sorted({identity for entry in selected for identity in [entry['id'], *(item['id'] for item in entry['items'])]})
allowed = set()
async with httpx.AsyncClient(timeout=20) as client:
info = await get_json(client, runtime, '/System/Info')
if not isinstance(info, dict) or source_key(runtime.jellyfin_base_url) != content['source'] or item_id(info.get('Id')) != content['server_id']:
raise CatalogError('The Jellyfin server changed. Create a new edition for the current library.')
user = await get_json(client, runtime, '/Users/' + jellyfin_id)
if not isinstance(user, dict) or item_id(user.get('Id')) != item_id(jellyfin_id) or not isinstance(user.get('Policy'), dict):
raise CatalogError('Could not verify the recipients Jellyfin account.')
if user['Policy'].get('IsDisabled') or user['Policy'].get('EnableMediaPlayback') is False:
return {**content, 'titles': [], 'recipient_disabled': True}
views = await get_json(client, runtime, '/UserViews', {'UserId': jellyfin_id, 'IncludeHidden': 'true', 'IncludeExternalContent': 'false'})
if not isinstance(views, dict) or not isinstance(views.get('Items'), list) or len(views['Items']) > 32:
raise CatalogError('Could not check the recipients library access.')
for view in views['Items']:
parent = item_id(view.get('Id')) if isinstance(view, dict) else None
if not parent:
raise CatalogError('Jellyfin returned a library without a valid identity.')
for offset in range(0, len(ids), 100):
chunk = ids[offset:offset + 100]
payload = await get_json(client, runtime, '/Items', {'UserId': jellyfin_id, 'ParentId': parent, 'Ids': ','.join(chunk),
'Recursive': 'true', 'Limit': len(chunk), 'EnableUserData': 'false', 'EnableImages': 'false',
'IsMissing': 'false', 'IsPlaceHolder': 'false'})
if not isinstance(payload, dict) or not isinstance(payload.get('Items'), list):
raise CatalogError('Could not check the recipients library access.')
allowed.update(item_id(item.get('Id')) for item in payload['Items'] if isinstance(item, dict))
titles = []
for entry in selected:
accessible = [item for item in entry['items'] if item['id'] in allowed]
if entry['id'] in allowed and accessible:
titles.append({**entry, 'items': accessible})
return {**content, 'titles': titles}
async def poster(runtime, identity: str) -> bytes | None:
if not item_id(identity):
return None
key = (source_key(runtime.jellyfin_base_url), hashlib.sha256(runtime.jellyfin_api_key.encode()).hexdigest(), identity)
async with _poster_lock:
cached = _posters.get(key)
if cached and cached[0] > time.monotonic():
_posters.move_to_end(key)
return cached[1]
result = None
try:
async with httpx.AsyncClient(timeout=10) as client:
async with client.stream('GET', runtime.jellyfin_base_url.rstrip('/') + f'/Items/{identity}/Images/Primary',
headers={'X-Emby-Token': runtime.jellyfin_api_key}, params={'maxWidth': 160, 'maxHeight': 240, 'quality': 82, 'format': 'Jpg'}) as response:
response.raise_for_status()
data = bytearray()
async for chunk in response.aiter_bytes():
data.extend(chunk)
if len(data) > 512 * 1024:
raise ValueError('Poster too large')
with Image.open(io.BytesIO(data)) as image:
if image.width * image.height > 4_000_000:
raise ValueError('Poster dimensions too large')
image.thumbnail((160, 240))
target = io.BytesIO()
image.convert('RGB').save(target, format='JPEG', quality=82)
result = target.getvalue()
except (httpx.HTTPError, ValueError, OSError, Image.DecompressionBombError):
pass
_posters[key] = (time.monotonic() + (1800 if result else 60), result)
while len(_posters) > 128:
_posters.popitem(last=False)
return result
async def posters(runtime, content: dict) -> dict:
titles = [entry for entry in content['titles'] if entry['selected'] and entry['has_artwork']]
results = await asyncio.gather(*(poster(runtime, entry['id']) for entry in titles))
return {entry['id']: data for entry, data in zip(titles, results) if data}
+74
View File
@@ -0,0 +1,74 @@
import base64
import html
from urllib.parse import urlencode
from .recap_email import document
def description(entry):
if entry['type'] == 'movie':
return f"Movie · {entry['year']}" if entry.get('year') else 'Movie'
seasons = sorted({item['season'] for item in entry['items'] if isinstance(item.get('season'), int)})
count = len(entry['items'])
labels = ', '.join('Specials' if value == 0 else str(value) for value in seasons[:8])
suffix = f" · {'Season' if len(seasons) == 1 else 'Seasons'} {labels}" if labels else ''
return f"{count} new {'episode' if count == 1 else 'episodes'}{suffix}"
def render_confirmation(username, url):
intro = f"Hi {username}, confirm your email to receive new arrivals, featured picks and announcements from your media library."
return {'subject': 'Confirm your Magent newsletter subscription',
'body_text': f'{intro}\n\nConfirm newsletter subscription: {url}\n\nThis link expires in 24 hours. If you did not request this, ignore this email.',
'body_html': document(title='Your next watch starts here.', intro=intro,
content='<p style="color:#bdb6c3;font-size:14px;line-height:1.7">A weekly look at new movies and TV updates, with posters and links to watch.</p>',
action='Confirm newsletter subscription', url=url, kicker='NEW IN YOUR LIBRARY',
footer='This link expires in 24 hours. If you did not request this, ignore this email.')}
def render(content, images, public_url, playback_url, unsubscribe_url, *, preview=False, test=False):
esc = html.escape
titles = [entry for entry in content['titles'] if entry['selected']]
body, lines, attachments = [], [], []
intro = str(content.get('intro') or '').strip()
if intro:
body.append(f'<p style="font-size:15px;line-height:1.8;color:#e5e1e4;overflow-wrap:anywhere">{esc(intro).replace(chr(10), "<br>")}</p>')
lines += [intro, '']
sections = [('Featured picks', [entry for entry in titles if entry['featured']]),
('New movies', [entry for entry in titles if not entry['featured'] and entry['type'] == 'movie']),
('Fresh episodes', [entry for entry in titles if not entry['featured'] and entry['type'] == 'series'])]
for heading, entries in sections:
if not entries:
continue
body.append(f'<h2 style="font-size:20px;margin:28px 0 8px;color:#e5e1e4">{heading}</h2>')
lines += [heading, '']
for entry in entries:
watch = playback_url + '/web/index.html#!/details?' + urlencode({'id': entry['id'], 'serverId': content['server_id']})
image_data = images.get(entry['id'])
cid = f"newsletter-{entry['id']}@magent"
if image_data:
source = 'data:image/jpeg;base64,' + base64.b64encode(image_data).decode() if preview else 'cid:' + cid
poster = f'<img src="{source}" width="80" alt="{esc(entry["title"], quote=True)}" style="display:block;width:80px;height:auto;border-radius:7px;border:0">'
if not preview:
attachments.append({'cid': cid, 'data': image_data})
else:
poster = f'<div style="width:80px;height:112px;line-height:112px;background:#353039;color:#c7bdff;text-align:center;border-radius:7px;font-size:11px">{"TV" if entry["type"] == "series" else "MOVIE"}</div>'
details = description(entry)
overview = str(entry.get('overview') or '')[:180]
copy = f'<p style="margin:8px 0;font-size:12px;line-height:1.6;color:#bdb6c3">{esc(overview)}</p>' if overview and entry['featured'] else ''
body.append(f'''<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="table-layout:fixed;border-bottom:1px solid #363338"><tr>
<td width="92" valign="top" style="padding:18px 12px 18px 0">{poster}</td><td valign="top" style="padding:18px 0;overflow-wrap:anywhere">
<h3 style="margin:0 0 8px;font-size:16px;line-height:1.4;color:#eee8f2">{esc(entry['title'])}</h3><p style="font-size:12px;line-height:1.6;color:#a69fac;margin:0 0 10px">{esc(details)}</p>{copy}
<a href="{esc(watch, quote=True)}" style="display:inline-block;padding:8px 0;color:#c7bdff;text-decoration:none;font-size:13px;font-weight:bold">Watch on Jellyfin &#8599;</a></td></tr></table>''')
lines += [entry['title'], details, watch, '']
if not titles:
body.append('<p style="font-size:14px;line-height:1.7;color:#bdb6c3">Your next discovery is waiting in your media library.</p>')
period = f"{content['period_start'][:10]} to {content['period_end'][:10]} · UTC"
footer = f'You subscribed to the Magent newsletter.<br>Arrivals recorded by Jellyfin · {esc(period)}<br><a href="{esc(unsubscribe_url, quote=True)}" style="color:#c7bdff">Unsubscribe from newsletters</a> · <a href="{esc(public_url + "/profile#newsletters", quote=True)}" style="color:#c7bdff">Email preferences</a>'
subject = ('[Test] ' if test else '') + content['subject']
return {'subject': subject, 'body_text': '\n'.join([subject, '', *lines, f'Browse Jellyfin: {playback_url}', '',
f'Arrivals recorded by Jellyfin: {period}', f'Unsubscribe from newsletters: {unsubscribe_url}',
f'Email preferences: {public_url}/profile#newsletters']),
'body_html': document(title='Whats new in your library',
intro=('This is your test edition. ' if test else '') + 'New stories for your watchlist. Find your next movie or catch up on fresh episodes.',
content=''.join(body), action='Explore Jellyfin', url=playback_url, footer=footer, kicker='YOUR NEXT WATCH'),
'inline_images': attachments}
+351
View File
@@ -0,0 +1,351 @@
"""Independent newsletter consent and immutable edition snapshots using the shared email queue."""
import hashlib
import json
import secrets
import uuid
from contextlib import closing
from datetime import datetime, timedelta, timezone
from .. import db
from . import email_queue
from .recap_store import read_one, transaction
from .public_urls import magent_public_url
class Conflict(ValueError):
pass
def init_schema(conn):
for sql in (
"""CREATE TABLE IF NOT EXISTS newsletter_settings (
id INTEGER PRIMARY KEY CHECK(id=1), enabled INTEGER NOT NULL DEFAULT 0,
weekday INTEGER NOT NULL DEFAULT 4, hour INTEGER NOT NULL DEFAULT 9, limit_titles INTEGER NOT NULL DEFAULT 12,
public_url TEXT NOT NULL DEFAULT '', intro TEXT NOT NULL DEFAULT '', revision INTEGER NOT NULL DEFAULT 1,
next_send_at REAL, generation_claim TEXT, generation_until REAL, generation_attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT NOT NULL DEFAULT '')""",
"INSERT OR IGNORE INTO newsletter_settings (id, public_url) SELECT 1, public_url FROM email_recap_settings WHERE id=1",
"""CREATE TABLE IF NOT EXISTS newsletter_subscriptions (
user_id INTEGER PRIMARY KEY, state TEXT NOT NULL, email TEXT NOT NULL,
identity_source TEXT NOT NULL, identity_id TEXT NOT NULL, version TEXT NOT NULL,
confirmation_hash TEXT UNIQUE, confirmation_expires REAL, requested_at REAL NOT NULL,
confirmed_at REAL, unsubscribe_token TEXT NOT NULL UNIQUE)""",
"""CREATE TABLE IF NOT EXISTS newsletter_editions (
id TEXT PRIMARY KEY, subject TEXT NOT NULL, intro TEXT NOT NULL, content_json TEXT NOT NULL,
revision INTEGER NOT NULL DEFAULT 1, state TEXT NOT NULL DEFAULT 'draft', origin TEXT NOT NULL DEFAULT 'manual',
weekly_key TEXT UNIQUE, send_at REAL, created_at REAL NOT NULL, updated_at REAL NOT NULL, created_by TEXT NOT NULL)""",
"""CREATE TABLE IF NOT EXISTS newsletter_versions (
edition_id TEXT NOT NULL, revision INTEGER NOT NULL, content_json TEXT NOT NULL,
PRIMARY KEY (edition_id, revision))""",
"""CREATE TABLE IF NOT EXISTS newsletter_deliveries (
id TEXT PRIMARY KEY, dedupe_key TEXT NOT NULL UNIQUE, user_id INTEGER NOT NULL,
edition_id TEXT NOT NULL, edition_revision INTEGER NOT NULL, kind TEXT NOT NULL,
email TEXT NOT NULL, subscription_version TEXT NOT NULL, public_url TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'queued', attempts INTEGER NOT NULL DEFAULT 0,
created_at REAL NOT NULL, updated_at REAL NOT NULL, next_attempt_at REAL NOT NULL,
claim TEXT, lease_until REAL, detail TEXT NOT NULL DEFAULT '')""",
"CREATE INDEX IF NOT EXISTS idx_newsletter_queue ON newsletter_deliveries (state, next_attempt_at)",
"""CREATE TRIGGER IF NOT EXISTS newsletter_account_changed AFTER UPDATE OF email, is_blocked ON users
WHEN LOWER(TRIM(COALESCE(NEW.email,''))) != LOWER(TRIM(COALESCE(OLD.email,''))) OR NEW.is_blocked=1
BEGIN UPDATE newsletter_subscriptions SET state='off', confirmation_hash=NULL, confirmed_at=NULL WHERE user_id=NEW.id; END""",
"""CREATE TRIGGER IF NOT EXISTS newsletter_account_deleted AFTER DELETE ON users
BEGIN DELETE FROM newsletter_subscriptions WHERE user_id=OLD.id;
UPDATE newsletter_deliveries SET state='cancelled', detail='Account removed.'
WHERE user_id=OLD.id AND state IN ('queued','retry','preparing'); END""",
"""CREATE TRIGGER IF NOT EXISTS newsletter_identity_changed AFTER UPDATE ON jellyfin_user_links
WHEN NEW.jellyfin_user_id != OLD.jellyfin_user_id OR NEW.source != OLD.source OR NEW.local_user_id != OLD.local_user_id
BEGIN UPDATE newsletter_subscriptions SET state='off', confirmation_hash=NULL, confirmed_at=NULL WHERE user_id=OLD.local_user_id; END""",
"""CREATE TRIGGER IF NOT EXISTS newsletter_identity_deleted AFTER DELETE ON jellyfin_user_links
BEGIN UPDATE newsletter_subscriptions SET state='off', confirmation_hash=NULL, confirmed_at=NULL WHERE user_id=OLD.local_user_id; END""",
):
conn.execute(sql)
def settings() -> dict:
result = read_one('SELECT * FROM newsletter_settings WHERE id=1')
result['public_url'] = magent_public_url(result['public_url'])
result['enabled'] = bool(result['enabled'])
return result
def public_settings() -> dict:
return {key: value for key, value in settings().items() if key in
{'enabled', 'weekday', 'hour', 'limit_titles', 'public_url', 'intro', 'revision', 'next_send_at', 'last_error'}}
def next_due(now: datetime, weekday: int, hour: int) -> datetime:
now = now.astimezone(timezone.utc)
due = now.replace(hour=hour, minute=0, second=0, microsecond=0) + timedelta(days=(weekday - now.weekday()) % 7)
return due if due > now else due + timedelta(days=7)
def save_settings(values: dict, now: datetime):
values = {**values, "public_url": magent_public_url(values.get("public_url", ""))}
with transaction() as conn:
old = dict(conn.execute('SELECT * FROM newsletter_settings WHERE id=1').fetchone())
if old['revision'] != values['revision']:
raise Conflict('The newsletter settings changed. Refresh before saving.')
due = next_due(now, values['weekday'], values['hour']).timestamp() if values['enabled'] else None
conn.execute("""UPDATE newsletter_settings SET enabled=?, weekday=?, hour=?, limit_titles=?, public_url=?, intro=?,
revision=revision+1, next_send_at=?, generation_claim=NULL, generation_until=NULL, generation_attempts=0, last_error='' WHERE id=1""",
(values['enabled'], values['weekday'], values['hour'], values['limit_titles'], values['public_url'], values['intro'], due))
if not values['enabled'] or any(old[key] != values[key] for key in ('weekday', 'hour', 'public_url')):
conn.execute("UPDATE newsletter_editions SET state='cancelled', updated_at=? WHERE origin='weekly' AND state IN ('scheduled','queued')", (now.timestamp(),))
conn.execute("""UPDATE newsletter_deliveries SET state='cancelled', detail='Weekly schedule paused or changed.'
WHERE state IN ('queued','retry','preparing') AND kind='edition'
AND edition_id IN (SELECT id FROM newsletter_editions WHERE state='cancelled')""")
return public_settings()
def subscription(user_id):
return read_one('SELECT * FROM newsletter_subscriptions WHERE user_id=?', (user_id,))
def disable(user_id):
with transaction() as conn:
conn.execute("UPDATE newsletter_subscriptions SET state='off', confirmation_hash=NULL, confirmed_at=NULL WHERE user_id=?", (user_id,))
conn.execute("UPDATE newsletter_deliveries SET state='cancelled', detail='Newsletter subscription turned off.' WHERE user_id=? AND state IN ('queued','retry','preparing')", (user_id,))
def request_confirmation(user, source, identity, now):
token = secrets.token_urlsafe(32)
with transaction() as conn:
old = conn.execute('SELECT requested_at FROM newsletter_subscriptions WHERE user_id=?', (user['id'],)).fetchone()
if old and old[0] > now - 300:
raise Conflict('Please wait five minutes before requesting another confirmation.')
conn.execute("""INSERT INTO newsletter_subscriptions (user_id,state,email,identity_source,identity_id,version,
confirmation_hash,confirmation_expires,requested_at,unsubscribe_token) VALUES (?,'pending',?,?,?,?,?,?,?,?)
ON CONFLICT(user_id) DO UPDATE SET state='pending',email=excluded.email,identity_source=excluded.identity_source,
identity_id=excluded.identity_id,version=excluded.version,confirmation_hash=excluded.confirmation_hash,
confirmation_expires=excluded.confirmation_expires,requested_at=excluded.requested_at,confirmed_at=NULL,
unsubscribe_token=excluded.unsubscribe_token""",
(user['id'], user['email'].strip(), source, identity, uuid.uuid4().hex,
hashlib.sha256(token.encode()).hexdigest(), now + 86400, now, secrets.token_urlsafe(32)))
return token
def token_subscription(token, action):
if action == 'confirm':
return read_one('SELECT * FROM newsletter_subscriptions WHERE confirmation_hash=?', (hashlib.sha256(token.encode()).hexdigest(),))
return read_one('SELECT * FROM newsletter_subscriptions WHERE unsubscribe_token=?', (token,))
def confirm(sub, now):
with transaction() as conn:
result = conn.execute("""UPDATE newsletter_subscriptions SET state='enabled',confirmed_at=?,confirmation_hash=NULL
WHERE user_id=? AND version=? AND state='pending' AND confirmation_expires>?
AND EXISTS (SELECT 1 FROM users u JOIN jellyfin_user_links j ON j.local_user_id=u.id
WHERE u.id=newsletter_subscriptions.user_id AND u.is_blocked=0
AND LOWER(TRIM(u.email))=LOWER(TRIM(newsletter_subscriptions.email))
AND j.source=identity_source AND j.jellyfin_user_id=identity_id)""", (now, sub['user_id'], sub['version'], now))
return result.rowcount == 1
def unpack(row):
if row is None:
return None
result = dict(row)
result['content'] = json.loads(result.pop('content_json'))
return result
def edition(identity):
return unpack(read_one('SELECT * FROM newsletter_editions WHERE id=?', (identity,)))
def create_edition(content, subject, intro, creator, now):
identity = uuid.uuid4().hex
with transaction() as conn:
conn.execute('''INSERT INTO newsletter_editions (id,subject,intro,content_json,created_at,updated_at,created_by)
VALUES (?,?,?,?,?,?,?)''', (identity, subject, intro, json.dumps(content), now, now, creator))
return edition(identity)
def editable(conn, identity, revision):
row = conn.execute('SELECT * FROM newsletter_editions WHERE id=?', (identity,)).fetchone()
if not row or row['revision'] != revision:
raise Conflict('This edition changed. Reload it before continuing.')
if row['state'] != 'draft':
raise Conflict('This edition is already scheduled or finished. Create a new draft to make changes.')
return unpack(row)
def update_edition(identity, revision, subject, intro, selections, now):
with transaction() as conn:
old = editable(conn, identity, revision)
titles = old['content']['titles']
selected = {entry['id']: entry for entry in selections}
if len(selected) != len(selections) or set(selected) != {entry['id'] for entry in titles}:
raise Conflict('The title selection does not match this draft. Reload the edition.')
if sum(bool(entry['selected']) for entry in selections) > 24 or sum(bool(entry['featured']) for entry in selections) > 3:
raise Conflict('Choose up to 24 titles and three featured picks.')
if any(entry['featured'] and not entry['selected'] for entry in selections):
raise Conflict('Featured picks must be included in the edition.')
for entry in titles:
entry.update(selected=selected[entry['id']]['selected'], featured=selected[entry['id']]['featured'])
conn.execute('UPDATE newsletter_editions SET subject=?,intro=?,content_json=?,revision=revision+1,updated_at=? WHERE id=?',
(subject, intro, json.dumps(old['content']), now, identity))
return edition(identity)
def snapshot(conn, row):
data = {**row['content'], 'subject': row['subject'], 'intro': row['intro']}
# Store only included titles; retries of a test retain the exact saved version.
data['titles'] = [entry for entry in data['titles'] if entry['selected']]
conn.execute('INSERT OR IGNORE INTO newsletter_versions (edition_id,revision,content_json) VALUES (?,?,?)',
(row['id'], row['revision'], json.dumps(data)))
def version(delivery):
row = read_one('SELECT content_json FROM newsletter_versions WHERE edition_id=? AND revision=?', (delivery['edition_id'], delivery['edition_revision']))
return json.loads(row['content_json']) if row else None
def publish(identity, revision, send_at, now):
with transaction() as conn:
previous = conn.execute('SELECT revision,state FROM newsletter_editions WHERE id=?', (identity,)).fetchone()
if previous and previous['revision'] == revision and previous['state'] in {'scheduled', 'queued', 'complete'}:
return edition(identity)
row = editable(conn, identity, revision)
if not any(entry['selected'] for entry in row['content']['titles']) and not row['intro'].strip():
raise Conflict('Add an announcement or select a title before sending.')
snapshot(conn, row)
conn.execute("UPDATE newsletter_editions SET state='scheduled',send_at=?,updated_at=? WHERE id=?", (send_at, now, identity))
return edition(identity)
def cancel(identity, now):
with transaction() as conn:
conn.execute("UPDATE newsletter_editions SET state='cancelled',updated_at=? WHERE id=? AND state IN ('draft','scheduled','queued')", (now, identity))
conn.execute("UPDATE newsletter_deliveries SET state='cancelled',detail='Edition cancelled.',updated_at=? WHERE edition_id=? AND state IN ('queued','retry','preparing')", (now, identity))
return edition(identity)
def _enqueue(conn, sub, row, kind, key, public_url, now):
identity = uuid.uuid4().hex
conn.execute('''INSERT OR IGNORE INTO newsletter_deliveries (id,dedupe_key,user_id,edition_id,edition_revision,kind,email,
subscription_version,public_url,created_at,updated_at,next_attempt_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)''',
(identity, key, sub['user_id'], row['id'], row['revision'], kind, sub['email'], sub['version'], public_url, now, now, now))
return conn.execute('SELECT id FROM newsletter_deliveries WHERE dedupe_key=?', (key,)).fetchone()[0]
def enqueue_test(sub, identity, revision, request_id, public_url, now):
key = f"test:{sub['user_id']}:{request_id}"
with transaction() as conn:
previous = conn.execute('SELECT id,edition_id,edition_revision FROM newsletter_deliveries WHERE dedupe_key=?', (key,)).fetchone()
if previous:
if previous['edition_id'] != identity or previous['edition_revision'] != revision:
raise Conflict('This test request was already used for another saved version.')
return previous['id']
row = unpack(conn.execute('SELECT * FROM newsletter_editions WHERE id=? AND revision=?', (identity, revision)).fetchone())
if not row or row['state'] == 'cancelled':
raise Conflict('This edition changed or was cancelled. Reload it first.')
if conn.execute("SELECT 1 FROM newsletter_deliveries WHERE user_id=? AND kind='test' AND created_at>?", (sub['user_id'], now-300)).fetchone():
raise Conflict('Please wait five minutes between newsletter test emails.')
snapshot(conn, row)
return _enqueue(conn, sub, row, 'test', key, public_url, now)
def enqueue_due(now):
with transaction() as conn:
config = dict(conn.execute('SELECT * FROM newsletter_settings WHERE id=1').fetchone())
config['public_url'] = magent_public_url(config['public_url'])
rows = conn.execute("SELECT * FROM newsletter_editions WHERE state='scheduled' AND send_at<=?", (now,)).fetchall()
for raw in rows:
row = unpack(raw)
subs = conn.execute("SELECT * FROM newsletter_subscriptions WHERE state='enabled' AND confirmed_at<=?", (row['send_at'],)).fetchall()
for sub in subs:
_enqueue(conn, sub, row, 'edition', f"edition:{row['id']}:{sub['user_id']}", config['public_url'], now)
conn.execute("UPDATE newsletter_editions SET state=?,updated_at=? WHERE id=?", ('queued' if subs else 'complete', now, row['id']))
def claim_delivery(now):
with transaction() as conn:
return email_queue.claim(conn, 'newsletter_deliveries', now)
def begin_sending(delivery, now):
with transaction() as conn:
result = conn.execute("""UPDATE newsletter_deliveries SET state='sending',updated_at=?,lease_until=?
WHERE id=? AND claim=? AND state='preparing'
AND EXISTS (SELECT 1 FROM newsletter_subscriptions s JOIN users u ON u.id=s.user_id
JOIN jellyfin_user_links j ON j.local_user_id=u.id AND j.source=s.identity_source
WHERE s.user_id=newsletter_deliveries.user_id AND s.state='enabled'
AND s.version=newsletter_deliveries.subscription_version AND u.is_blocked=0
AND LOWER(TRIM(u.email))=LOWER(TRIM(s.email)) AND j.jellyfin_user_id=s.identity_id)
AND EXISTS (SELECT 1 FROM newsletter_settings WHERE id=1 AND public_url=newsletter_deliveries.public_url)
AND EXISTS (SELECT 1 FROM newsletter_editions e WHERE e.id=newsletter_deliveries.edition_id AND e.state!='cancelled')""",
(now, now+1800, delivery['id'], delivery['claim']))
return result.rowcount == 1
def finish(delivery, state, detail, now, delay=0):
with transaction() as conn:
email_queue.finish(conn, 'newsletter_deliveries', delivery, state, detail, now, delay)
def finish_editions(now):
with transaction() as conn:
conn.execute("""UPDATE newsletter_editions SET state='complete',updated_at=? WHERE state='queued'
AND NOT EXISTS (SELECT 1 FROM newsletter_deliveries d WHERE d.edition_id=newsletter_editions.id
AND d.kind='edition' AND d.state IN ('queued','preparing','sending','retry'))""", (now,))
def claim_weekly(now: datetime):
with transaction() as conn:
config = dict(conn.execute('SELECT * FROM newsletter_settings WHERE id=1').fetchone())
stamp = now.timestamp()
if not config['enabled'] or not config['next_send_at'] or config['next_send_at'] > stamp or (config['generation_until'] or 0) > stamp:
return None
claim = uuid.uuid4().hex
conn.execute('UPDATE newsletter_settings SET generation_claim=?,generation_until=?,generation_attempts=generation_attempts+1 WHERE id=1', (claim, stamp+600))
due = next_due(now, config['weekday'], config['hour']) - timedelta(days=7)
return {**config, 'generation_claim': claim, 'due': due, 'generation_attempts': config['generation_attempts']+1}
def complete_weekly(config, content, now: datetime, failure=''):
with transaction() as conn:
current = conn.execute('SELECT * FROM newsletter_settings WHERE id=1').fetchone()
if not current['enabled'] or current['revision'] != config['revision'] or current['generation_claim'] != config['generation_claim']:
return
if failure:
retry = config['generation_attempts'] < 3
conn.execute('''UPDATE newsletter_settings SET generation_claim=NULL,generation_until=?,last_error=?,next_send_at=?,
generation_attempts=? WHERE id=1''', (now.timestamp()+300 if retry else None, failure,
current['next_send_at'] if retry else next_due(now, config['weekday'], config['hour']).timestamp(),
config['generation_attempts'] if retry else 0))
return
identity = uuid.uuid4().hex
due = config['due']
empty = not content['titles']
conn.execute('''INSERT OR IGNORE INTO newsletter_editions
(id,subject,intro,content_json,state,origin,weekly_key,send_at,created_at,updated_at,created_by)
VALUES (?,?,?,?,?,'weekly',?,?,?,?,?)''',
(identity, f"Whats new in your library · {due.strftime('%d %b %Y')}", config['intro'], json.dumps(content),
'skipped' if empty else 'scheduled', due.isoformat(), due.timestamp(), now.timestamp(), now.timestamp(), 'Weekly schedule'))
row = unpack(conn.execute('SELECT * FROM newsletter_editions WHERE weekly_key=?', (due.isoformat(),)).fetchone())
if not empty:
snapshot(conn, row)
conn.execute('''UPDATE newsletter_settings SET next_send_at=?,generation_claim=NULL,generation_until=NULL,
generation_attempts=0,last_error=? WHERE id=1''',
(next_due(now, config['weekday'], config['hour']).timestamp(), 'No new arrivals for the weekly edition; no email was queued.' if empty else ''))
def overview(offset=0):
with closing(db._connect()) as conn:
import sqlite3
conn.row_factory = sqlite3.Row
rows = conn.execute('SELECT * FROM newsletter_editions ORDER BY created_at DESC,id LIMIT 30').fetchall()
editions = []
for raw in rows:
row = unpack(raw)
content = row.pop('content')
row.update(period_start=content['period_start'], period_end=content['period_end'], titles=sum(entry['selected'] for entry in content['titles']))
editions.append(row)
deliveries = conn.execute('''SELECT d.id,d.edition_id,e.subject,d.kind,d.email,d.state,d.attempts,d.updated_at,d.next_attempt_at,
d.detail,u.username FROM newsletter_deliveries d LEFT JOIN users u ON u.id=d.user_id
LEFT JOIN newsletter_editions e ON e.id=d.edition_id ORDER BY d.created_at DESC,d.id LIMIT 50 OFFSET ?''', (offset,)).fetchall()
subscribers = conn.execute("SELECT COUNT(*) FROM newsletter_subscriptions WHERE state='enabled'").fetchone()[0]
total = conn.execute('SELECT COUNT(*) FROM newsletter_deliveries').fetchone()[0]
return {'editions': editions, 'deliveries': [dict(row) for row in deliveries], 'subscribers': subscribers, 'total': total}
+271
View File
@@ -0,0 +1,271 @@
"""Weekly new-arrival newsletters, manual editions and separate opt-in delivery."""
import asyncio
import logging
import time
import uuid
from datetime import datetime, timedelta, timezone
from urllib.parse import urlencode, urlsplit
from .. import db
from ..runtime import get_runtime_settings
from . import email_recaps, newsletter_catalog as catalog, newsletter_email as template, newsletter_store as store
from . import recap_email as mail, recap_store
from .invite_email import smtp_email_config_ready
from .jellyfin_identity import linked_user_id, source_key
logger = logging.getLogger(__name__)
NewsletterError = email_recaps.RecapError
def playback_url(runtime) -> str:
value = str(runtime.jellyfin_public_url or '').strip().rstrip('/')
try:
parsed = urlsplit(value)
if parsed.scheme in {'https', 'http'} and parsed.hostname and not (parsed.username or parsed.password or parsed.query or parsed.fragment) and not any(c.isspace() or c in '<>"\\' for c in value):
return value
except ValueError:
pass
return ''
def delivery_ready(public_url=None):
config = store.settings()
if not (public_url if public_url is not None else config['public_url']):
return False, 'Set the application URL in Hosting & proxy for newsletter email links.'
runtime = get_runtime_settings()
if not runtime.jellyfin_base_url or not runtime.jellyfin_api_key:
return False, 'Connect Jellyfin to collect new arrivals.'
if not playback_url(runtime):
return False, 'Set the public Jellyfin address in Jellyfin settings for Watch links.'
ready, detail = smtp_email_config_ready()
if not ready:
return ready, detail
if not email_recaps.worker_enabled():
return False, 'Background automation is paused on this server.'
return True, 'Newsletter delivery is configured.'
def account_for(user):
account = db.get_user_by_username(user.get('username', ''))
if not account or account.get('is_blocked') or account.get('is_expired'):
raise NewsletterError('This account cannot receive newsletters.', 403)
return account
def active_subscription(account):
sub = store.subscription(account['id'])
if sub and sub['state'] != 'off' and not email_recaps.binding_matches(sub, account):
store.disable(account['id'])
sub = store.subscription(account['id'])
return sub
def preferences(user):
account = account_for(user)
sub = active_subscription(account)
runtime = get_runtime_settings()
ready, detail = delivery_ready()
linked = bool(linked_user_id(account['username'], runtime.jellyfin_base_url))
email = mail.valid_email(account.get('email'))
config = store.settings()
state = sub['state'] if sub else 'off'
if state == 'pending' and sub['confirmation_expires'] <= time.time():
state = 'expired'
return {'state': state, 'email': account.get('email'), 'can_subscribe': ready and linked and bool(email),
'detail': detail if not ready else 'Save a valid profile email address.' if not email else
'Link your Jellyfin account so newsletter titles match your library access.' if not linked else 'New arrivals and featured picks, in your inbox.',
'schedule_enabled': config['enabled'], 'next_send_at': config['next_send_at'], 'weekday': config['weekday'], 'hour': config['hour'],
'resend_after': sub['requested_at'] + 300 if sub else None}
async def subscribe(user):
account = account_for(user)
preference = preferences(user)
if preference['state'] == 'enabled':
return preference
if not preference['can_subscribe']:
raise NewsletterError(preference['detail'])
runtime = get_runtime_settings()
try:
token = store.request_confirmation(account, source_key(runtime.jellyfin_base_url),
linked_user_id(account['username'], runtime.jellyfin_base_url), time.time())
except store.Conflict as exc:
raise NewsletterError(str(exc), 429) from exc
# The click supplies separate newsletter consent. Reuse a still-valid confirmed address if available.
recap = recap_store.subscription(account['id'])
if recap and recap['state'] == 'enabled' and email_recaps.binding_matches(recap, account):
if store.confirm(store.subscription(account['id']), time.time()):
return {**preferences(user), 'message': 'Newsletter subscription is on, using your confirmed profile email.'}
config = store.settings()
url = config['public_url'] + '/newsletter-subscription#' + urlencode({'action': 'confirm', 'token': token})
try:
await asyncio.to_thread(mail.send_email, account['email'].strip(), template.render_confirmation(account['username'], url),
mail.message_id(uuid.uuid4().hex, config['public_url']))
except mail.DeliveryError as exc:
raise NewsletterError('Could not confirm delivery of the verification email. Check your inbox; another can be requested in five minutes.', 502) from exc
return {**preferences(user), 'message': 'Check your inbox and confirm within 24 hours to turn on newsletters.'}
def token_action(token, action, apply=False):
sub = store.token_subscription(token, action)
if not sub:
raise NewsletterError('This newsletter link is invalid or has already been used. Open Profile to manage your subscription.', 410)
if action == 'unsubscribe':
if apply:
store.disable(sub['user_id'])
return {'action': action, 'state': 'off' if apply or sub['state'] == 'off' else 'ready'}
account = db.get_user_by_id(sub['user_id'])
if sub['state'] != 'pending' or sub['confirmation_expires'] <= time.time() or not email_recaps.binding_matches(sub, account):
raise NewsletterError('This confirmation expired or your account changed. Request a new newsletter link in Profile.', 410)
if apply and not store.confirm(sub, time.time()):
raise NewsletterError('This confirmation is no longer available. Request a new newsletter link in Profile.', 410)
return {'action': action, 'state': 'enabled' if apply else 'ready'}
async def collect(start, end, limit):
runtime = get_runtime_settings()
result = await asyncio.wait_for(catalog.collect(runtime, start, end, limit), timeout=180)
return {**result, 'playback_url': playback_url(runtime)}
async def create_draft(user, days):
end = datetime.now(timezone.utc)
config = store.settings()
content = await collect(end - timedelta(days=days), end, config['limit_titles'])
return store.create_edition(content, f"Whats new in your library · {end.strftime('%d %b %Y')}", config['intro'], user['username'], end.timestamp())
def require_edition(identity, revision=None):
row = store.edition(identity)
if not row:
raise NewsletterError('Newsletter edition not found.', 404)
if revision is not None and row['revision'] != revision:
raise NewsletterError('This edition changed. Reload it before continuing.')
return row
async def preview(identity, revision):
row = require_edition(identity, revision)
runtime = get_runtime_settings()
config = store.settings()
if not config['public_url'] or not playback_url(runtime):
raise NewsletterError('Check the application URL in Hosting & proxy and the public playback URL in Jellyfin settings before previewing.')
if row['content']['source'] != source_key(runtime.jellyfin_base_url) or row['content']['playback_url'] != playback_url(runtime):
raise NewsletterError('The Jellyfin connection or public address changed. Create a fresh draft.')
content = {**row['content'], 'subject': row['subject'], 'intro': row['intro']}
images = await asyncio.wait_for(catalog.posters(runtime, content), timeout=90)
rendered = template.render(content, images, config['public_url'], content['playback_url'], config['public_url'] + '/profile#newsletters', preview=True)
rendered.pop('inline_images')
return {'id': row['id'], 'revision': row['revision'], **rendered}
def queue_test(user, identity, revision, request_id):
ready, detail = delivery_ready()
if not ready:
raise NewsletterError(detail)
account = account_for(user)
sub = active_subscription(account)
if not sub or sub['state'] != 'enabled':
raise NewsletterError('Subscribe to newsletters and confirm your email in Profile before sending yourself a test.')
delivery_id = store.enqueue_test(sub, identity, revision, request_id, store.settings()['public_url'], time.time())
return {'id': delivery_id, 'message': 'Test queued for your confirmed newsletter email. Delivery history will show the result.'}
def publish(identity, revision, send_at):
ready, detail = delivery_ready()
if not ready:
raise NewsletterError(detail)
row = require_edition(identity, revision)
runtime = get_runtime_settings()
if row['content']['source'] != source_key(runtime.jellyfin_base_url) or row['content']['playback_url'] != playback_url(runtime):
raise NewsletterError('The Jellyfin connection changed. Create a fresh draft before sending.')
now = datetime.now(timezone.utc)
when = now if send_at is None else send_at
if when.tzinfo is None:
raise NewsletterError('Choose a send time with an explicit timezone.', 422)
when = when.astimezone(timezone.utc)
if send_at is not None and not now + timedelta(seconds=30) <= when <= now + timedelta(days=90):
raise NewsletterError('Schedule the edition at least 30 seconds ahead and within the next 90 days.', 422)
return store.publish(identity, revision, when.timestamp(), now.timestamp())
def eligible(delivery):
account = db.get_user_by_id(delivery['user_id'])
sub = active_subscription(account) if account else None
ready, _ = delivery_ready()
if not ready or not sub or sub['state'] != 'enabled' or sub['version'] != delivery['subscription_version'] or sub['email'] != delivery['email'] or not email_recaps.binding_matches(sub, account) or store.settings()['public_url'] != delivery['public_url']:
raise mail.DeliveryCancelled()
row = store.edition(delivery['edition_id'])
if not row or row['state'] == 'cancelled':
raise mail.DeliveryCancelled()
return account, sub
async def process_delivery(delivery):
state, detail, delay = 'failed', 'Could not prepare this newsletter.', 0
try:
_, sub = eligible(delivery)
content = store.version(delivery)
runtime = get_runtime_settings()
if not content or content['playback_url'] != playback_url(runtime) or content['source'] != source_key(runtime.jellyfin_base_url):
raise mail.DeliveryCancelled()
content = await asyncio.wait_for(catalog.for_recipient(runtime, content, sub['identity_id']), timeout=120)
if content.get('recipient_disabled') or (not content['titles'] and not content['intro'].strip()):
state, detail = 'skipped', 'No selected titles are available to this account.'
else:
images = await asyncio.wait_for(catalog.posters(runtime, content), timeout=90)
unsubscribe = delivery['public_url'] + '/newsletter-subscription#' + urlencode({'action': 'unsubscribe', 'token': sub['unsubscribe_token']})
rendered = template.render(content, images, delivery['public_url'], content['playback_url'], unsubscribe, test=delivery['kind'] == 'test')
def before_data():
eligible(delivery)
if not store.begin_sending(delivery, time.time()):
raise mail.DeliveryCancelled()
await asyncio.to_thread(mail.send_email, delivery['email'], rendered, mail.message_id(delivery['id'], delivery['public_url']), before_data)
state, detail = 'sent', 'Accepted by the mail server.'
except mail.DeliveryCancelled:
state, detail = 'cancelled', 'Subscription, account, edition or email settings changed.'
except (catalog.CatalogError, TimeoutError):
state, detail = 'retry', 'Jellyfin content or library access could not be checked.'
except mail.DeliveryError as exc:
state, detail = exc.state, exc.detail
except Exception as exc:
logger.error('newsletter delivery error id=%s type=%s', delivery['id'], type(exc).__name__)
current = store.read_one('SELECT state FROM newsletter_deliveries WHERE id=?', (delivery['id'],))
if current and current['state'] == 'sending':
state, detail = 'unknown', 'Delivery outcome is unknown; check the mail server.'
if state == 'retry':
if delivery['attempts'] >= 3:
state, detail = 'failed', detail + ' Stopped after three attempts.'
else:
delay = 300 if delivery['attempts'] == 1 else 1800
store.finish(delivery, state, detail, time.time(), delay)
async def run_once():
if delivery_ready()[0]:
config = store.claim_weekly(datetime.now(timezone.utc))
if config:
try:
content = await collect(config['due'] - timedelta(days=7), config['due'], config['limit_titles'])
store.complete_weekly(config, content, datetime.now(timezone.utc))
except (catalog.CatalogError, TimeoutError):
store.complete_weekly(config, None, datetime.now(timezone.utc), 'Could not collect a complete weekly edition from Jellyfin. No newsletter was queued.')
store.enqueue_due(time.time())
for _ in range(10):
delivery = store.claim_delivery(time.time())
if not delivery:
break
await process_delivery(delivery)
store.finish_editions(time.time())
async def run_newsletter_loop():
while True:
try:
await run_once()
except Exception as exc:
logger.error('newsletter worker failed type=%s', type(exc).__name__)
await asyncio.sleep(30)
+280
View File
@@ -0,0 +1,280 @@
from __future__ import annotations
import logging
from typing import Any, Dict, Optional
from urllib.parse import quote
import httpx
from ..config import settings as env_settings
from ..db import get_setting
from ..network_security import validate_notification_target_url
from ..runtime import get_runtime_settings
from .invite_email import send_generic_email
logger = logging.getLogger(__name__)
def _clean_text(value: Any, fallback: str = "") -> str:
if value is None:
return fallback
if isinstance(value, str):
trimmed = value.strip()
return trimmed if trimmed else fallback
return str(value)
def _split_emails(value: str) -> list[str]:
if not value:
return []
parts = [entry.strip() for entry in value.replace(";", ",").split(",")]
return [entry for entry in parts if entry and "@" in entry]
def _resolve_app_url() -> str:
runtime = get_runtime_settings()
for candidate in (
runtime.magent_application_url,
runtime.magent_proxy_base_url,
env_settings.cors_allow_origin,
):
normalized = _clean_text(candidate)
if normalized:
return normalized.rstrip("/")
port = int(getattr(runtime, "magent_application_port", 3000) or 3000)
return f"http://localhost:{port}"
def _portal_item_url(item_id: int) -> str:
return f"{_resolve_app_url()}/portal?item={item_id}"
async def _http_post_json(url: str, payload: Dict[str, Any]) -> Dict[str, Any]:
validate_notification_target_url(url)
async with httpx.AsyncClient(timeout=12.0) as client:
response = await client.post(url, json=payload)
response.raise_for_status()
try:
body = response.json()
except ValueError:
body = response.text
return {"status_code": response.status_code, "body": body}
async def _send_discord(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
runtime = get_runtime_settings()
webhook = _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(
runtime.discord_webhook_url
)
if not webhook:
return {"status": "skipped", "detail": "Discord webhook not configured."}
data = {
"content": f"**{title}**\n{message}",
"embeds": [
{
"title": title,
"description": message,
"fields": [
{"name": "Type", "value": _clean_text(payload.get("kind"), "unknown"), "inline": True},
{"name": "Status", "value": _clean_text(payload.get("status"), "unknown"), "inline": True},
{"name": "Priority", "value": _clean_text(payload.get("priority"), "normal"), "inline": True},
],
"url": _clean_text(payload.get("item_url")),
}
],
}
result = await _http_post_json(webhook, data)
return {"status": "ok", "detail": f"Discord accepted ({result['status_code']})."}
async def _send_telegram(title: str, message: str) -> Dict[str, Any]:
runtime = get_runtime_settings()
bot_token = _clean_text(runtime.magent_notify_telegram_bot_token)
chat_id = _clean_text(runtime.magent_notify_telegram_chat_id)
if not bot_token or not chat_id:
return {"status": "skipped", "detail": "Telegram is not configured."}
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
payload = {"chat_id": chat_id, "text": f"{title}\n\n{message}", "disable_web_page_preview": True}
result = await _http_post_json(url, payload)
return {"status": "ok", "detail": f"Telegram accepted ({result['status_code']})."}
async def _send_webhook(payload: Dict[str, Any]) -> Dict[str, Any]:
runtime = get_runtime_settings()
webhook = _clean_text(runtime.magent_notify_webhook_url)
if not webhook:
return {"status": "skipped", "detail": "Generic webhook is not configured."}
result = await _http_post_json(webhook, payload)
return {"status": "ok", "detail": f"Webhook accepted ({result['status_code']})."}
async def _send_push(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
runtime = get_runtime_settings()
provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
base_url = _clean_text(runtime.magent_notify_push_base_url)
token = _clean_text(runtime.magent_notify_push_token)
topic = _clean_text(runtime.magent_notify_push_topic)
if provider == "ntfy":
if not base_url or not topic:
return {"status": "skipped", "detail": "ntfy needs base URL and topic."}
validate_notification_target_url(base_url)
url = f"{base_url.rstrip('/')}/{quote(topic)}"
headers = {"Title": title, "Tags": "magent,portal"}
async with httpx.AsyncClient(timeout=12.0) as client:
response = await client.post(url, content=message.encode("utf-8"), headers=headers)
response.raise_for_status()
return {"status": "ok", "detail": f"ntfy accepted ({response.status_code})."}
if provider == "gotify":
if not base_url or not token:
return {"status": "skipped", "detail": "Gotify needs base URL and token."}
validate_notification_target_url(base_url)
url = f"{base_url.rstrip('/')}/message?token={quote(token)}"
body = {"title": title, "message": message, "priority": 5, "extras": {"client::display": {"contentType": "text/plain"}}}
result = await _http_post_json(url, body)
return {"status": "ok", "detail": f"Gotify accepted ({result['status_code']})."}
if provider == "pushover":
user_key = _clean_text(runtime.magent_notify_push_user_key)
if not token or not user_key:
return {"status": "skipped", "detail": "Pushover needs token and user key."}
form = {"token": token, "user": user_key, "title": title, "message": message}
async with httpx.AsyncClient(timeout=12.0) as client:
response = await client.post("https://api.pushover.net/1/messages.json", data=form)
response.raise_for_status()
return {"status": "ok", "detail": f"Pushover accepted ({response.status_code})."}
if provider == "discord":
return await _send_discord(title, message, payload)
if provider == "telegram":
return await _send_telegram(title, message)
if provider == "webhook":
return await _send_webhook(payload)
return {"status": "skipped", "detail": f"Unsupported push provider '{provider}'."}
async def _send_email(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
runtime = get_runtime_settings()
recipients = _split_emails(_clean_text(get_setting("portal_notification_recipients")))
fallback = _clean_text(runtime.magent_notify_email_from_address)
if fallback and fallback not in recipients:
recipients.append(fallback)
if not recipients:
return {"status": "skipped", "detail": "No portal notification recipient is configured."}
body_text = (
f"{title}\n\n"
f"{message}\n\n"
f"Kind: {_clean_text(payload.get('kind'))}\n"
f"Status: {_clean_text(payload.get('status'))}\n"
f"Priority: {_clean_text(payload.get('priority'))}\n"
f"Requested by: {_clean_text(payload.get('requested_by'))}\n"
f"Open: {_clean_text(payload.get('item_url'))}\n"
)
body_html = (
"<div style=\"font-family:Segoe UI,Arial,sans-serif; color:#132033;\">"
f"<h2 style=\"margin:0 0 12px;\">{title}</h2>"
f"<p style=\"margin:0 0 16px; line-height:1.7;\">{message}</p>"
"<table style=\"border-collapse:collapse; width:100%; margin:0 0 16px;\">"
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Kind</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('kind'))}</td></tr>"
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Status</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('status'))}</td></tr>"
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Priority</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('priority'))}</td></tr>"
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Requested by</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('requested_by'))}</td></tr>"
"</table>"
f"<a href=\"{_clean_text(payload.get('item_url'))}\" style=\"display:inline-block; padding:10px 16px; border-radius:999px; background:#1c6bff; color:#fff; text-decoration:none; font-weight:700;\">Open portal item</a>"
"</div>"
)
deliveries: list[Dict[str, Any]] = []
for recipient in recipients:
try:
result = await send_generic_email(
recipient_email=recipient,
subject=title,
body_text=body_text,
body_html=body_html,
)
deliveries.append({"recipient": recipient, "status": "ok", **result})
except Exception as exc:
deliveries.append({"recipient": recipient, "status": "error", "detail": str(exc)})
successful = [entry for entry in deliveries if entry.get("status") == "ok"]
if successful:
return {"status": "ok", "detail": f"Email sent to {len(successful)} recipient(s).", "deliveries": deliveries}
return {"status": "error", "detail": "Email delivery failed for all recipients.", "deliveries": deliveries}
async def send_portal_notification(
*,
event_type: str,
item: Dict[str, Any],
actor_username: str,
actor_role: str,
note: Optional[str] = None,
) -> Dict[str, Any]:
runtime = get_runtime_settings()
if not runtime.magent_notify_enabled:
return {"status": "skipped", "detail": "Notifications are disabled.", "channels": {}}
item_id = int(item.get("id") or 0)
title = f"{env_settings.app_name} portal update: {item.get('title') or f'Item #{item_id}'}"
message_lines = [
f"Event: {event_type}",
f"Actor: {actor_username} ({actor_role})",
f"Item #{item_id} is now '{_clean_text(item.get('status'), 'unknown')}'.",
]
if note:
message_lines.append(f"Note: {note}")
message_lines.append(f"Open: {_portal_item_url(item_id)}")
message = "\n".join(message_lines)
payload = {
"type": "portal.notification",
"event": event_type,
"item_id": item_id,
"item_url": _portal_item_url(item_id),
"kind": _clean_text(item.get("kind")),
"status": _clean_text(item.get("status")),
"priority": _clean_text(item.get("priority")),
"requested_by": _clean_text(item.get("created_by_username")),
"actor_username": actor_username,
"actor_role": actor_role,
"note": note or "",
}
channels: Dict[str, Dict[str, Any]] = {}
if runtime.magent_notify_discord_enabled:
try:
channels["discord"] = await _send_discord(title, message, payload)
except Exception as exc:
channels["discord"] = {"status": "error", "detail": str(exc)}
if runtime.magent_notify_telegram_enabled:
try:
channels["telegram"] = await _send_telegram(title, message)
except Exception as exc:
channels["telegram"] = {"status": "error", "detail": str(exc)}
if runtime.magent_notify_webhook_enabled:
try:
channels["webhook"] = await _send_webhook(payload)
except Exception as exc:
channels["webhook"] = {"status": "error", "detail": str(exc)}
if runtime.magent_notify_push_enabled:
try:
channels["push"] = await _send_push(title, message, payload)
except Exception as exc:
channels["push"] = {"status": "error", "detail": str(exc)}
if runtime.magent_notify_email_enabled:
try:
channels["email"] = await _send_email(title, message, payload)
except Exception as exc:
channels["email"] = {"status": "error", "detail": str(exc)}
successful = [name for name, value in channels.items() if value.get("status") == "ok"]
failed = [name for name, value in channels.items() if value.get("status") == "error"]
skipped = [name for name, value in channels.items() if value.get("status") == "skipped"]
logger.info(
"portal notification event=%s item_id=%s successful=%s failed=%s skipped=%s",
event_type,
item_id,
successful,
failed,
skipped,
)
overall = "ok" if successful and not failed else "error" if failed and not successful else "partial"
if not channels:
overall = "skipped"
return {"status": overall, "channels": channels}
+206
View File
@@ -0,0 +1,206 @@
from __future__ import annotations
from contextvars import ContextVar, Token
from copy import deepcopy
from datetime import datetime, timezone
import re
import threading
import time
import uuid
from typing import Any, Dict, Optional
_OPERATION_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{8,80}$")
_OPERATION_TTL_SECONDS = 15 * 60
_MAX_OPERATIONS = 500
_MAX_EVENTS = 60
_current_operation_id: ContextVar[Optional[str]] = ContextVar(
"magent_operation_id", default=None
)
_operations: Dict[str, Dict[str, Any]] = {}
_lock = threading.Lock()
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def normalize_operation_id(value: Optional[str]) -> Optional[str]:
if not isinstance(value, str):
return None
normalized = value.strip()
return normalized if _OPERATION_ID_PATTERN.fullmatch(normalized) else None
def _prune_locked(now_monotonic: float) -> None:
expired = [
operation_id
for operation_id, operation in _operations.items()
if now_monotonic - float(operation.get("updated_monotonic") or 0) > _OPERATION_TTL_SECONDS
]
for operation_id in expired:
_operations.pop(operation_id, None)
if len(_operations) <= _MAX_OPERATIONS:
return
oldest = sorted(
_operations,
key=lambda operation_id: float(_operations[operation_id].get("updated_monotonic") or 0),
)
for operation_id in oldest[: len(_operations) - _MAX_OPERATIONS]:
_operations.pop(operation_id, None)
def begin_operation(operation_id: str, *, label: Optional[str], path: str) -> Token:
now_monotonic = time.monotonic()
now_iso = _now_iso()
normalized_label = str(label or "Requested action").strip()[:120] or "Requested action"
with _lock:
_prune_locked(now_monotonic)
_operations[operation_id] = {
"id": operation_id,
"label": normalized_label,
"path": path,
"status": "running",
"started_at": now_iso,
"updated_at": now_iso,
"updated_monotonic": now_monotonic,
"duration_ms": None,
"events": [
{
"id": uuid.uuid4().hex,
"service": "Magent",
"state": "complete",
"message": "Your action has been received. Magent is starting the checks.",
"started_at": now_iso,
"finished_at": now_iso,
"duration_ms": 0,
"status_code": None,
}
],
}
return _current_operation_id.set(operation_id)
def reset_operation(token: Token) -> None:
_current_operation_id.reset(token)
def start_remote_call(service: str, message: Optional[str] = None) -> Optional[str]:
operation_id = _current_operation_id.get()
if not operation_id:
return None
event_id = uuid.uuid4().hex
now_iso = _now_iso()
now_monotonic = time.monotonic()
with _lock:
operation = _operations.get(operation_id)
if not operation:
return None
operation["events"].append(
{
"id": event_id,
"service": service,
"state": "active",
"message": message or f"Contacting {service}",
"started_at": now_iso,
"finished_at": None,
"duration_ms": None,
"status_code": None,
"started_monotonic": now_monotonic,
}
)
operation["events"] = operation["events"][-_MAX_EVENTS:]
operation["updated_at"] = now_iso
operation["updated_monotonic"] = now_monotonic
return event_id
def finish_remote_call(
event_id: Optional[str],
*,
success: bool,
status_code: Optional[int] = None,
message: Optional[str] = None,
) -> None:
operation_id = _current_operation_id.get()
if not operation_id or not event_id:
return
now_iso = _now_iso()
now_monotonic = time.monotonic()
with _lock:
operation = _operations.get(operation_id)
if not operation:
return
event = next(
(candidate for candidate in operation["events"] if candidate.get("id") == event_id),
None,
)
if not event:
return
started_monotonic = float(event.pop("started_monotonic", now_monotonic))
event["state"] = "complete" if success else "error"
event["finished_at"] = now_iso
event["duration_ms"] = round((now_monotonic - started_monotonic) * 1000, 1)
event["status_code"] = status_code
event["message"] = message or (
f"{event['service']} responded successfully."
if success
else f"{event['service']} returned an error."
)
operation["updated_at"] = now_iso
operation["updated_monotonic"] = now_monotonic
def finish_operation(operation_id: str, *, success: bool, status_code: Optional[int]) -> None:
now_iso = _now_iso()
now_monotonic = time.monotonic()
with _lock:
operation = _operations.get(operation_id)
if not operation:
return
for event in operation["events"]:
if event.get("state") == "active":
started_monotonic = float(event.pop("started_monotonic", now_monotonic))
event["state"] = "error"
event["finished_at"] = now_iso
event["duration_ms"] = round((now_monotonic - started_monotonic) * 1000, 1)
event["message"] = f"{event.get('service') or 'Remote service'} did not complete."
started = datetime.fromisoformat(str(operation["started_at"]))
duration_ms = (datetime.now(timezone.utc) - started).total_seconds() * 1000
operation["status"] = "complete" if success else "error"
operation["status_code"] = status_code
operation["duration_ms"] = round(duration_ms, 1)
operation["updated_at"] = now_iso
operation["updated_monotonic"] = now_monotonic
operation["events"].append(
{
"id": uuid.uuid4().hex,
"service": "Magent",
"state": "complete" if success else "error",
"message": (
"This action has finished. Check the request status for what happens next."
if success
else "This action could not be completed. Open the activity details to see which step needs attention."
),
"started_at": now_iso,
"finished_at": now_iso,
"duration_ms": 0,
"status_code": status_code,
}
)
operation["events"] = operation["events"][-_MAX_EVENTS:]
def get_operation(operation_id: str) -> Optional[Dict[str, Any]]:
normalized = normalize_operation_id(operation_id)
if not normalized:
return None
with _lock:
operation = _operations.get(normalized)
if not operation:
return None
result = deepcopy(operation)
result.pop("updated_monotonic", None)
for event in result.get("events", []):
event.pop("started_monotonic", None)
return result
+335
View File
@@ -0,0 +1,335 @@
from __future__ import annotations
import logging
import secrets
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional
from ..auth import normalize_user_auth_provider, resolve_user_auth_provider
from ..clients.jellyfin import JellyfinClient
from ..clients.jellyseerr import JellyseerrClient
from ..db import (
create_password_reset_token,
delete_expired_password_reset_tokens,
get_password_reset_token,
get_user_by_jellyseerr_id,
get_user_by_username,
get_users_by_username_ci,
mark_password_reset_token_used,
set_user_auth_provider,
set_user_password,
increment_user_auth_version,
sync_jellyfin_password_state,
)
from ..runtime import get_runtime_settings
from .invite_email import send_password_reset_email
from .user_cache import get_cached_jellyseerr_users, save_jellyseerr_users_cache
logger = logging.getLogger(__name__)
PASSWORD_RESET_TOKEN_TTL_MINUTES = 30
class PasswordResetUnavailableError(RuntimeError):
pass
def _normalize_handles(value: object) -> list[str]:
if not isinstance(value, str):
return []
normalized = value.strip().lower()
if not normalized:
return []
handles = [normalized]
if "@" in normalized:
handles.append(normalized.split("@", 1)[0])
return list(dict.fromkeys(handles))
def _pick_preferred_user(users: list[dict], requested_identifier: str) -> dict | None:
if not users:
return None
requested = str(requested_identifier or "").strip().lower()
def _rank(user: dict) -> tuple[int, int, int, int]:
provider = str(user.get("auth_provider") or "local").strip().lower()
role = str(user.get("role") or "user").strip().lower()
username = str(user.get("username") or "").strip().lower()
return (
0 if role == "admin" else 1,
0 if isinstance(user.get("jellyseerr_user_id"), int) else 1,
0 if provider == "jellyfin" else (1 if provider == "local" else 2),
0 if username == requested else 1,
)
return sorted(users, key=_rank)[0]
def _find_matching_seerr_user(identifier: str, users: list[dict]) -> dict | None:
target_handles = set(_normalize_handles(identifier))
if not target_handles:
return None
for user in users:
if not isinstance(user, dict):
continue
for key in ("username", "email"):
value = user.get(key)
if target_handles.intersection(_normalize_handles(value)):
return user
return None
async def _fetch_all_seerr_users() -> list[dict]:
cached = get_cached_jellyseerr_users()
if cached is not None:
return cached
runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if not client.configured():
return []
users: list[dict] = []
take = 100
skip = 0
while True:
payload = await client.get_users(take=take, skip=skip)
if not payload:
break
if isinstance(payload, list):
batch = payload
elif isinstance(payload, dict):
batch = payload.get("results") or payload.get("users") or payload.get("data") or payload.get("items")
else:
batch = None
if not isinstance(batch, list) or not batch:
break
users.extend([user for user in batch if isinstance(user, dict)])
if len(batch) < take:
break
skip += take
if users:
return save_jellyseerr_users_cache(users)
return users
def _resolve_seerr_user_email(seerr_user: Optional[dict], local_user: Optional[dict]) -> Optional[str]:
if isinstance(local_user, dict):
stored_email = str(local_user.get("email") or "").strip()
if "@" in stored_email:
return stored_email
username = str(local_user.get("username") or "").strip()
if "@" in username:
return username
if isinstance(seerr_user, dict):
email = str(seerr_user.get("email") or "").strip()
if "@" in email:
return email
return None
async def _resolve_reset_target(identifier: str) -> Optional[Dict[str, Any]]:
normalized_identifier = str(identifier or "").strip()
if not normalized_identifier:
return None
local_user = normalize_user_auth_provider(
_pick_preferred_user(get_users_by_username_ci(normalized_identifier), normalized_identifier)
)
seerr_users: list[dict] | None = None
seerr_user: dict | None = None
if isinstance(local_user, dict) and isinstance(local_user.get("jellyseerr_user_id"), int):
seerr_users = await _fetch_all_seerr_users()
seerr_user = next(
(
user
for user in seerr_users
if isinstance(user, dict) and int(user.get("id") or user.get("userId") or 0) == int(local_user["jellyseerr_user_id"])
),
None,
)
if not local_user:
seerr_users = seerr_users if seerr_users is not None else await _fetch_all_seerr_users()
seerr_user = _find_matching_seerr_user(normalized_identifier, seerr_users)
if seerr_user:
seerr_user_id = seerr_user.get("id") or seerr_user.get("userId") or seerr_user.get("Id")
try:
seerr_user_id = int(seerr_user_id) if seerr_user_id is not None else None
except (TypeError, ValueError):
seerr_user_id = None
if seerr_user_id is not None:
local_user = normalize_user_auth_provider(get_user_by_jellyseerr_id(seerr_user_id))
if not local_user:
for candidate in (seerr_user.get("email"), seerr_user.get("username")):
if not isinstance(candidate, str) or not candidate.strip():
continue
local_user = normalize_user_auth_provider(
_pick_preferred_user(get_users_by_username_ci(candidate), candidate)
)
if local_user:
break
if not local_user:
return None
auth_provider = resolve_user_auth_provider(local_user)
username = str(local_user.get("username") or "").strip()
recipient_email = _resolve_seerr_user_email(seerr_user, local_user)
if not recipient_email:
seerr_users = seerr_users if seerr_users is not None else await _fetch_all_seerr_users()
if isinstance(local_user.get("jellyseerr_user_id"), int):
seerr_user = next(
(
user
for user in seerr_users
if isinstance(user, dict) and int(user.get("id") or user.get("userId") or 0) == int(local_user["jellyseerr_user_id"])
),
None,
)
if not seerr_user:
seerr_user = _find_matching_seerr_user(username, seerr_users)
recipient_email = _resolve_seerr_user_email(seerr_user, local_user)
if not recipient_email:
return None
if auth_provider == "jellyseerr":
runtime = get_runtime_settings()
jellyfin_client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if jellyfin_client.configured():
try:
jellyfin_user = await jellyfin_client.find_user_by_name(username)
except Exception:
jellyfin_user = None
if isinstance(jellyfin_user, dict):
auth_provider = "jellyfin"
if auth_provider not in {"local", "jellyfin"}:
return None
return {
"username": username,
"recipient_email": recipient_email,
"auth_provider": auth_provider,
}
def _token_record_is_usable(record: Optional[dict]) -> bool:
if not isinstance(record, dict):
return False
if record.get("is_used"):
return False
if record.get("is_expired"):
return False
return True
def _mask_email(email: str) -> str:
candidate = str(email or "").strip()
if "@" not in candidate:
return "valid reset link"
local_part, domain = candidate.split("@", 1)
if not local_part:
return f"***@{domain}"
if len(local_part) == 1:
return f"{local_part}***@{domain}"
return f"{local_part[0]}***{local_part[-1]}@{domain}"
async def request_password_reset(
identifier: str,
*,
requested_by_ip: Optional[str] = None,
requested_user_agent: Optional[str] = None,
) -> Dict[str, Any]:
delete_expired_password_reset_tokens()
target = await _resolve_reset_target(identifier)
if not target:
logger.info("password reset requested with no eligible match")
return {"status": "ok", "issued": False}
token = secrets.token_urlsafe(32)
expires_at = (datetime.now(timezone.utc) + timedelta(minutes=PASSWORD_RESET_TOKEN_TTL_MINUTES)).isoformat()
create_password_reset_token(
token,
target["username"],
target["recipient_email"],
target["auth_provider"],
expires_at,
requested_by_ip=requested_by_ip,
requested_user_agent=requested_user_agent,
)
await send_password_reset_email(
recipient_email=target["recipient_email"],
username=target["username"],
token=token,
expires_at=expires_at,
auth_provider=target["auth_provider"],
)
return {
"status": "ok",
"issued": True,
"username": target["username"],
"recipient_email": target["recipient_email"],
"auth_provider": target["auth_provider"],
"expires_at": expires_at,
}
def verify_password_reset_token(token: str) -> Dict[str, Any]:
delete_expired_password_reset_tokens()
record = get_password_reset_token(token)
if not _token_record_is_usable(record):
raise ValueError("Password reset link is invalid or has expired.")
return {
"status": "ok",
"recipient_hint": _mask_email(str(record.get("recipient_email") or "")),
"auth_provider": record.get("auth_provider"),
"expires_at": record.get("expires_at"),
}
async def apply_password_reset(token: str, new_password: str) -> Dict[str, Any]:
delete_expired_password_reset_tokens()
record = get_password_reset_token(token)
if not _token_record_is_usable(record):
raise ValueError("Password reset link is invalid or has expired.")
username = str(record.get("username") or "").strip()
if not username:
raise ValueError("Password reset link is invalid or has expired.")
stored_user = normalize_user_auth_provider(get_user_by_username(username))
if not stored_user:
raise ValueError("Password reset link is invalid or has expired.")
auth_provider = resolve_user_auth_provider(stored_user)
if auth_provider == "jellyseerr":
auth_provider = "jellyfin"
if auth_provider == "local":
set_user_password(username, new_password)
if str(stored_user.get("auth_provider") or "").strip().lower() != "local":
set_user_auth_provider(username, "local")
mark_password_reset_token_used(token)
logger.info("password reset applied username=%s provider=local", username)
return {"status": "ok", "provider": "local", "username": username}
if auth_provider == "jellyfin":
runtime = get_runtime_settings()
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if not client.configured():
raise PasswordResetUnavailableError("Jellyfin is not configured for password reset.")
jellyfin_user = await client.find_user_by_name(username)
user_id = client._extract_user_id(jellyfin_user)
if not user_id:
raise ValueError("Password reset link is invalid or has expired.")
await client.set_user_password(user_id, new_password)
sync_jellyfin_password_state(username, new_password)
increment_user_auth_version(username)
if str(stored_user.get("auth_provider") or "").strip().lower() != "jellyfin":
set_user_auth_provider(username, "jellyfin")
mark_password_reset_token_used(token)
logger.info("password reset applied username=%s provider=jellyfin", username)
return {"status": "ok", "provider": "jellyfin", "username": username}
raise ValueError("Password reset is not available for this sign-in provider.")
+32
View File
@@ -0,0 +1,32 @@
"""Configured public email links, independent of request Host/forwarded headers."""
from urllib.parse import urlsplit
from ..runtime import get_runtime_settings
from ..installation_origin import managed_runtime
def valid_public_url(value):
value = str(value or '').strip().rstrip('/')
try:
parsed = urlsplit(value)
if (parsed.scheme in {'http', 'https'} and parsed.hostname
and not (parsed.username or parsed.password or parsed.query or parsed.fragment)
and (parsed.port is None or parsed.port > 0)
and not any(c.isspace() or ord(c) < 33 or c in '<>"\\' for c in value)):
return value
except ValueError:
pass
return ''
def magent_public_url(legacy_url=''):
runtime = get_runtime_settings()
proxy = getattr(runtime, 'magent_proxy_base_url', None)
application = getattr(runtime, 'magent_application_url', None)
if managed_runtime():
return valid_public_url(application)
if getattr(runtime, 'magent_proxy_enabled', False) and str(proxy or '').strip():
return valid_public_url(proxy)
if str(application or '').strip():
return valid_public_url(application)
# Preserve pre-existing installations until Hosting & proxy has been configured.
return valid_public_url(legacy_url)
+198
View File
@@ -0,0 +1,198 @@
"""Personal recap email rendering and SMTP delivery with explicit acceptance tracking."""
import html
import re
import smtplib
import ssl
from contextlib import suppress
from datetime import datetime
from email.message import EmailMessage
from email.policy import SMTP as SMTP_POLICY
from email.utils import formataddr, formatdate
from urllib.parse import urlsplit
from ..runtime import get_runtime_settings
class DeliveryError(Exception):
def __init__(self, state: str, detail: str):
self.state, self.detail = state, detail
super().__init__(detail)
class DeliveryCancelled(Exception):
pass
def valid_email(value: str | None) -> str | None:
value = str(value or "").strip()
if (len(value) <= 254 and re.fullmatch(r"[^@\s<>;,\"\\]+@[^@\s<>;,\"\\]+\.[^@\s<>;,\"\\]+", value)
and all(32 < ord(char) < 127 for char in value)):
return value
return None
def month_label(value: str) -> str:
return datetime.strptime(value, "%Y-%m").strftime("%B %Y")
def number(value: float) -> str:
return f"{value:,.0f}"
def document(*, title: str, intro: str, content: str, action: str, url: str, footer: str, kicker: str = 'YOUR MONTH IN VIEWING') -> str:
esc = html.escape
return f'''<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="color-scheme" content="dark"><title>{esc(title)}</title><style>@media(max-width:280px){{.email-metrics td{{display:block!important;width:auto!important;padding:16px 0!important}}.email-metrics tr{{display:block!important}}}}</style></head>
<body style="margin:0;padding:0;background:#131315;color:#e5e1e4;font-family:Arial,Helvetica,sans-serif">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#131315"><tr><td align="center" style="padding:24px 12px">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="width:100%;max-width:600px;table-layout:fixed;background:#1c1b1d;border:1px solid #363338;border-radius:16px">
<tr><td style="padding:32px 24px 8px;color:#c7bdff;font-size:12px;letter-spacing:2px;font-weight:bold">MAGENT <span style="color:#918b98;letter-spacing:0">/ {esc(kicker)}</span></td></tr>
<tr><td style="padding:12px 24px"><h1 style="margin:0 0 16px;font-size:32px;line-height:1.2;color:#f3eef6">{esc(title)}</h1><p style="margin:0;color:#bdb6c3;font-size:15px;line-height:1.7;overflow-wrap:anywhere">{esc(intro)}</p></td></tr>
<tr><td style="padding:12px 24px">{content}</td></tr>
<tr><td style="padding:20px 24px 32px"><a href="{esc(url, quote=True)}" style="display:inline-block;padding:15px 22px;border-radius:8px;background:#c7bdff;color:#211b30;text-decoration:none;font-size:14px;font-weight:bold">{esc(action)} &#8599;</a></td></tr>
</table><table role="presentation" width="600" style="width:100%;max-width:600px"><tr><td style="padding:22px 18px;color:#a69fac;font-size:12px;line-height:1.7;text-align:center">{footer}</td></tr></table>
</td></tr></table></body></html>'''
def render_confirmation(username: str, url: str) -> dict:
title = "Your month, delivered."
intro = f"Hi {username}, confirm this email address to receive personal viewing reports from Magent. You choose whether to request them yourself or also receive automatic monthly emails."
text = f"{intro}\n\nConfirm email recaps: {url}\n\nThis link expires in 24 hours. If you did not request this, ignore this email. No viewing history will be emailed until you confirm."
body = document(title=title, intro=intro,
content='<p style="color:#bdb6c3;font-size:14px;line-height:1.7">Minutes watched, movies, episodes, your longest run and requests — with a link to your full monthly report.</p>',
action="Confirm email recaps", url=url,
footer="This link expires in 24 hours. If you did not request this, ignore this email.<br>No viewing history will be emailed until you confirm.")
return {"subject": "Confirm your Magent email recaps", "body_text": text, "body_html": body}
def render_recap(report: dict, username: str, public_url: str, unsubscribe_url: str, *, test: bool = False, requested: bool = False) -> dict:
esc = html.escape
month = month_label(report["month"])
previous = month_label(report["comparison_month"])
if report.get('is_partial'):
month += ' so far'
previous += ' (same elapsed period, capped at month end)' if report.get('comparison_capped') else ' (same elapsed period)'
summary = report["summary"]
metrics = (("Minutes watched", "minutes", summary["minutes"]), ("Movies played", "movies", summary["movies"]),
("Episodes played", "episodes", summary["episodes"]), ("Requests made", "requests", report["requests"]["total"]))
cells, lines = [], []
for label, key, value in metrics:
change = report["changes"][key]
difference = change["difference"]
comparison = ("No change" if difference == 0 else f"{'+' if difference > 0 else ''}{number(abs(difference))}")
if change["percent"] is not None and difference:
comparison += f" ({'+' if difference > 0 else ''}{abs(change['percent']):g}%)"
comparison += f" from {previous}"
lines.append(f"{label}: {number(value)}. {comparison}.")
cells.append(f'<td width="50%" valign="top" style="padding:16px 10px;border-bottom:1px solid #363338"><span style="color:#bdb6c3;font-size:12px">{label}</span><br><strong style="display:block;margin:10px 0;color:#e0d8ff;font-size:30px">{number(value)}</strong><span style="color:#a69fac;font-size:11px;line-height:1.6">{esc(comparison)}</span></td>')
content = '<table role="presentation" class="email-metrics" width="100%" cellpadding="0" cellspacing="0" style="table-layout:fixed"><tr>' + ''.join(cells[:2]) + '</tr><tr>' + ''.join(cells[2:]) + '</tr></table>'
habit = f"{number(summary['active_days'])} days watched · {number(summary['longest_streak'])}-day longest run"
content += f'<p style="color:#e5e1e4;font-size:14px;line-height:1.7;margin:24px 0">{esc(habit)}</p>'
patterns = report.get("patterns", {})
if patterns:
detail = f"Average play: {number(patterns['average_play_minutes'])} min. Longest play: {number(patterns['longest_play_minutes'])} min. Weekend viewing: {number(patterns['weekend_percent'])}%."
lines.append(detail)
content += f'<p style="padding:18px;background:#242334;border-radius:12px;color:#d8cfff;line-height:1.8">{esc(detail)}</p>'
for heading, rows in (("Your week in viewing (UTC)", patterns["weekdays"]), ("Movies, TV and more", patterns["media"])):
peak = max(1, *(row["minutes"] for row in rows))
content += f'<h2 style="font-size:18px;color:#e5e1e4">{heading}</h2><table role="presentation" width="100%" cellspacing="0" cellpadding="0">'
for row in rows:
width = round(row["minutes"] / peak * 100)
content += f'<tr><td style="padding:8px 0;color:#bdb6c3;font-size:12px;width:100px">{esc(row["name"])}</td><td style="padding:8px"><table role="presentation" width="{width}%" cellspacing="0" cellpadding="0"><tr><td height="8" style="background:{"#8cdbdd" if width else "transparent"};border-radius:4px;font-size:0">&nbsp;</td></tr></table></td><td style="width:65px;color:#e0d8ff;font-size:12px;text-align:right">{number(row["minutes"])} min</td></tr>'
lines.append(f"{row['name']}: {number(row['minutes'])} minutes")
content += '</table>'
top = report.get("top_titles", [])[:3]
if top:
content += '<h2 style="font-size:18px;color:#e5e1e4;margin:24px 0 8px">Your most watched</h2>'
for item in top:
artwork = item.get("email_artwork", "")
if artwork.startswith(("cid:", "data:image/")):
content += f'<img src="{esc(artwork, quote=True)}" alt="{esc(item["title"], quote=True)}" width="80" style="display:block;border-radius:10px;margin-top:20px" />'
content += f'<p style="font-size:14px;line-height:1.6;color:#e5e1e4;margin:12px 0;overflow-wrap:anywhere">{esc(item["title"])}<br><span style="font-size:12px;color:#a69fac">{number(item["minutes"])} minutes · {number(item["plays"])} plays</span></p>'
else:
content += '<p style="font-size:14px;color:#bdb6c3;line-height:1.7">No viewing was recorded this month. Your requests are still included.</p>'
report_url = f"{public_url}/insights/reports?month={report['month']}"
intro = f"Hi {username}, heres your {month} in viewing. A little look back at the stories you spent time with."
footer = f'You enabled personal report emails from Magent.<br>Based on retained Jellystat history. Calendar months use UTC; request statuses are current.<br><a href="{esc(unsubscribe_url, quote=True)}" style="color:#c7bdff">Unsubscribe from recaps</a> · <a href="{esc(public_url + "/profile#monthly-recaps", quote=True)}" style="color:#c7bdff">Email preferences</a>'
if requested:
intro = 'You requested this report. ' + intro
if test:
intro = "This is your test recap. " + intro
body = document(title=month, intro=intro, content=content, action="Explore your full report", url=report_url, footer=footer)
text = '\n'.join([intro, '', *lines, '', habit, '', 'Most watched:',
*(f"{item['title']}: {number(item['minutes'])} minutes" for item in top), '',
f"Your full report: {report_url}", '', 'Based on retained Jellystat history. Calendar months use UTC; request statuses are current.',
f"Unsubscribe from recaps: {unsubscribe_url}", f"Email preferences: {public_url}/profile#monthly-recaps"])
return {"subject": f"{'[Test] ' if test else ''}Your {month} in viewing · Magent", "body_text": text, "body_html": body}
def send_email(recipient: str, rendered: dict, message_id: str, before_data=lambda: None) -> None:
"""Return only after SMTP accepts DATA. Never retry an ambiguous DATA disconnect.
A stable Message-ID aids diagnosis; it is not an SMTP deduplication guarantee.
See RFC 5321 §4.5.3.2.6 and Python's smtplib exception definitions.
"""
runtime = get_runtime_settings()
sender = valid_email(runtime.magent_notify_email_from_address)
if not sender or not valid_email(recipient):
raise DeliveryError("failed", "A valid sender and recipient email are required.")
message = EmailMessage(policy=SMTP_POLICY)
message["From"] = formataddr((str(runtime.magent_notify_email_from_name or "Magent").replace('\r', '').replace('\n', ''), sender))
message["To"], message["Subject"] = recipient, rendered["subject"]
message["Date"], message["Message-ID"] = formatdate(localtime=False), message_id
message["Auto-Submitted"], message["X-Auto-Response-Suppress"] = "auto-generated", "All"
message.set_content(rendered["body_text"])
message.add_alternative(rendered["body_html"], subtype="html")
html_part = message.get_payload()[-1]
for attachment in rendered.get('inline_images', []):
html_part.add_related(
attachment['data'], maintype='image', subtype=attachment.get('subtype', 'jpeg'), cid=f"<{attachment['cid']}>",
filename=attachment['cid'].split('@')[0] + '.' + attachment.get('subtype', 'jpeg'), disposition='inline')
payload = message.as_bytes()
smtp, stage = None, "connect"
try:
kwargs = {"timeout": 30, "local_hostname": sender.split('@', 1)[1]}
if runtime.magent_notify_email_use_ssl:
smtp = smtplib.SMTP_SSL(runtime.magent_notify_email_smtp_host, runtime.magent_notify_email_smtp_port,
context=ssl.create_default_context(), **kwargs)
else:
smtp = smtplib.SMTP(runtime.magent_notify_email_smtp_host, runtime.magent_notify_email_smtp_port, **kwargs)
smtp.ehlo_or_helo_if_needed()
if runtime.magent_notify_email_use_tls and not runtime.magent_notify_email_use_ssl:
smtp.starttls(context=ssl.create_default_context())
smtp.ehlo()
if runtime.magent_notify_email_smtp_username:
smtp.login(runtime.magent_notify_email_smtp_username, runtime.magent_notify_email_smtp_password)
code, reply = smtp.mail(sender)
if code != 250:
raise smtplib.SMTPResponseException(code, reply)
code, reply = smtp.rcpt(recipient)
if code not in (250, 251):
raise smtplib.SMTPResponseException(code, reply)
before_data()
stage = "data"
code, reply = smtp.data(payload)
if code != 250:
raise smtplib.SMTPDataError(code, reply)
stage = "accepted"
except smtplib.SMTPResponseException as exc:
state = "retry" if 400 <= exc.smtp_code < 500 else "failed"
raise DeliveryError(state, f"Mail server returned SMTP {exc.smtp_code}.") from exc
except (ssl.SSLError, smtplib.SMTPNotSupportedError, UnicodeError, ValueError) as exc:
raise DeliveryError("failed", "Check the SMTP security and sender settings.") from exc
except (OSError, smtplib.SMTPException) as exc:
state = "unknown" if stage == "data" else "retry"
detail = "Mail server acceptance is unknown; check its logs before taking further action." if state == "unknown" else "Could not reach or finish connecting to the mail server."
raise DeliveryError(state, detail) from exc
finally:
if smtp:
# A failed QUIT after a 250 DATA response must not turn an accepted email into a retry.
with suppress(Exception):
smtp.quit()
with suppress(Exception):
smtp.close()
def message_id(delivery_id: str, public_url: str) -> str:
host = urlsplit(public_url).hostname or "magent.local"
return f"<magent-recap-{delivery_id}@{host}>"
+247
View File
@@ -0,0 +1,247 @@
"""Durable consent, schedule and delivery records for personal email recaps."""
import hashlib
import secrets
import sqlite3
import uuid
from contextlib import closing, contextmanager
from datetime import datetime
from .. import db
from .monthly_reports import shift_month
from . import email_queue
from .public_urls import magent_public_url
def init_schema(conn: sqlite3.Connection) -> None:
for statement in (
"""CREATE TABLE IF NOT EXISTS email_recap_settings (
id INTEGER PRIMARY KEY CHECK (id = 1), enabled INTEGER NOT NULL DEFAULT 0,
day INTEGER NOT NULL DEFAULT 2, hour INTEGER NOT NULL DEFAULT 9,
public_url TEXT NOT NULL DEFAULT '', next_send_at REAL)""",
"INSERT OR IGNORE INTO email_recap_settings (id) VALUES (1)",
"""CREATE TABLE IF NOT EXISTS email_recap_subscriptions (
user_id INTEGER PRIMARY KEY, state TEXT NOT NULL, email TEXT NOT NULL,
identity_source TEXT NOT NULL, identity_id TEXT NOT NULL, version TEXT NOT NULL,
confirmation_hash TEXT UNIQUE, confirmation_expires REAL, requested_at REAL NOT NULL,
confirmed_at REAL, unsubscribe_token TEXT NOT NULL UNIQUE)""",
"""CREATE TABLE IF NOT EXISTS email_recap_deliveries (
id TEXT PRIMARY KEY, dedupe_key TEXT NOT NULL UNIQUE, user_id INTEGER NOT NULL,
month TEXT NOT NULL, kind TEXT NOT NULL, email TEXT NOT NULL,
subscription_version TEXT NOT NULL, public_url TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'queued', attempts INTEGER NOT NULL DEFAULT 0,
created_at REAL NOT NULL, updated_at REAL NOT NULL, next_attempt_at REAL NOT NULL,
claim TEXT, lease_until REAL, detail TEXT NOT NULL DEFAULT '')""",
"CREATE INDEX IF NOT EXISTS idx_email_recap_queue ON email_recap_deliveries (state, next_attempt_at)",
"""CREATE TRIGGER IF NOT EXISTS email_recap_account_changed AFTER UPDATE OF email, is_blocked ON users
WHEN LOWER(TRIM(COALESCE(NEW.email, ''))) != LOWER(TRIM(COALESCE(OLD.email, '')))
OR NEW.is_blocked = 1
BEGIN UPDATE email_recap_subscriptions SET state = 'off', confirmation_hash = NULL,
confirmed_at = NULL WHERE user_id = NEW.id; END""",
"""CREATE TRIGGER IF NOT EXISTS email_recap_account_deleted AFTER DELETE ON users
BEGIN DELETE FROM email_recap_subscriptions WHERE user_id = OLD.id;
UPDATE email_recap_deliveries SET state = 'cancelled', detail = 'Account removed.'
WHERE user_id = OLD.id AND state IN ('queued', 'retry', 'preparing'); END""",
"""CREATE TRIGGER IF NOT EXISTS email_recap_identity_changed AFTER UPDATE ON jellyfin_user_links
WHEN NEW.jellyfin_user_id != OLD.jellyfin_user_id OR NEW.source != OLD.source
OR NEW.local_user_id != OLD.local_user_id
BEGIN UPDATE email_recap_subscriptions SET state = 'off', confirmation_hash = NULL,
confirmed_at = NULL WHERE user_id = OLD.local_user_id; END""",
"""CREATE TRIGGER IF NOT EXISTS email_recap_identity_deleted AFTER DELETE ON jellyfin_user_links
BEGIN UPDATE email_recap_subscriptions SET state = 'off', confirmation_hash = NULL,
confirmed_at = NULL WHERE user_id = OLD.local_user_id; END""",
):
conn.execute(statement)
columns = {row[1] for row in conn.execute('PRAGMA table_info(email_recap_subscriptions)')}
if 'automatic_monthly' not in columns:
conn.execute('ALTER TABLE email_recap_subscriptions ADD COLUMN automatic_monthly INTEGER NOT NULL DEFAULT 1')
@contextmanager
def transaction():
with closing(db._connect()) as conn, conn:
conn.row_factory = sqlite3.Row
conn.execute("BEGIN IMMEDIATE")
yield conn
def read_one(sql: str, args=()) -> dict | None:
with closing(db._connect()) as conn:
conn.row_factory = sqlite3.Row
row = conn.execute(sql, args).fetchone()
return dict(row) if row else None
def settings() -> dict:
row = read_one("SELECT * FROM email_recap_settings WHERE id = 1")
row["public_url"] = magent_public_url(row["public_url"])
return {key: (bool(value) if key == "enabled" else value) for key, value in row.items() if key != "id"}
def next_due(now: datetime, day: int, hour: int) -> datetime:
due = shift_month(now, 0).replace(day=day, hour=hour)
return due if due > now else shift_month(now, 1).replace(day=day, hour=hour)
def save_settings(values: dict, now: datetime) -> dict:
values = {**values, "public_url": magent_public_url(values.get("public_url", ""))}
with transaction() as conn:
old = dict(conn.execute("SELECT * FROM email_recap_settings WHERE id = 1").fetchone())
changed = any(old[key] != values[key] for key in ("day", "hour", "public_url"))
due = old["next_send_at"]
if not values["enabled"]:
due = None
elif not old["enabled"] or changed:
due = next_due(now, values["day"], values["hour"]).timestamp()
conn.execute("UPDATE email_recap_settings SET enabled=?, day=?, hour=?, public_url=?, next_send_at=? WHERE id=1",
(values["enabled"], values["day"], values["hour"], values["public_url"], due))
if not values["enabled"] or changed:
conn.execute("""UPDATE email_recap_deliveries SET state='cancelled', detail='Schedule paused or changed.', updated_at=?
WHERE kind='scheduled' AND state IN ('queued', 'retry', 'preparing')""", (now.timestamp(),))
return settings()
def subscription(user_id: int) -> dict | None:
return read_one("SELECT * FROM email_recap_subscriptions WHERE user_id=?", (user_id,))
def disable(user_id: int) -> None:
with transaction() as conn:
conn.execute("UPDATE email_recap_subscriptions SET state='off', confirmation_hash=NULL, confirmed_at=NULL WHERE user_id=?", (user_id,))
conn.execute("""UPDATE email_recap_deliveries SET state='cancelled', detail='Email recaps turned off.'
WHERE user_id=? AND state IN ('queued', 'retry', 'preparing')""", (user_id,))
def request_confirmation(user: dict, source: str, identity: str, now: float, automatic_monthly: bool = True) -> str:
token = secrets.token_urlsafe(32)
with transaction() as conn:
old = conn.execute("SELECT * FROM email_recap_subscriptions WHERE user_id=?", (user["id"],)).fetchone()
if old and old["requested_at"] > now - 300:
raise ValueError("Please wait five minutes before requesting another confirmation email.")
conn.execute("""INSERT INTO email_recap_subscriptions
(user_id, state, email, identity_source, identity_id, version, confirmation_hash,
confirmation_expires, requested_at, confirmed_at, unsubscribe_token)
VALUES (?, 'pending', ?, ?, ?, ?, ?, ?, ?, NULL, ?)
ON CONFLICT(user_id) DO UPDATE SET state='pending', email=excluded.email,
identity_source=excluded.identity_source, identity_id=excluded.identity_id, version=excluded.version,
confirmation_hash=excluded.confirmation_hash, confirmation_expires=excluded.confirmation_expires,
requested_at=excluded.requested_at, confirmed_at=NULL, unsubscribe_token=excluded.unsubscribe_token""",
(user["id"], user["email"].strip(), source, identity, uuid.uuid4().hex,
hashlib.sha256(token.encode()).hexdigest(), now + 86400, now, secrets.token_urlsafe(32)))
conn.execute('UPDATE email_recap_subscriptions SET automatic_monthly=? WHERE user_id=?', (automatic_monthly, user['id']))
return token
def token_subscription(token: str, action: str) -> dict | None:
if action == "confirm":
return read_one("SELECT * FROM email_recap_subscriptions WHERE confirmation_hash=?",
(hashlib.sha256(token.encode()).hexdigest(),))
return read_one("SELECT * FROM email_recap_subscriptions WHERE unsubscribe_token=?", (token,))
def confirm(sub: dict, now: float) -> bool:
with transaction() as conn:
# Recheck address and blocked state in the same transaction as the consent write.
result = conn.execute("""UPDATE email_recap_subscriptions SET state='enabled', confirmed_at=?, confirmation_hash=NULL
WHERE user_id=? AND version=? AND state='pending' AND confirmation_expires>?
AND EXISTS (SELECT 1 FROM users WHERE users.id=user_id AND is_blocked=0
AND LOWER(TRIM(users.email))=LOWER(TRIM(email_recap_subscriptions.email)))""",
(now, sub["user_id"], sub["version"], now))
return result.rowcount == 1
def _enqueue(conn, sub: dict, month: str, kind: str, key: str, public_url: str, now: float) -> str:
delivery_id = uuid.uuid4().hex
conn.execute("""INSERT OR IGNORE INTO email_recap_deliveries
(id, dedupe_key, user_id, month, kind, email, subscription_version, public_url,
created_at, updated_at, next_attempt_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(delivery_id, key, sub["user_id"], month, kind, sub["email"], sub["version"], public_url, now, now, now))
return conn.execute("SELECT id FROM email_recap_deliveries WHERE dedupe_key=?", (key,)).fetchone()[0]
def enqueue_test(sub: dict, month: str, request_id: str, public_url: str, now: float, kind: str = "test") -> str:
key = f"{kind}:{sub['user_id']}:{request_id}"
with transaction() as conn:
existing = conn.execute("SELECT id,month,subscription_version FROM email_recap_deliveries WHERE dedupe_key=?", (key,)).fetchone()
if existing:
if existing['month'] != month or existing['subscription_version'] != sub['version']:
raise ValueError('This send request was already used. Refresh before requesting another report.')
return existing[0]
recent = conn.execute("SELECT 1 FROM email_recap_deliveries WHERE user_id=? AND kind IN ('test','on_demand') AND created_at>?",
(sub["user_id"], now - 300)).fetchone()
if recent:
raise ValueError("Please wait five minutes between report emails.")
return _enqueue(conn, sub, month, kind, key, public_url, now)
def enqueue_due(now: datetime) -> int:
with transaction() as conn:
config = dict(conn.execute("SELECT * FROM email_recap_settings WHERE id=1").fetchone())
config["public_url"] = magent_public_url(config["public_url"])
if not config["enabled"] or not config["next_send_at"] or config["next_send_at"] > now.timestamp():
return 0
# After long downtime, send only the latest due recap; never backfill a pile of old emails.
due = shift_month(now, 0).replace(day=config["day"], hour=config["hour"])
if due > now:
due = shift_month(now, -1).replace(day=config["day"], hour=config["hour"])
month = shift_month(due, -1).strftime("%Y-%m")
subs = conn.execute("SELECT * FROM email_recap_subscriptions WHERE state='enabled' AND automatic_monthly=1 AND confirmed_at<=?", (due.timestamp(),)).fetchall()
before = conn.total_changes
for sub in subs:
_enqueue(conn, dict(sub), month, "scheduled", f"scheduled:{sub['user_id']}:{month}", config["public_url"], now.timestamp())
count = conn.total_changes - before
conn.execute("UPDATE email_recap_settings SET next_send_at=? WHERE id=1",
(next_due(now, config["day"], config["hour"]).timestamp(),))
return count
def claim_delivery(now: float) -> dict | None:
with transaction() as conn:
return email_queue.claim(conn, "email_recap_deliveries", now)
def begin_sending(delivery: dict, now: float) -> bool:
with transaction() as conn:
# Consent may have changed while the report or SMTP connection was being prepared.
result = conn.execute("""UPDATE email_recap_deliveries SET state='sending', updated_at=?, lease_until=?
WHERE id=? AND claim=? AND state='preparing'
AND EXISTS (SELECT 1 FROM email_recap_subscriptions s JOIN users u ON u.id=s.user_id
JOIN jellyfin_user_links j ON j.local_user_id=u.id AND j.source=s.identity_source
WHERE s.user_id=email_recap_deliveries.user_id AND s.state='enabled'
AND s.version=email_recap_deliveries.subscription_version AND u.is_blocked=0
AND (email_recap_deliveries.kind!='scheduled' OR s.automatic_monthly=1)
AND LOWER(TRIM(u.email))=LOWER(TRIM(s.email)) AND j.jellyfin_user_id=s.identity_id)
AND EXISTS (SELECT 1 FROM email_recap_settings c WHERE c.id=1 AND c.public_url=email_recap_deliveries.public_url
AND (email_recap_deliveries.kind IN ('test','on_demand') OR c.enabled=1))""", (now, now + 1800, delivery["id"], delivery["claim"]))
return result.rowcount == 1
def finish(delivery: dict, state: str, detail: str, now: float, delay: int = 0) -> None:
with transaction() as conn:
email_queue.finish(conn, "email_recap_deliveries", delivery, state, detail, now, delay)
def history(limit: int = 50, offset: int = 0) -> dict:
with closing(db._connect()) as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute("""SELECT d.id, d.month, d.kind, d.email, d.state, d.attempts, d.created_at, d.updated_at,
d.next_attempt_at, d.detail, u.username FROM email_recap_deliveries d LEFT JOIN users u ON u.id=d.user_id
ORDER BY d.created_at DESC, d.id LIMIT ? OFFSET ?""", (limit, offset)).fetchall()
total = conn.execute("SELECT COUNT(*) FROM email_recap_deliveries").fetchone()[0]
subscribers = conn.execute("SELECT COUNT(*) FROM email_recap_subscriptions WHERE state='enabled'").fetchone()[0]
return {"deliveries": [dict(row) for row in rows], "total": total, "subscribers": subscribers}
def set_automatic(user_id: int, enabled: bool):
with transaction() as conn:
conn.execute('UPDATE email_recap_subscriptions SET automatic_monthly=? WHERE user_id=?', (enabled, user_id))
if not enabled:
conn.execute("""UPDATE email_recap_deliveries SET state='cancelled',detail='Automatic monthly emails turned off.'
WHERE user_id=? AND kind='scheduled' AND state IN ('queued','retry','preparing')""", (user_id,))
def personal_history(user_id: int) -> list[dict]:
with closing(db._connect()) as conn:
conn.row_factory = sqlite3.Row
return [dict(row) for row in conn.execute("""SELECT id,month,kind,state,created_at,detail
FROM email_recap_deliveries WHERE user_id=? ORDER BY created_at DESC,id DESC LIMIT 5""", (user_id,))]
+129
View File
@@ -0,0 +1,129 @@
"""Explicit original-language requests without changing shared quality defaults."""
import asyncio
import copy
import hashlib
import json
import re
import httpx
from fastapi import HTTPException
_profile_lock = asyncio.Lock()
_prefix = "Magent Original "
def language_info(details):
code = str(details.get("originalLanguage") or details.get("original_language") or "").lower()
if not re.fullmatch(r"[a-z]{2}", code) or code in {"en", "xx", "zz"}:
return None
return {"code": code}
def profile_body(profile):
return {key: copy.deepcopy(value) for key, value in profile.items() if key not in {"id", "name"}}
def profile_name(body):
return _prefix + hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()[:16]
def is_original_profile(profile):
return ((profile.get("language") or {}).get("id") == -2
and profile.get("name") == profile_name(profile_body(profile)))
async def original_profile(client, default_id):
# Reuse immutable copies; never edit a profile already used by other titles.
async with _profile_lock:
try:
profiles = await client.get_quality_profiles()
except httpx.HTTPError as exc:
raise HTTPException(502, "Radarr could not load the language profile. Try again.") from exc
if not isinstance(profiles, list):
raise HTTPException(502, "Radarr returned invalid quality profiles.")
default = next((p for p in profiles if p.get("id") == default_id), None)
if not default:
raise HTTPException(409, "The default quality profile changed. Reload the request.")
body = profile_body(default)
body["language"] = {"id": -2, "name": "Original"}
name = profile_name(body)
match = next((p for p in profiles if p.get("name") == name and profile_body(p) == body), None)
if match:
return match["id"]
try:
result = await client.post("/api/v3/qualityprofile", payload={**body, "name": name})
except httpx.HTTPError as exc:
raise HTTPException(502, "Radarr could not prepare the original-language profile. Try again.") from exc
if not isinstance(result, dict) or not isinstance(result.get("id"), int):
raise HTTPException(502, "Radarr could not prepare the original-language profile. Try again.")
return result["id"]
async def apply_original_to_movie(client, tmdb_id):
movies = await client.get_movie_by_tmdb_id(tmdb_id)
if not isinstance(movies, list):
raise HTTPException(502, "Radarr did not return the movie list.")
matches = [movie for movie in movies if movie.get('tmdbId') == tmdb_id]
if not matches:
return None
if len(matches) != 1:
raise HTTPException(409, "Radarr returned multiple movies for this identity.")
movie = matches[0]
profile_id = await original_profile(client, movie['qualityProfileId'])
if movie['qualityProfileId'] != profile_id:
movie['qualityProfileId'] = profile_id
await client.update_movie(movie)
verified = await client.get_movie(movie['id'])
if not verified or verified.get('qualityProfileId') != profile_id:
raise HTTPException(502, "Radarr did not save the original-language choice. Try again before searching.")
return profile_id
async def movie_search_outcome(client, movie_id, command, attempts=12, delay=2):
command_id = command.get('id') if isinstance(command, dict) else None
if not isinstance(command_id, int):
return {'status': 'searching', 'message': 'Search submitted; download confirmation is not available yet. Recheck the request shortly.'}
for attempt in range(attempts):
state = await client.get(f'/api/v3/command/{command_id}')
status = str((state or {}).get('status', '')).lower()
queue = await client.get_queue(movie_id)
records = queue.get('records', []) if isinstance(queue, dict) else queue or []
matching = [item for item in records if item.get('movieId') == movie_id]
if any(item.get('trackedDownloadStatus') in {'warning', 'error'} for item in matching):
return {'status': 'attention', 'message': 'Radarr found a download, but it reports a download or import problem. Open the pipeline details to review it.'}
if matching:
return {'status': 'downloading', 'message': 'Radarr has a download queued for this movie. The pipeline will track its progress.'}
if status in {'failed', 'aborted', 'cancelled'}:
return {'status': 'attention', 'message': 'Radarr could not complete the search. Check service health or try Search and choose a download.'}
if status == 'completed':
movie = await client.get_movie(movie_id)
if (movie or {}).get('hasFile'):
return {'status': 'complete', 'message': 'Radarr already has the movie file. Recheck the request for Jellyfin availability.'}
# Command completion precedes download-client queue refresh. Keep polling.
pass
if attempt + 1 < attempts:
await asyncio.sleep(delay)
return {'status': 'pending', 'message': 'The search was submitted, but a download is not confirmed yet. The download queue may still be updating. Close this window and recheck the request shortly.'}
async def series_search_outcome(client, series_id, commands, attempts=12, delay=2):
ids = [item.get('id') for item in commands if isinstance(item, dict) and isinstance(item.get('id'), int)]
if not ids:
return {'status': 'searching', 'message': 'Search submitted to Sonarr; no download is confirmed yet. Recheck the pipeline shortly.'}
for attempt in range(attempts):
states = await asyncio.gather(*(client.get(f'/api/v3/command/{identity}') for identity in ids))
queue = await client.get_queue(series_id)
records = queue.get('records', []) if isinstance(queue, dict) else queue or []
matching = [item for item in records if item.get('seriesId') == series_id]
if any(item.get('trackedDownloadStatus') in {'warning', 'error'} for item in matching):
return {'status': 'attention', 'message': 'Sonarr has a download with a reported problem. Review the pipeline details.'}
if matching:
return {'status': 'downloading', 'message': 'Sonarr has downloads queued for this show. The pipeline will track their progress.'}
statuses = {str((state or {}).get('status', '')).lower() for state in states}
if statuses & {'failed', 'aborted', 'cancelled'}:
return {'status': 'attention', 'message': 'A Sonarr search failed. Check the service or try Search and choose a download.'}
# Even completed commands can precede Sonarr's download queue refresh.
if attempt + 1 < attempts:
await asyncio.sleep(delay)
return {'status': 'pending', 'message': 'The search was submitted, but a download is not confirmed yet. The download queue may still be updating. Close this window and recheck the request shortly.'}
+63
View File
@@ -0,0 +1,63 @@
"""State-changing requests may originate only from explicitly configured sites.
The public Hosting & proxy URL can be stored in the database, while the CORS
environment setting still has its localhost default on an upgraded install.
Never infer a trusted origin from request Host or forwarded headers.
"""
from urllib.parse import urlsplit
from starlette.middleware.cors import CORSMiddleware
from ..config import settings
from ..installation_origin import managed_runtime
from .public_urls import magent_public_url, valid_public_url
def _origin(value: str, *, configured_url: bool = False) -> tuple[str, str, int] | None:
value = str(value or "")
if any(character.isspace() or ord(character) < 33 or ord(character) == 127 for character in value):
return None
if "?" in value or "#" in value:
return None
validated = valid_public_url(value)
if not validated:
return None
parsed = urlsplit(value)
if parsed.username is not None or parsed.password is not None:
return None
if not configured_url and parsed.path:
return None
return (
parsed.scheme.lower(),
parsed.hostname.lower(),
parsed.port or (443 if parsed.scheme == "https" else 80),
)
def is_allowed_request_origin(origin: str) -> bool:
candidate = _origin(origin)
if candidate is None:
return False
if managed_runtime():
# The operator confirms this address using the first-install token.
# No localhost fallback remains trusted after a managed installation.
return candidate == _origin(magent_public_url(), configured_url=True)
if candidate == _origin(str(settings.cors_allow_origin or "").rstrip("/")):
return True
return candidate == _origin(magent_public_url(), configured_url=True)
def can_claim_initial_origin() -> bool:
if not managed_runtime() or magent_public_url():
return False
from .setup import get_public_setup_status
return get_public_setup_status()["needs_admin"]
class ConfiguredOriginCORSMiddleware(CORSMiddleware):
"""Keep CORS response/preflight policy aligned with managed origin checks."""
def is_allowed_origin(self, origin: str) -> bool:
if managed_runtime():
return is_allowed_request_origin(origin)
return super().is_allowed_origin(origin)
+206
View File
@@ -0,0 +1,206 @@
"""Persistent, operator-authorized first-install setup.
Initialize the marker before the main schema: an existing users table identifies
an upgraded installation, while a new database must finish the setup wizard.
The marker and first administrator are protected by SQLite write transactions.
"""
from datetime import datetime, timezone
import hmac
from math import ceil
from time import time
from typing import Literal
from .. import db
from ..config import settings
from ..security import hash_password, validate_password_policy
from ..installation_origin import normalize_application_origin
SetupStep = Literal["administrator", "apps", "preferences", "review"]
SETUP_STEPS = ("administrator", "apps", "preferences", "review")
BOOTSTRAP_WINDOW_SECONDS = 15 * 60
BOOTSTRAP_IP_ATTEMPTS = 5
BOOTSTRAP_GLOBAL_ATTEMPTS = 30
class SetupUnavailableError(ValueError):
"""Setup has finished, or another administrator already exists."""
class InvalidSetupTokenError(ValueError):
"""The operator's setup token was absent or did not match."""
def initialize_setup_state() -> None:
"""Run once before init_db; subsequent calls preserve progress."""
with db._connect() as conn:
conn.execute("BEGIN IMMEDIATE")
existing_install = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'users'"
).fetchone() is not None
conn.execute(
"""CREATE TABLE IF NOT EXISTS installation_setup (
id INTEGER PRIMARY KEY CHECK (id = 1),
completed INTEGER NOT NULL CHECK (completed IN (0, 1)),
step TEXT NOT NULL,
completed_at TEXT
)"""
)
conn.execute(
"""CREATE TABLE IF NOT EXISTS installation_setup_attempts (
scope TEXT NOT NULL,
key_hash TEXT NOT NULL,
occurred_at REAL NOT NULL
)"""
)
conn.execute(
"""INSERT OR IGNORE INTO installation_setup (id, completed, step, completed_at)
VALUES (1, ?, ?, ?)""",
(
int(existing_install),
"review" if existing_install else "administrator",
datetime.now(timezone.utc).isoformat() if existing_install else None,
),
)
def get_setup_state() -> dict:
with db._connect() as conn:
# Old databases and isolated callers without startup initialization are
# already installed. A missing marker must never open public bootstrap.
table = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'installation_setup'"
).fetchone()
row = conn.execute(
"SELECT completed, step, completed_at FROM installation_setup WHERE id = 1"
).fetchone() if table else None
if row is None:
return {"completed": True, "step": "review", "completed_at": None}
return {"completed": bool(row[0]), "step": row[1], "completed_at": row[2]}
def is_setup_required() -> bool:
return not get_setup_state()["completed"]
def get_public_setup_status() -> dict:
required = is_setup_required()
return {"setup_required": required, "needs_admin": required and not db.has_admin_user()}
def setup_token_configured() -> bool:
"""Reject missing values and obvious examples, without claiming to measure entropy."""
token = str(getattr(settings, "setup_token", "") or "").strip()
placeholder = token.casefold().replace("_", "-")
return (
len(token) >= 32
and len(set(token)) > 1
and not placeholder.startswith(("replace-with-", "replace-me", "change-me", "changeme", "your-setup-token"))
)
def consume_bootstrap_attempt(client_ip: str) -> int | None:
"""Atomically reserve one attempt; return Retry-After when limited.
The IP is keyed using the existing HMAC helper, never stored in clear text.
A shared cap limits distributed attempts and expensive password hashing.
"""
now = time()
cutoff = now - BOOTSTRAP_WINDOW_SECONDS
limits = (
("setup-ip", db._rate_limit_key_hash(client_ip), BOOTSTRAP_IP_ATTEMPTS),
("setup-global", db._rate_limit_key_hash("bootstrap"), BOOTSTRAP_GLOBAL_ATTEMPTS),
)
with db._connect() as conn:
conn.execute("BEGIN IMMEDIATE")
conn.execute(
"DELETE FROM installation_setup_attempts WHERE occurred_at < ?",
(cutoff,),
)
retry_after = 0
for scope, key, maximum in limits:
count, oldest = conn.execute(
"""SELECT COUNT(*), MIN(occurred_at) FROM installation_setup_attempts
WHERE scope = ? AND key_hash = ? AND occurred_at >= ?""",
(scope, key, cutoff),
).fetchone()
if count >= maximum:
retry_after = max(retry_after, ceil(BOOTSTRAP_WINDOW_SECONDS - (now - oldest)), 1)
if retry_after:
return retry_after
conn.executemany(
"INSERT INTO installation_setup_attempts (scope, key_hash, occurred_at) VALUES (?, ?, ?)",
[(scope, key, now) for scope, key, _ in limits],
)
return None
def bootstrap_administrator(setup_token: str, username: str, password: str, *, application_url: str | None = None) -> None:
"""Claim fresh setup exactly once using the deployment's setup token."""
expected = str(getattr(settings, "setup_token", "") or "")
if not setup_token_configured() or not hmac.compare_digest(
setup_token.encode("utf-8"), expected.encode("utf-8")
):
raise InvalidSetupTokenError("Invalid setup token.")
username = username.strip()
if not username or len(username) > 100 or any(
character.isspace() or ord(character) < 32 or ord(character) == 127 for character in username
):
raise ValueError("Username must contain 1 to 100 characters without spaces or control characters.")
if len(password) > 1024:
raise ValueError("Password must contain no more than 1024 characters.")
password = validate_password_policy(password)
if application_url is not None:
application_url = normalize_application_origin(application_url)
if not is_setup_required() or db.has_admin_user():
raise SetupUnavailableError("Initial administrator setup is no longer available.")
password_hash = hash_password(password)
with db._connect() as conn:
conn.execute("BEGIN IMMEDIATE")
setup = conn.execute("SELECT completed FROM installation_setup WHERE id = 1").fetchone()
admin = conn.execute("SELECT 1 FROM users WHERE LOWER(role) = 'admin' LIMIT 1").fetchone()
if setup is None or setup[0] or admin:
raise SetupUnavailableError("Initial administrator setup is no longer available.")
if any(str(row[0]).strip().casefold() == username.casefold() for row in conn.execute("SELECT username FROM users")):
raise SetupUnavailableError("That username already exists.")
conn.execute(
"""INSERT INTO users (username, password_hash, role, auth_provider, created_at)
VALUES (?, ?, 'admin', 'local', ?)""",
(username, password_hash, datetime.now(timezone.utc).isoformat()),
)
conn.execute("UPDATE installation_setup SET step = 'apps' WHERE id = 1")
if application_url is not None:
conn.execute(
"""INSERT INTO settings (key, value, updated_at) VALUES ('magent_application_url', ?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at""",
(application_url, datetime.now(timezone.utc).isoformat()),
)
def update_setup_step(step: SetupStep) -> dict:
if step not in SETUP_STEPS:
raise ValueError("Invalid setup step.")
if not is_setup_required():
return get_setup_state()
with db._connect() as conn:
conn.execute(
"UPDATE installation_setup SET step = ? WHERE id = 1 AND completed = 0", (step,)
)
return get_setup_state()
def complete_setup() -> dict:
if not is_setup_required():
return get_setup_state()
with db._connect() as conn:
conn.execute("BEGIN IMMEDIATE")
if not conn.execute("SELECT 1 FROM users WHERE LOWER(role) = 'admin' LIMIT 1").fetchone():
raise SetupUnavailableError("Create an administrator before completing setup.")
conn.execute(
"""UPDATE installation_setup SET completed = 1, step = 'review', completed_at = ?
WHERE id = 1 AND completed = 0""",
(datetime.now(timezone.utc).isoformat(),),
)
return get_setup_state()
File diff suppressed because it is too large Load Diff
+185
View File
@@ -0,0 +1,185 @@
import json
import logging
from datetime import datetime, timezone, timedelta
from typing import Any, Dict, List, Optional
from ..db import get_setting, set_setting, delete_setting
logger = logging.getLogger(__name__)
JELLYSEERR_CACHE_KEY = "jellyseerr_users_cache"
JELLYSEERR_CACHE_AT_KEY = "jellyseerr_users_cached_at"
JELLYFIN_CACHE_KEY = "jellyfin_users_cache"
JELLYFIN_CACHE_AT_KEY = "jellyfin_users_cached_at"
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _parse_iso(value: Optional[str]) -> Optional[datetime]:
if not value:
return None
try:
parsed = datetime.fromisoformat(value)
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed
def _cache_is_fresh(cached_at: Optional[str], max_age_minutes: int) -> bool:
parsed = _parse_iso(cached_at)
if not parsed:
return False
age = datetime.now(timezone.utc) - parsed
return age <= timedelta(minutes=max_age_minutes)
def _load_cached_users(
cache_key: str, cache_at_key: str, max_age_minutes: int
) -> Optional[List[Dict[str, Any]]]:
cached_at = get_setting(cache_at_key)
if not _cache_is_fresh(cached_at, max_age_minutes):
return None
raw = get_setting(cache_key)
if not raw:
return None
try:
data = json.loads(raw)
except (TypeError, json.JSONDecodeError):
return None
if isinstance(data, list):
return [item for item in data if isinstance(item, dict)]
return None
def _save_cached_users(cache_key: str, cache_at_key: str, users: List[Dict[str, Any]]) -> None:
payload = json.dumps(users, ensure_ascii=True)
set_setting(cache_key, payload)
set_setting(cache_at_key, _now_iso())
def _normalized_handles(value: Any) -> List[str]:
if not isinstance(value, str):
return []
normalized = value.strip().lower()
if not normalized:
return []
handles = [normalized]
if "@" in normalized:
handles.append(normalized.split("@", 1)[0])
return list(dict.fromkeys(handles))
def build_jellyseerr_candidate_map(users: List[Dict[str, Any]]) -> Dict[str, int]:
candidate_to_id: Dict[str, int] = {}
for user in users:
if not isinstance(user, dict):
continue
user_id = user.get("id") or user.get("userId") or user.get("Id")
try:
user_id = int(user_id)
except (TypeError, ValueError):
continue
for key in ("username", "email", "displayName", "name"):
for handle in _normalized_handles(user.get(key)):
candidate_to_id.setdefault(handle, user_id)
return candidate_to_id
def find_matching_jellyseerr_user(
identifier: str, users: List[Dict[str, Any]]
) -> Optional[Dict[str, Any]]:
target_handles = set(_normalized_handles(identifier))
if not target_handles:
return None
for user in users:
if not isinstance(user, dict):
continue
for key in ("username", "email", "displayName", "name"):
if target_handles.intersection(_normalized_handles(user.get(key))):
return user
return None
def extract_jellyseerr_user_email(user: Optional[Dict[str, Any]]) -> Optional[str]:
if not isinstance(user, dict):
return None
value = user.get("email")
if not isinstance(value, str):
return None
candidate = value.strip()
if not candidate or "@" not in candidate:
return None
return candidate
def match_jellyseerr_user_id(
username: str, candidate_map: Dict[str, int]
) -> Optional[int]:
for handle in _normalized_handles(username):
matched = candidate_map.get(handle)
if matched is not None:
return matched
return None
def save_jellyseerr_users_cache(users: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
normalized: List[Dict[str, Any]] = []
for user in users:
if not isinstance(user, dict):
continue
normalized.append(
{
"id": user.get("id") or user.get("userId") or user.get("Id"),
"email": user.get("email"),
"username": user.get("username"),
"displayName": user.get("displayName"),
"name": user.get("name"),
}
)
_save_cached_users(JELLYSEERR_CACHE_KEY, JELLYSEERR_CACHE_AT_KEY, normalized)
logger.debug("Cached Seerr users: %s", len(normalized))
return normalized
def get_cached_jellyseerr_users(max_age_minutes: int = 1440) -> Optional[List[Dict[str, Any]]]:
return _load_cached_users(JELLYSEERR_CACHE_KEY, JELLYSEERR_CACHE_AT_KEY, max_age_minutes)
def save_jellyfin_users_cache(users: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
normalized: List[Dict[str, Any]] = []
for user in users:
if not isinstance(user, dict):
continue
normalized.append(
{
"id": user.get("Id"),
"name": user.get("Name"),
"hasPassword": user.get("HasPassword"),
"lastLoginDate": user.get("LastLoginDate"),
}
)
_save_cached_users(JELLYFIN_CACHE_KEY, JELLYFIN_CACHE_AT_KEY, normalized)
logger.debug("Cached Jellyfin users: %s", len(normalized))
return normalized
def get_cached_jellyfin_users(max_age_minutes: int = 1440) -> Optional[List[Dict[str, Any]]]:
return _load_cached_users(JELLYFIN_CACHE_KEY, JELLYFIN_CACHE_AT_KEY, max_age_minutes)
def clear_user_import_caches() -> Dict[str, int]:
cleared = 0
for key in (
JELLYSEERR_CACHE_KEY,
JELLYSEERR_CACHE_AT_KEY,
JELLYFIN_CACHE_KEY,
JELLYFIN_CACHE_AT_KEY,
):
delete_setting(key)
cleared += 1
logger.debug("Cleared user import cache keys: %s", cleared)
return {"settingsKeysCleared": cleared}