Process 1 build 0203261953

This commit is contained in:
2026-03-02 19:54:14 +13:00
parent b0ef455498
commit 9c69d9fd17
22 changed files with 672 additions and 279 deletions

View File

@@ -4,8 +4,8 @@ from typing import Dict, Any, Optional
from fastapi import Depends, HTTPException, status, Request
from fastapi.security import OAuth2PasswordBearer
from .db import get_user_by_username, upsert_user_activity
from .security import safe_decode_token, TokenError
from .db import get_user_by_username, set_user_auth_provider, upsert_user_activity
from .security import safe_decode_token, TokenError, verify_password
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
@@ -38,6 +38,42 @@ def _extract_client_ip(request: Request) -> str:
return "unknown"
def resolve_user_auth_provider(user: Optional[Dict[str, Any]]) -> str:
if not isinstance(user, dict):
return "local"
provider = str(user.get("auth_provider") or "local").strip().lower() or "local"
if provider != "local":
return provider
password_hash = user.get("password_hash")
if isinstance(password_hash, str) and password_hash:
if verify_password("jellyfin-user", password_hash):
return "jellyfin"
if verify_password("jellyseerr-user", password_hash):
return "jellyseerr"
return provider
def normalize_user_auth_provider(user: Optional[Dict[str, Any]]) -> Dict[str, Any]:
if not isinstance(user, dict):
return {}
resolved_provider = resolve_user_auth_provider(user)
stored_provider = str(user.get("auth_provider") or "local").strip().lower() or "local"
if resolved_provider != stored_provider:
username = str(user.get("username") or "").strip()
if username:
set_user_auth_provider(username, resolved_provider)
refreshed_user = get_user_by_username(username)
if refreshed_user:
user = refreshed_user
normalized = dict(user)
normalized["auth_provider"] = resolved_provider
normalized["password_change_supported"] = resolved_provider in {"local", "jellyfin"}
normalized["password_provider"] = (
resolved_provider if resolved_provider in {"local", "jellyfin"} else None
)
return normalized
def _load_current_user_from_token(
token: str,
request: Optional[Request] = None,
@@ -63,6 +99,8 @@ def _load_current_user_from_token(
if _is_expired(user.get("expires_at")):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired")
user = normalize_user_auth_provider(user)
if request is not None:
ip = _extract_client_ip(request)
user_agent = request.headers.get("user-agent", "unknown")
@@ -78,6 +116,8 @@ def _load_current_user_from_token(
"profile_id": user.get("profile_id"),
"expires_at": user.get("expires_at"),
"is_expired": bool(user.get("is_expired", False)),
"password_change_supported": bool(user.get("password_change_supported", False)),
"password_provider": user.get("password_provider"),
}

View File

@@ -1,8 +1,2 @@
BUILD_NUMBER = "0203261610"
CHANGELOG = '2026-01-22\\n- Initial commit\\n- Ignore build artifacts\\n- Update README\\n- Update README with Docker-first guide\\n\\n2026-01-23\\n- Fix cache titles via Seerr media lookup\\n- Split search actions and improve download options\\n- Fallback manual grab to qBittorrent\\n- Hide header actions when signed out\\n- Add feedback form and webhook\\n- Fix cache titles and move feedback link\\n- Show available status on landing when in Jellyfin\\n- Add default branding assets when missing\\n- Use bundled branding assets\\n- Remove password fields from users page\\n- Add Docker Hub compose override\\n- Fix backend Dockerfile paths for root context\\n- Copy public assets into frontend image\\n- Use backend branding assets for logo and favicon\\n\\n2026-01-24\\n- Route grabs through Sonarr/Radarr only\\n- Document fix buttons in how-it-works\\n- Clarify how-it-works steps and fixes\\n- Map Prowlarr releases to Arr indexers for manual grab\\n- Improve request handling and qBittorrent categories\\n\\n2026-01-25\\n- Add site banner, build number, and changelog\\n- Automate build number tagging and sync\\n- Improve mobile header layout\\n- Move account actions into avatar menu\\n- Add user stats and activity tracking\\n- Add Jellyfin login cache and admin-only stats\\n- Tidy request sync controls\\n- Seed branding logo from bundled assets\\n- Serve bundled branding assets by default\\n- Harden request cache titles and cache-only reads\\n- Build 2501262041\\n\\n2026-01-26\\n- Fix cache title hydration\\n- Fix sync progress bar animation\\n\\n2026-01-27\\n- Add cache control artwork stats\\n- Improve cache stats performance (build 271261145)\\n- Fix backend cache stats import (build 271261149)\\n- Clarify request sync settings (build 271261159)\\n- Bump build number to 271261202\\n- Fix request titles in snapshots (build 271261219)\\n- Fix snapshot title fallback (build 271261228)\\n- Add cache load spinner (build 271261238)\\n- Bump build number (process 2) 271261322\\n- Add service test buttons (build 271261335)\\n- Fallback to TMDB when artwork cache fails (build 271261524)\\n- Hydrate missing artwork from Seerr (build 271261539)\\n\\n2026-01-29\\n- release: 2901262036\\n- release: 2901262044\\n- release: 2901262102\\n- Hardcode build number in backend\\n- Bake build number and changelog\\n- Update full changelog\\n- Tidy full changelog\\n- Build 2901262240: cache users\n\n2026-01-30\n- Merge backend and frontend into one container'
BUILD_NUMBER = "0203261953"
CHANGELOG = '2026-03-02|Process 1 build 0203261610\n2026-03-02|Process 1 build 0203261608\n2026-03-02|Add dedicated profile invites page and fix mobile admin layout\n2026-03-01|Persist Seerr media failure suppression and reduce sync error noise\n2026-03-01|Add repository line ending policy\n2026-03-01|Finalize diagnostics, logging controls, and email test support\n2026-03-01|Add invite email templates and delivery workflow\n2026-02-28|Finalize dev-1.3 upgrades and Seerr updates\n2026-02-27|admin docs and layout refresh, build 2702261314\n2026-02-27|Build 2702261153: fix jellyfin sync user visibility\n2026-02-26|Build 2602262241: live request page updates\n2026-02-26|Build 2602262204\n2026-02-26|Build 2602262159: restore jellyfin-first user source\n2026-02-26|Build 2602262049: split magent settings and harden local login\n2026-02-26|Build 2602262030: add magent settings and hardening\n2026-02-26|Build 2602261731: fix user resync after nuclear wipe\n2026-02-26|Build 2602261717: master invite policy and self-service invite controls\n2026-02-26|Build 2602261636: self-service invites and count fixes\n2026-02-26|Build 2602261605: invite trace and cross-system user lifecycle\n2026-02-26|Build 2602261536: refine invite layouts and tighten UI\n2026-02-26|Build 2602261523: live updates, invite cleanup and nuclear resync\n2026-02-26|Build 2602261442: tidy users and invite layouts\n2026-02-26|Build 2602261409: unify invite management controls\n2026-02-26|Build 2602260214: invites profiles and expiry admin controls\n2026-02-26|Build 2602260022: enterprise UI refresh and users bulk auto-search\n2026-02-25|Build 2502262321: fix auto-search quality and per-user toggle\n2026-02-02|Build 0202261541: allow FQDN service URLs\n2026-01-30|Build 3001262148: single container\n2026-01-29|Build 2901262244: format changelog\n2026-01-29|Build 2901262240: cache users\n2026-01-29|Tidy full changelog\n2026-01-29|Update full changelog\n2026-01-29|Bake build number and changelog\n2026-01-29|Hardcode build number in backend\n2026-01-29|release: 2901262102\n2026-01-29|release: 2901262044\n2026-01-29|release: 2901262036\n2026-01-27|Hydrate missing artwork from Jellyseerr (build 271261539)\n2026-01-27|Fallback to TMDB when artwork cache fails (build 271261524)\n2026-01-27|Add service test buttons (build 271261335)\n2026-01-27|Bump build number (process 2) 271261322\n2026-01-27|Add cache load spinner (build 271261238)\n2026-01-27|Fix snapshot title fallback (build 271261228)\n2026-01-27|Fix request titles in snapshots (build 271261219)\n2026-01-27|Bump build number to 271261202\n2026-01-27|Clarify request sync settings (build 271261159)\n2026-01-27|Fix backend cache stats import (build 271261149)\n2026-01-27|Improve cache stats performance (build 271261145)\n2026-01-27|Add cache control artwork stats\n2026-01-26|Fix sync progress bar animation\n2026-01-26|Fix cache title hydration\n2026-01-25|Build 2501262041\n2026-01-25|Harden request cache titles and cache-only reads\n2026-01-25|Serve bundled branding assets by default\n2026-01-25|Seed branding logo from bundled assets\n2026-01-25|Tidy request sync controls\n2026-01-25|Add Jellyfin login cache and admin-only stats\n2026-01-25|Add user stats and activity tracking\n2026-01-25|Move account actions into avatar menu\n2026-01-25|Improve mobile header layout\n2026-01-25|Automate build number tagging and sync\n2026-01-25|Add site banner, build number, and changelog\n2026-01-24|Improve request handling and qBittorrent categories\n2026-01-24|Map Prowlarr releases to Arr indexers for manual grab\n2026-01-24|Clarify how-it-works steps and fixes\n2026-01-24|Document fix buttons in how-it-works\n2026-01-24|Route grabs through Sonarr/Radarr only\n2026-01-23|Use backend branding assets for logo and favicon\n2026-01-23|Copy public assets into frontend image\n2026-01-23|Fix backend Dockerfile paths for root context\n2026-01-23|Add Docker Hub compose override\n2026-01-23|Remove password fields from users page\n2026-01-23|Use bundled branding assets\n2026-01-23|Add default branding assets when missing\n2026-01-23|Show available status on landing when in Jellyfin\n2026-01-23|Fix cache titles and move feedback link\n2026-01-23|Add feedback form and webhook\n2026-01-23|Hide header actions when signed out\n2026-01-23|Fallback manual grab to qBittorrent\n2026-01-23|Split search actions and improve download options\n2026-01-23|Fix cache titles via Jellyseerr media lookup\n2026-01-22|Update README with Docker-first guide\n2026-01-22|Update README\n2026-01-22|Ignore build artifacts\n2026-01-22|Initial commit'

View File

@@ -1398,6 +1398,24 @@ def set_user_password(username: str, password: str) -> None:
)
def sync_jellyfin_password_state(username: str, password: str) -> None:
if not username or not password:
return
password_hash = hash_password(password)
timestamp = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
conn.execute(
"""
UPDATE users
SET password_hash = ?,
jellyfin_password_hash = ?,
last_jellyfin_auth_at = ?
WHERE username = ? COLLATE NOCASE
""",
(password_hash, password_hash, timestamp, username),
)
def set_jellyfin_auth_cache(username: str, password: str) -> None:
if not username or not password:
return

View File

@@ -12,7 +12,13 @@ from urllib.parse import urlparse, urlunparse
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, Request
from fastapi.responses import StreamingResponse
from ..auth import require_admin, get_current_user, require_admin_event_stream
from ..auth import (
require_admin,
get_current_user,
require_admin_event_stream,
normalize_user_auth_provider,
resolve_user_auth_provider,
)
from ..config import settings as env_settings
from ..db import (
delete_setting,
@@ -40,7 +46,7 @@ from ..db import (
set_user_profile_id,
set_user_expires_at,
set_user_password,
set_jellyfin_auth_cache,
sync_jellyfin_password_state,
set_user_role,
run_integrity_check,
vacuum_db,
@@ -85,6 +91,7 @@ from ..services.invite_email import (
reset_invite_email_template,
save_invite_email_template,
send_test_email,
smtp_email_delivery_warning,
send_templated_email,
smtp_email_config_ready,
)
@@ -1451,7 +1458,8 @@ async def update_user_password(username: str, payload: Dict[str, Any]) -> Dict[s
if not user:
raise HTTPException(status_code=404, detail="User not found")
new_password_clean = new_password.strip()
auth_provider = str(user.get("auth_provider") or "local").lower()
user = normalize_user_auth_provider(user)
auth_provider = resolve_user_auth_provider(user)
if auth_provider == "local":
set_user_password(username, new_password_clean)
return {"status": "ok", "username": username, "provider": "local"}
@@ -1468,7 +1476,7 @@ async def update_user_password(username: str, payload: Dict[str, Any]) -> Dict[s
await client.set_user_password(user_id, new_password_clean)
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Jellyfin password update failed: {exc}") from exc
set_jellyfin_auth_cache(username, new_password_clean)
sync_jellyfin_password_state(username, new_password_clean)
return {"status": "ok", "username": username, "provider": "jellyfin"}
raise HTTPException(
status_code=400,
@@ -1691,11 +1699,12 @@ async def update_invite_policy(payload: Dict[str, Any]) -> Dict[str, Any]:
@router.get("/invites/email/templates")
async def get_invite_email_template_settings() -> Dict[str, Any]:
ready, detail = smtp_email_config_ready()
warning = smtp_email_delivery_warning()
return {
"status": "ok",
"email": {
"configured": ready,
"detail": detail,
"detail": warning or detail,
},
"templates": list(get_invite_email_templates().values()),
}

View File

@@ -18,7 +18,6 @@ from ..db import (
get_user_by_username,
get_users_by_username_ci,
set_user_password,
set_jellyfin_auth_cache,
set_user_jellyseerr_id,
set_user_auth_provider,
get_signup_invite_by_code,
@@ -35,13 +34,14 @@ from ..db import (
get_global_request_leader,
get_global_request_total,
get_setting,
sync_jellyfin_password_state,
)
from ..runtime import get_runtime_settings
from ..clients.jellyfin import JellyfinClient
from ..clients.jellyseerr import JellyseerrClient
from ..security import create_access_token, verify_password
from ..security import create_stream_token
from ..auth import get_current_user
from ..auth import get_current_user, normalize_user_auth_provider, resolve_user_auth_provider
from ..config import settings
from ..services.user_cache import (
build_jellyseerr_candidate_map,
@@ -599,7 +599,7 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm
save_jellyfin_users_cache(users)
except Exception:
pass
set_jellyfin_auth_cache(canonical_username, password)
sync_jellyfin_password_state(canonical_username, password)
if user and user.get("jellyseerr_user_id") is None and candidate_map:
matched_id = match_jellyseerr_user_id(canonical_username, candidate_map)
if matched_id is not None:
@@ -781,7 +781,7 @@ async def signup(payload: dict) -> dict:
if jellyfin_client.configured():
logger.info("signup provisioning jellyfin username=%s", username)
auth_provider = "jellyfin"
local_password_value = "jellyfin-user"
local_password_value = password_value
try:
await jellyfin_client.create_user_with_password(username, password_value)
except httpx.HTTPStatusError as exc:
@@ -838,7 +838,7 @@ async def signup(payload: dict) -> dict:
increment_signup_invite_use(int(invite["id"]))
created_user = get_user_by_username(username)
if auth_provider == "jellyfin":
set_jellyfin_auth_cache(username, password_value)
sync_jellyfin_password_state(username, password_value)
if (
created_user
and created_user.get("jellyseerr_user_id") is None
@@ -1129,16 +1129,20 @@ async def change_password(payload: dict, current_user: dict = Depends(get_curren
status_code=status.HTTP_400_BAD_REQUEST, detail="Password must be at least 8 characters."
)
username = str(current_user.get("username") or "").strip()
auth_provider = str(current_user.get("auth_provider") or "local").lower()
if not username:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user")
new_password_clean = new_password.strip()
stored_user = normalize_user_auth_provider(get_user_by_username(username))
auth_provider = resolve_user_auth_provider(stored_user or current_user)
logger.info("password change requested username=%s provider=%s", username, auth_provider)
if auth_provider == "local":
user = verify_user_password(username, current_password)
if not user:
logger.warning("password change rejected username=%s provider=local reason=invalid-current-password", username)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect")
set_user_password(username, new_password_clean)
logger.info("password change completed username=%s provider=local", username)
return {"status": "ok", "provider": "local"}
if auth_provider == "jellyfin":
@@ -1152,6 +1156,7 @@ async def change_password(payload: dict, current_user: dict = Depends(get_curren
try:
auth_result = await client.authenticate_by_name(username, current_password)
if not isinstance(auth_result, dict) or not auth_result.get("User"):
logger.warning("password change rejected username=%s provider=jellyfin reason=invalid-current-password", username)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect"
)
@@ -1159,6 +1164,7 @@ async def change_password(payload: dict, current_user: dict = Depends(get_curren
raise
except Exception as exc:
detail = _extract_http_error_detail(exc)
logger.warning("password change validation failed username=%s provider=jellyfin detail=%s", username, detail)
if isinstance(exc, httpx.HTTPStatusError) and exc.response is not None and exc.response.status_code in {401, 403}:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect"
@@ -1176,13 +1182,15 @@ async def change_password(payload: dict, current_user: dict = Depends(get_curren
await client.set_user_password(user_id, new_password_clean)
except Exception as exc:
detail = _extract_http_error_detail(exc)
logger.warning("password change update failed username=%s provider=jellyfin detail=%s", username, detail)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Jellyfin password update failed: {detail}",
) from exc
# Keep Magent's Jellyfin auth cache in sync for faster subsequent sign-ins.
set_jellyfin_auth_cache(username, new_password_clean)
# Keep Magent's password hash and Jellyfin auth cache aligned with Jellyfin.
sync_jellyfin_password_state(username, new_password_clean)
logger.info("password change completed username=%s provider=jellyfin", username)
return {"status": "ok", "provider": "jellyfin"}
raise HTTPException(

View File

@@ -3,6 +3,7 @@ from typing import Any, Dict
from fastapi import APIRouter, Depends
from ..auth import get_current_user
from ..build_info import BUILD_NUMBER, CHANGELOG
from ..runtime import get_runtime_settings
router = APIRouter(prefix="/site", tags=["site"])
@@ -17,7 +18,7 @@ def _build_site_info(include_changelog: bool) -> Dict[str, Any]:
if tone not in _BANNER_TONES:
tone = "info"
info = {
"buildNumber": (runtime.site_build_number or "").strip(),
"buildNumber": (runtime.site_build_number or BUILD_NUMBER or "").strip(),
"banner": {
"enabled": bool(runtime.site_banner_enabled and banner_message),
"message": banner_message,
@@ -25,7 +26,7 @@ def _build_site_info(include_changelog: bool) -> Dict[str, Any]:
},
}
if include_changelog:
info["changelog"] = (runtime.site_changelog or "").strip()
info["changelog"] = (CHANGELOG or "").strip()
return info

View File

@@ -18,7 +18,7 @@ from ..clients.sonarr import SonarrClient
from ..config import settings as env_settings
from ..db import run_integrity_check
from ..runtime import get_runtime_settings
from .invite_email import send_test_email, smtp_email_config_ready
from .invite_email import send_test_email, smtp_email_config_ready, smtp_email_delivery_warning
DiagnosticRunner = Callable[[], Awaitable[Dict[str, Any]]]
@@ -302,6 +302,13 @@ async def _run_jellyfin_check(runtime) -> Dict[str, Any]:
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}
@@ -425,6 +432,7 @@ def _build_diagnostic_checks(recipient_email: Optional[str] = None) -> List[Diag
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)
@@ -539,7 +547,7 @@ def _build_diagnostic_checks(recipient_email: Optional[str] = None) -> List[Diag
description="Sends a live test email using the configured SMTP provider.",
live_safe=False,
configured=email_ready,
config_detail=email_detail,
config_detail=email_warning or email_detail,
target=smtp_target,
runner=lambda recipient_email=recipient_email: _run_email_check(recipient_email),
),

View File

@@ -382,6 +382,20 @@ def smtp_email_config_ready() -> tuple[bool, str]:
return True, "ok"
def smtp_email_delivery_warning() -> Optional[str]:
runtime = get_runtime_settings()
host = _normalize_display_text(runtime.magent_notify_email_smtp_host).lower()
username = _normalize_display_text(runtime.magent_notify_email_smtp_username)
password = _normalize_display_text(runtime.magent_notify_email_smtp_password)
if host.endswith(".mail.protection.outlook.com") and not (username and password):
return (
"Unauthenticated Microsoft 365 relay mode is configured. SMTP acceptance does not "
"confirm mailbox delivery. For reliable delivery, use smtp.office365.com:587 with "
"SMTP credentials or configure a verified Exchange relay connector."
)
return None
def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body_html: str) -> None:
runtime = get_runtime_settings()
host = _normalize_display_text(runtime.magent_notify_email_smtp_host)
@@ -392,6 +406,7 @@ def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body
from_name = _normalize_display_text(runtime.magent_notify_email_from_name, env_settings.app_name)
use_tls = bool(runtime.magent_notify_email_use_tls)
use_ssl = bool(runtime.magent_notify_email_use_ssl)
delivery_warning = smtp_email_delivery_warning()
if not host or not from_address:
raise RuntimeError("SMTP email settings are incomplete.")
logger.info(
@@ -405,6 +420,8 @@ def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body
bool(username and password),
subject,
)
if delivery_warning:
logger.warning("smtp delivery warning host=%s detail=%s", host, delivery_warning)
message = EmailMessage()
message["Subject"] = subject
@@ -515,4 +532,8 @@ async def send_test_email(recipient_email: Optional[str] = None) -> Dict[str, st
body_html=body_html,
)
logger.info("SMTP test email sent: recipient=%s", resolved_email)
return {"recipient_email": resolved_email, "subject": subject}
result = {"recipient_email": resolved_email, "subject": subject}
warning = smtp_email_delivery_warning()
if warning:
result["warning"] = warning
return result