feat(release): publish minimal self-contained Magent source
This commit is contained in:
@@ -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")
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"]))
|
||||
@@ -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.'}
|
||||
@@ -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.')}
|
||||
@@ -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)}
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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."}
|
||||
@@ -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()
|
||||
@@ -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 recipient’s 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 recipient’s 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 recipient’s 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}
|
||||
@@ -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 ↗</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='What’s 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}
|
||||
@@ -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"What’s 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}
|
||||
@@ -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"What’s 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)
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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.")
|
||||
@@ -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)
|
||||
@@ -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)} ↗</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"> </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}, here’s 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}>"
|
||||
@@ -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,))]
|
||||
@@ -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.'}
|
||||
@@ -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)
|
||||
@@ -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
@@ -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}
|
||||
Reference in New Issue
Block a user