chore: standardize security and quality foundations
Magent CI/CD / verify (push) Failing after 9m34s
Magent CI/CD / deploy-beta (push) Skipped

This commit is contained in:
2026-09-17 20:03:47 +12:00
parent 5639dbcb83
commit f852e7c941
127 changed files with 17928 additions and 10741 deletions
+58
View File
@@ -0,0 +1,58 @@
"""Shared HTTP request and error contracts."""
from typing import Any, Optional
from pydantic import BaseModel, ConfigDict, Field
class StrictRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
class ErrorResponse(BaseModel):
detail: str
COMMON_ERROR_RESPONSES: dict[int, dict[str, Any]] = {
400: {"model": ErrorResponse, "description": "Invalid request"},
401: {"model": ErrorResponse, "description": "Authentication required"},
403: {"model": ErrorResponse, "description": "Permission denied"},
404: {"model": ErrorResponse, "description": "Resource not found"},
409: {"model": ErrorResponse, "description": "Request conflict"},
429: {"model": ErrorResponse, "description": "Rate limit exceeded"},
500: {"model": ErrorResponse, "description": "Unexpected server error"},
502: {"model": ErrorResponse, "description": "Upstream service error"},
503: {"model": ErrorResponse, "description": "Service unavailable"},
}
class SignupRequest(StrictRequest):
invite_code: str = Field(min_length=1, max_length=256)
username: str = Field(min_length=1, max_length=100)
password: str = Field(min_length=1, max_length=1024)
email: Optional[str] = Field(default=None, max_length=320)
class ForgotPasswordRequest(StrictRequest):
identifier: Optional[str] = Field(default=None, max_length=320)
username: Optional[str] = Field(default=None, max_length=100)
email: Optional[str] = Field(default=None, max_length=320)
class PasswordResetRequest(StrictRequest):
token: str = Field(min_length=1, max_length=512)
new_password: str = Field(min_length=1, max_length=1024)
class ProfileEmailUpdateRequest(StrictRequest):
email: Optional[str] = Field(default=None, max_length=320)
class ChangePasswordRequest(StrictRequest):
current_password: str = Field(min_length=1, max_length=1024)
new_password: str = Field(min_length=1, max_length=1024)
def request_data(payload: BaseModel | dict[str, Any]) -> dict[str, Any]:
"""Keep direct service-level tests compatible while FastAPI validates HTTP input."""
return payload if isinstance(payload, dict) else payload.model_dump()
-7
View File
@@ -1,7 +1,6 @@
import re
from typing import Any, Dict, Optional
import httpx
import time
from .base import ApiClient, _operation_error_message
from ..services.operation_progress import finish_remote_call, start_remote_call
@@ -186,7 +185,6 @@ class JellyfinClient(ApiClient):
) -> Optional[Dict[str, Any]]:
if not self.base_url or not self.api_key:
return None
started_at = time.perf_counter()
operation_event_id = start_remote_call("Jellyfin", "Checking whether the title is available in Jellyfin…")
url = f"{self.base_url}/Items"
params = {
@@ -214,7 +212,6 @@ class JellyfinClient(ApiClient):
if isinstance(item, dict) and item.get('Id'):
items[item['Id']] = item
result = {'Items': list(items.values()), 'TotalRecordCount': len(items)}
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
finish_remote_call(
operation_event_id,
success=True,
@@ -223,7 +220,6 @@ class JellyfinClient(ApiClient):
)
return result
except Exception as exc:
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
finish_remote_call(
operation_event_id,
@@ -277,7 +273,6 @@ class JellyfinClient(ApiClient):
async def refresh_library(self, recursive: bool = True) -> None:
if not self.base_url or not self.api_key:
return None
started_at = time.perf_counter()
operation_event_id = start_remote_call("Jellyfin", "Asking Jellyfin to refresh its library…")
url = f"{self.base_url}/Library/Refresh"
headers = self._emby_headers()
@@ -286,7 +281,6 @@ class JellyfinClient(ApiClient):
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, headers=headers, params=params)
response.raise_for_status()
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
finish_remote_call(
operation_event_id,
success=True,
@@ -294,7 +288,6 @@ class JellyfinClient(ApiClient):
message="Jellyfin accepted the library refresh and is scanning for new media.",
)
except Exception as exc:
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
finish_remote_call(
operation_event_id,
-10
View File
@@ -1,7 +1,6 @@
from typing import Any, Dict, Optional
import httpx
import logging
import time
from .base import ApiClient, _operation_error_message
from ..services.operation_progress import finish_remote_call, start_remote_call
@@ -89,7 +88,6 @@ class QBittorrentClient(ApiClient):
async def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
if not self.base_url:
return None
started_at = time.perf_counter()
operation_event_id = start_remote_call("qBittorrent", "Checking qBittorrent for matching downloads…")
try:
async with httpx.AsyncClient(timeout=10.0) as client:
@@ -97,7 +95,6 @@ class QBittorrentClient(ApiClient):
response = await client.get(f"{self.base_url}{path}", params=params)
response.raise_for_status()
result = response.json()
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
finish_remote_call(
operation_event_id,
success=True,
@@ -106,7 +103,6 @@ class QBittorrentClient(ApiClient):
)
return result
except Exception as exc:
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
finish_remote_call(
operation_event_id,
@@ -119,7 +115,6 @@ class QBittorrentClient(ApiClient):
async def _get_text(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[str]:
if not self.base_url:
return None
started_at = time.perf_counter()
operation_event_id = start_remote_call("qBittorrent")
try:
async with httpx.AsyncClient(timeout=10.0) as client:
@@ -127,7 +122,6 @@ class QBittorrentClient(ApiClient):
response = await client.get(f"{self.base_url}{path}", params=params)
response.raise_for_status()
result = response.text.strip()
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
finish_remote_call(
operation_event_id,
success=True,
@@ -136,7 +130,6 @@ class QBittorrentClient(ApiClient):
)
return result
except Exception as exc:
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
finish_remote_call(
operation_event_id,
@@ -149,14 +142,12 @@ class QBittorrentClient(ApiClient):
async def _post_form(self, path: str, data: Dict[str, Any]) -> None:
if not self.base_url:
return None
started_at = time.perf_counter()
operation_event_id = start_remote_call("qBittorrent")
try:
async with httpx.AsyncClient(timeout=10.0) as client:
await self._login(client)
response = await client.post(f"{self.base_url}{path}", data=data)
response.raise_for_status()
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
finish_remote_call(
operation_event_id,
success=True,
@@ -164,7 +155,6 @@ class QBittorrentClient(ApiClient):
message=_torrent_action_message(path),
)
except Exception as exc:
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
finish_remote_call(
operation_event_id,
+1
View File
@@ -67,6 +67,7 @@ class Settings(BaseSettings):
default="magent_logged_in", validation_alias=AliasChoices("AUTH_STATE_COOKIE_NAME")
)
log_level: str = Field(default="INFO", validation_alias=AliasChoices("LOG_LEVEL"))
log_format: str = Field(default="text", validation_alias=AliasChoices("LOG_FORMAT"))
log_file: str = Field(default="data/magent.log", validation_alias=AliasChoices("LOG_FILE"))
log_file_max_bytes: int = Field(
default=20_000_000, validation_alias=AliasChoices("LOG_FILE_MAX_BYTES")
+2 -155
View File
@@ -13,6 +13,7 @@ from .config import settings
from .models import Snapshot
from .security import hash_password, verify_and_update_password, verify_password
from .secret_storage import decrypt_setting_value, encrypt_setting_value, is_sensitive_setting
from .schema_migrations import run_schema_migrations
logger = logging.getLogger(__name__)
@@ -686,163 +687,9 @@ def init_db() -> None:
ON user_activity (last_seen_at)
"""
)
try:
conn.execute("ALTER TABLE users ADD COLUMN email TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN last_login_at TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN is_blocked INTEGER NOT NULL DEFAULT 0")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN auth_provider TEXT NOT NULL DEFAULT 'local'")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN jellyfin_password_hash TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN last_jellyfin_auth_at TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN jellyseerr_user_id INTEGER")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN auto_search_enabled INTEGER NOT NULL DEFAULT 1")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN invite_management_enabled INTEGER NOT NULL DEFAULT 0")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN profile_id INTEGER")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN expires_at TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN invited_by_code TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN invited_at TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN auth_version INTEGER NOT NULL DEFAULT 1")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE signup_invites ADD COLUMN recipient_email TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE signup_invites ADD COLUMN code_hint TEXT")
except sqlite3.OperationalError:
pass
run_schema_migrations(conn)
_protect_legacy_signup_invite_codes(conn)
_encrypt_legacy_sensitive_settings(conn)
try:
conn.execute("ALTER TABLE portal_items ADD COLUMN related_item_id INTEGER")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE portal_items ADD COLUMN workflow_request_status TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE portal_items ADD COLUMN workflow_media_status TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE portal_items ADD COLUMN issue_type TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE portal_items ADD COLUMN issue_resolved_at TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE portal_items ADD COLUMN metadata_json TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_portal_items_workflow
ON portal_items (kind, workflow_request_status, workflow_media_status, updated_at DESC, id DESC)
"""
)
except sqlite3.OperationalError:
pass
try:
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_portal_items_related_item
ON portal_items (related_item_id, updated_at DESC, id DESC)
"""
)
except sqlite3.OperationalError:
pass
try:
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_users_profile_id
ON users (profile_id)
"""
)
except sqlite3.OperationalError:
pass
try:
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_users_expires_at
ON users (expires_at)
"""
)
except sqlite3.OperationalError:
pass
try:
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_users_username_nocase
ON users (username COLLATE NOCASE)
"""
)
except sqlite3.OperationalError:
pass
try:
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_users_email_nocase
ON users (email COLLATE NOCASE)
"""
)
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE requests_cache ADD COLUMN requested_by_id INTEGER")
except sqlite3.OperationalError:
pass
try:
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_id
ON requests_cache (requested_by_id)
"""
)
except sqlite3.OperationalError:
pass
try:
conn.execute("PRAGMA optimize")
except sqlite3.OperationalError:
+25 -4
View File
@@ -3,6 +3,7 @@ import json
import logging
import os
import re
from datetime import datetime, timezone
from logging.handlers import RotatingFileHandler
from typing import Any, Mapping, Optional
from urllib.parse import parse_qs
@@ -39,6 +40,22 @@ class RequestContextFilter(logging.Filter):
return True
class JsonLogFormatter(logging.Formatter):
"""Stable JSON output for production log collectors."""
def format(self, record: logging.LogRecord) -> str:
payload: dict[str, Any] = {
"timestamp": datetime.fromtimestamp(record.created, timezone.utc).isoformat(),
"level": record.levelname,
"logger": record.name,
"request_id": getattr(record, "request_id", "-"),
"message": record.getMessage(),
}
if record.exc_info:
payload["exception"] = self.formatException(record.exc_info)
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
def bind_request_id(request_id: str) -> contextvars.Token[str]:
return REQUEST_ID_CONTEXT.set(request_id or "-")
@@ -150,6 +167,7 @@ def configure_logging(
log_file_backup_count: int = 10,
log_http_client_level: Optional[str] = "INFO",
log_background_sync_level: Optional[str] = "INFO",
log_format: Optional[str] = "text",
) -> None:
level_name = (log_level or "INFO").upper()
level = getattr(logging, level_name, logging.INFO)
@@ -176,10 +194,13 @@ def configure_logging(
handlers.append(file_handler)
context_filter = RequestContextFilter()
formatter = logging.Formatter(
fmt="%(asctime)s | %(levelname)s | %(name)s | request_id=%(request_id)s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
if str(log_format or "text").strip().lower() == "json":
formatter: logging.Formatter = JsonLogFormatter()
else:
formatter = logging.Formatter(
fmt="%(asctime)s | %(levelname)s | %(name)s | request_id=%(request_id)s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
for handler in handlers:
handler.addFilter(context_filter)
handler.setFormatter(formatter)
+2
View File
@@ -259,6 +259,7 @@ async def startup() -> None:
log_file_backup_count=settings.log_file_backup_count,
log_http_client_level=settings.log_http_client_level,
log_background_sync_level=settings.log_background_sync_level,
log_format=settings.log_format,
)
logger.info("startup begin app=%s build=%s", settings.app_name, settings.site_build_number)
_log_security_configuration_warnings()
@@ -273,6 +274,7 @@ async def startup() -> None:
log_file_backup_count=runtime.log_file_backup_count,
log_http_client_level=runtime.log_http_client_level,
log_background_sync_level=runtime.log_background_sync_level,
log_format=runtime.log_format,
)
logger.info(
"runtime settings applied log_level=%s log_file=%s log_file_max_bytes=%s log_file_backup_count=%s log_http_client_level=%s log_background_sync_level=%s request_source=%s",
+11 -9
View File
@@ -21,6 +21,7 @@ from ..auth import (
resolve_user_auth_provider,
)
from ..config import normalize_banner_color, settings as env_settings
from ..api_models import COMMON_ERROR_RESPONSES
from ..network_security import validate_notification_target_url
from ..db import (
delete_setting,
@@ -35,8 +36,6 @@ from ..db import (
get_user_by_id,
get_user_by_username,
get_user_request_stats,
create_user_if_missing,
set_user_jellyseerr_id,
set_setting,
set_user_blocked,
delete_user_data_by_username,
@@ -59,7 +58,6 @@ from ..db import (
cleanup_history,
update_request_cache_title,
repair_request_cache_titles,
delete_non_admin_users,
list_user_profiles,
get_user_profile,
create_user_profile,
@@ -73,6 +71,7 @@ from ..db import (
delete_signup_invite,
get_signup_invite_by_code,
disable_signup_invites_by_creator,
delete_non_admin_users, # noqa: F401 - retained for compatibility with maintenance tooling/tests
)
from ..runtime import get_runtime_settings
from ..clients.sonarr import SonarrClient
@@ -81,12 +80,8 @@ from ..clients.jellyfin import JellyfinClient
from ..clients.jellyseerr import JellyseerrClient
from ..services.jellyfin_sync import sync_jellyfin_users
from ..services.user_cache import (
build_jellyseerr_candidate_map,
extract_jellyseerr_user_email,
find_matching_jellyseerr_user,
get_cached_jellyfin_users,
get_cached_jellyseerr_users,
match_jellyseerr_user_id,
save_jellyfin_users_cache,
save_jellyseerr_users_cache,
clear_user_import_caches,
@@ -109,7 +104,12 @@ from ..logging_config import configure_logging
from ..routers import requests as requests_router
from ..routers.branding import save_branding_image
router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(require_admin)])
router = APIRouter(
prefix="/admin",
tags=["admin"],
dependencies=[Depends(require_admin)],
responses=COMMON_ERROR_RESPONSES,
)
events_router = APIRouter(prefix="/admin/events", tags=["admin"])
logger = logging.getLogger(__name__)
SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id"
@@ -247,6 +247,7 @@ SETTING_KEYS: List[str] = [
"qbittorrent_username",
"qbittorrent_password",
"log_level",
"log_format",
"log_file",
"log_file_max_bytes",
"log_file_backup_count",
@@ -741,7 +742,7 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
set_setting(key, value_to_store)
updates += 1
changed_keys.append(key)
if key in {"log_level", "log_file", "log_file_max_bytes", "log_file_backup_count", "log_http_client_level", "log_background_sync_level"}:
if key in {"log_level", "log_format", "log_file", "log_file_max_bytes", "log_file_backup_count", "log_http_client_level", "log_background_sync_level"}:
touched_logging = True
if touched_logging:
runtime = get_runtime_settings()
@@ -752,6 +753,7 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
log_file_backup_count=runtime.log_file_backup_count,
log_http_client_level=runtime.log_http_client_level,
log_background_sync_level=runtime.log_background_sync_level,
log_format=runtime.log_format,
)
logger.info("Admin updated settings: count=%s keys=%s", updates, changed_keys)
return {"status": "ok", "updated": updates}
+24 -6
View File
@@ -60,6 +60,15 @@ from ..auth import (
set_auth_cookies,
)
from ..config import settings
from ..api_models import (
COMMON_ERROR_RESPONSES,
ChangePasswordRequest,
ForgotPasswordRequest,
PasswordResetRequest,
ProfileEmailUpdateRequest,
SignupRequest,
request_data,
)
from ..network_security import request_trusts_forwarded_headers
from ..services.user_cache import (
build_jellyseerr_candidate_map,
@@ -81,7 +90,7 @@ from ..services.password_reset import (
verify_password_reset_token,
)
router = APIRouter(prefix="/auth", tags=["auth"])
router = APIRouter(prefix="/auth", tags=["auth"], responses=COMMON_ERROR_RESPONSES)
logger = logging.getLogger(__name__)
SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id"
STREAM_TOKEN_TTL_SECONDS = 120
@@ -869,7 +878,8 @@ async def invite_details(code: str) -> dict:
@router.post("/signup")
async def signup(payload: dict, response: Response) -> dict:
async def signup(payload: SignupRequest, response: Response) -> dict:
payload = request_data(payload)
if not isinstance(payload, dict):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
invite_code = str(payload.get("invite_code") or "").strip()
@@ -1054,7 +1064,8 @@ async def signup(payload: dict, response: Response) -> dict:
@router.post("/password/forgot")
async def forgot_password(payload: dict, request: Request) -> dict:
async def forgot_password(payload: ForgotPasswordRequest, request: Request) -> dict:
payload = request_data(payload)
if not isinstance(payload, dict):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
identifier = payload.get("identifier") or payload.get("username") or payload.get("email")
@@ -1106,7 +1117,8 @@ async def password_reset_verify(token: str) -> dict:
@router.post("/password/reset")
async def password_reset(payload: dict) -> dict:
async def password_reset(payload: PasswordResetRequest) -> dict:
payload = request_data(payload)
if not isinstance(payload, dict):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
token = payload.get("token")
@@ -1169,7 +1181,10 @@ async def profile(current_user: dict = Depends(get_current_user)) -> dict:
@router.put("/profile/email")
async def update_profile_email(payload: dict, current_user: dict = Depends(get_current_user)) -> dict:
async def update_profile_email(
payload: ProfileEmailUpdateRequest, current_user: dict = Depends(get_current_user)
) -> dict:
payload = request_data(payload)
if not isinstance(payload, dict):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
username = str(current_user.get("username") or "").strip()
@@ -1435,7 +1450,10 @@ async def delete_profile_invite(invite_id: int, current_user: dict = Depends(get
@router.post("/password")
async def change_password(payload: dict, current_user: dict = Depends(get_current_user)) -> dict:
async def change_password(
payload: ChangePasswordRequest, current_user: dict = Depends(get_current_user)
) -> dict:
payload = request_data(payload)
current_password = payload.get("current_password") if isinstance(payload, dict) else None
new_password = payload.get("new_password") if isinstance(payload, dict) else None
if not isinstance(current_password, str) or not isinstance(new_password, str):
+1 -1
View File
@@ -3,7 +3,7 @@ import warnings
from io import BytesIO
from typing import Any, Dict
from fastapi import APIRouter, HTTPException, UploadFile, File
from fastapi import APIRouter, HTTPException, UploadFile
from fastapi.responses import FileResponse
from PIL import Image, ImageDraw, ImageFont
+1 -1
View File
@@ -3,7 +3,7 @@ import re
import mimetypes
import logging
from typing import Optional
from fastapi import APIRouter, HTTPException, Response
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse, RedirectResponse
import httpx
+7 -1
View File
@@ -11,6 +11,7 @@ import httpx
from fastapi import APIRouter, Depends, HTTPException, Query
from ..auth import get_current_user
from ..api_models import COMMON_ERROR_RESPONSES
from ..clients.jellyfin import JellyfinClient
from ..db import (
add_portal_item_activity,
@@ -34,7 +35,12 @@ from ..services.issue_resolution import (
from ..services.notifications import send_portal_notification
from ..runtime import get_runtime_settings
router = APIRouter(prefix="/portal", tags=["portal"], dependencies=[Depends(get_current_user), Depends(require_portal_access)])
router = APIRouter(
prefix="/portal",
tags=["portal"],
dependencies=[Depends(get_current_user), Depends(require_portal_access)],
responses=COMMON_ERROR_RESPONSES,
)
logger = logging.getLogger(__name__)
PORTAL_KINDS = {"request", "issue", "feature"}
+1 -1
View File
@@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Response
from pydantic import BaseModel, ConfigDict, Field, field_validator
from ..services.public_urls import magent_public_url
from ..auth import get_current_user, require_admin
from ..auth import require_admin
from ..feature_guards import require_stats
from ..services import email_recaps as recaps, recap_store as store
+13 -17
View File
@@ -20,6 +20,7 @@ from ..clients.sonarr import SonarrClient
from ..clients.bazarr import BazarrClient
from ..ai.triage import triage_snapshot
from ..auth import get_current_user
from ..api_models import COMMON_ERROR_RESPONSES
from ..runtime import get_runtime_settings
from .images import cache_tmdb_image, is_tmdb_cached
from ..db import (
@@ -30,7 +31,6 @@ from ..db import (
save_action,
get_recent_actions,
get_recent_snapshots,
get_cached_requests,
get_cached_requests_since,
get_cached_request_by_media_id,
get_request_cache_lookup,
@@ -62,6 +62,7 @@ from ..db import (
)
from ..services.media_repair import current_cycle_torrents
from ..services.download_labels import label_episode_downloads
from ..services.arr import RootFolderNotFoundError, resolve_root_folder_path
from ..models import Snapshot, TriageResult, RequestType
from ..services.snapshot import (
_summarize_qbit,
@@ -70,7 +71,12 @@ from ..services.snapshot import (
jellyfin_item_matches_request,
)
router = APIRouter(prefix="/requests", tags=["requests"], dependencies=[Depends(get_current_user), Depends(require_request_access)])
router = APIRouter(
prefix="/requests",
tags=["requests"],
dependencies=[Depends(get_current_user), Depends(require_request_access)],
responses=COMMON_ERROR_RESPONSES,
)
CACHE_TTL_SECONDS = 600
_detail_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
@@ -1753,7 +1759,6 @@ def _filter_arr_release_results(results: Any, include_rejected: bool = False) ->
"approved": accepted,
"rejected": item.get("rejected"),
"temporarilyRejected": item.get("temporarilyRejected"),
"rejections": item.get("rejections"),
"downloadAllowed": item.get("downloadAllowed"),
"fullSeason": item.get("fullSeason"),
"seasonNumber": item.get("seasonNumber"),
@@ -1971,16 +1976,10 @@ def _issue_season_payloads(episodes: List[Dict[str, Any]]) -> List[Dict[str, Any
async def _resolve_root_folder_path(client: Any, root_folder: str, service_name: str) -> str:
if root_folder.isdigit():
folders = await client.get_root_folders()
if isinstance(folders, list):
for folder in folders:
if folder.get("id") == int(root_folder):
path = folder.get("path")
if isinstance(path, str) and path:
return path
raise HTTPException(status_code=400, detail=f"{service_name} root folder id {root_folder} not found")
return root_folder
try:
return await resolve_root_folder_path(client, root_folder, service_name)
except RootFolderNotFoundError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/{request_id}/issue-options")
@@ -2979,7 +2978,6 @@ async def recent_requests(
) -> dict:
runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
mode = (runtime.requests_data_source or "prefer_cache").lower()
# Browsing is always local. Synchronization is owned by background workers.
allow_remote = False
username_norm = _normalize_username(user.get("username", ""))
@@ -3007,8 +3005,6 @@ async def recent_requests(
allow_title_hydrate = False
allow_artwork_hydrate = False
stage_cache = await asyncio.to_thread(get_request_stage_cache)
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
jellyfin_cache: Dict[str, bool] = {}
results = []
for row in rows:
status = row.get("status")
@@ -3946,7 +3942,7 @@ async def action_grab(
release_title = receipt.get('title')
arr_error: Optional[str] = None
try:
response = await arr_client.grab_release(str(guid), arr_indexer_id)
await arr_client.grab_release(str(guid), arr_indexer_id)
action_message = (
f"{release_title or 'Selected release'} was sent through {service_label} for download and import."
+ (' Profile limits explicitly overridden: ' + '; '.join(receipt['rejections']) if receipt['override'] else '')
+116
View File
@@ -0,0 +1,116 @@
"""Transactional, versioned SQLite schema migrations for Magent."""
from __future__ import annotations
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Callable
MigrationStep = Callable[[sqlite3.Connection], None]
@dataclass(frozen=True)
class Migration:
version: int
name: str
apply: MigrationStep
def _column_names(conn: sqlite3.Connection, table: str) -> set[str]:
return {str(row[1]) for row in conn.execute(f'PRAGMA table_info("{table}")').fetchall()}
def _add_column(conn: sqlite3.Connection, table: str, definition: str) -> None:
column = definition.split(maxsplit=1)[0].strip('"')
if column not in _column_names(conn, table):
conn.execute(f'ALTER TABLE "{table}" ADD COLUMN {definition}')
def _migration_001_legacy_columns_and_indexes(conn: sqlite3.Connection) -> None:
for definition in (
"email TEXT",
"last_login_at TEXT",
"is_blocked INTEGER NOT NULL DEFAULT 0",
"auth_provider TEXT NOT NULL DEFAULT 'local'",
"jellyfin_password_hash TEXT",
"last_jellyfin_auth_at TEXT",
"jellyseerr_user_id INTEGER",
"auto_search_enabled INTEGER NOT NULL DEFAULT 1",
"invite_management_enabled INTEGER NOT NULL DEFAULT 0",
"profile_id INTEGER",
"expires_at TEXT",
"invited_by_code TEXT",
"invited_at TEXT",
"auth_version INTEGER NOT NULL DEFAULT 1",
):
_add_column(conn, "users", definition)
for definition in ("recipient_email TEXT", "code_hint TEXT"):
_add_column(conn, "signup_invites", definition)
for definition in (
"related_item_id INTEGER",
"workflow_request_status TEXT",
"workflow_media_status TEXT",
"issue_type TEXT",
"issue_resolved_at TEXT",
"metadata_json TEXT",
):
_add_column(conn, "portal_items", definition)
_add_column(conn, "requests_cache", "requested_by_id INTEGER")
statements = (
"CREATE INDEX IF NOT EXISTS idx_portal_items_workflow ON portal_items "
"(kind, workflow_request_status, workflow_media_status, updated_at DESC, id DESC)",
"CREATE INDEX IF NOT EXISTS idx_portal_items_related_item ON portal_items "
"(related_item_id, updated_at DESC, id DESC)",
"CREATE INDEX IF NOT EXISTS idx_users_profile_id ON users (profile_id)",
"CREATE INDEX IF NOT EXISTS idx_users_expires_at ON users (expires_at)",
"CREATE INDEX IF NOT EXISTS idx_users_username_nocase ON users (username COLLATE NOCASE)",
"CREATE INDEX IF NOT EXISTS idx_users_email_nocase ON users (email COLLATE NOCASE)",
"CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_id ON requests_cache (requested_by_id)",
"CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_id_created_at ON requests_cache "
"(requested_by_id, created_at DESC, request_id DESC)",
)
for statement in statements:
conn.execute(statement)
MIGRATIONS = (
Migration(1, "legacy_columns_and_indexes", _migration_001_legacy_columns_and_indexes),
)
def run_schema_migrations(conn: sqlite3.Connection) -> list[int]:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
applied_at TEXT NOT NULL
)
"""
)
applied = {int(row[0]) for row in conn.execute("SELECT version FROM schema_migrations")}
completed: list[int] = []
for migration in MIGRATIONS:
if migration.version in applied:
continue
savepoint = f"magent_migration_{migration.version}"
conn.execute(f"SAVEPOINT {savepoint}")
try:
migration.apply(conn)
conn.execute(
"INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)",
(migration.version, migration.name, datetime.now(timezone.utc).isoformat()),
)
conn.execute(f"RELEASE SAVEPOINT {savepoint}")
except Exception:
conn.execute(f"ROLLBACK TO SAVEPOINT {savepoint}")
conn.execute(f"RELEASE SAVEPOINT {savepoint}")
raise
completed.append(migration.version)
return completed
+21
View File
@@ -0,0 +1,21 @@
"""Shared Sonarr/Radarr configuration helpers."""
from typing import Any
class RootFolderNotFoundError(ValueError):
pass
async def resolve_root_folder_path(client: Any, root_folder: str, service_name: str) -> str:
configured = str(root_folder or "").strip()
if not configured.isdigit():
return configured
folders = await client.get_root_folders()
if isinstance(folders, list):
for folder in folders:
if isinstance(folder, dict) and folder.get("id") == int(configured):
path = str(folder.get("path") or "").strip()
if path:
return path
raise RootFolderNotFoundError(f"{service_name} root folder id {configured} not found")
+1 -1
View File
@@ -1,4 +1,3 @@
from .public_urls import magent_public_url
"""Independent newsletter consent and immutable edition snapshots using the shared email queue."""
import hashlib
@@ -11,6 +10,7 @@ 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):
+1 -1
View File
@@ -1,4 +1,3 @@
from .public_urls import magent_public_url
"""Durable consent, schedule and delivery records for personal email recaps."""
import hashlib
@@ -11,6 +10,7 @@ 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:
+17 -13
View File
@@ -32,6 +32,7 @@ from ..models import ActionOption, NormalizedState, RequestType, Snapshot, Timel
from .collector_search import read_search_status
from .media_repair import current_cycle_torrents, evaluate_media_repair
from .download_labels import label_episode_downloads
from .arr import RootFolderNotFoundError, resolve_root_folder_path
logger = logging.getLogger(__name__)
@@ -1234,11 +1235,6 @@ async def build_snapshot(request_id: str) -> Snapshot:
arr_item = None
arr_queue = None
episodes = None
media_status = jelly_request.get("media", {}).get("status")
try:
media_status_code = int(media_status) if media_status is not None else None
except (TypeError, ValueError):
media_status_code = None
if snapshot.request_type == RequestType.tv:
tvdb_id = jelly_request.get("media", {}).get("tvdbId")
if tvdb_id:
@@ -1390,11 +1386,15 @@ async def build_snapshot(request_id: str) -> Snapshot:
if runtime.radarr_quality_profile_id and runtime.radarr_root_folder:
radarr_client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
if radarr_client.configured():
root_folder = await _resolve_root_folder_path(
radarr_client, runtime.radarr_root_folder, "Radarr"
)
try:
root_folder = await resolve_root_folder_path(
radarr_client, runtime.radarr_root_folder, "Radarr"
)
except RootFolderNotFoundError as exc:
logger.warning("Skipping Jellyfin-to-Radarr sync: %s", exc)
root_folder = ""
tmdb_id = jelly_request.get("media", {}).get("tmdbId")
if tmdb_id:
if tmdb_id and root_folder:
try:
await radarr_client.add_movie(
int(tmdb_id),
@@ -1409,11 +1409,15 @@ async def build_snapshot(request_id: str) -> Snapshot:
if runtime.sonarr_quality_profile_id and runtime.sonarr_root_folder:
sonarr_client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
if sonarr_client.configured():
root_folder = await _resolve_root_folder_path(
sonarr_client, runtime.sonarr_root_folder, "Sonarr"
)
try:
root_folder = await resolve_root_folder_path(
sonarr_client, runtime.sonarr_root_folder, "Sonarr"
)
except RootFolderNotFoundError as exc:
logger.warning("Skipping Jellyfin-to-Sonarr sync: %s", exc)
root_folder = ""
tvdb_id = jelly_request.get("media", {}).get("tvdbId")
if tvdb_id:
if tvdb_id and root_folder:
try:
await sonarr_client.add_series(
int(tvdb_id),