Process 1 build 0203261953
This commit is contained in:
@@ -1 +1 @@
|
||||
0203261610
|
||||
0203261953
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -104,7 +104,7 @@ const SECTION_DESCRIPTIONS: Record<string, string> = {
|
||||
qbittorrent: 'Downloader connection settings.',
|
||||
requests: 'Control how often requests are refreshed and cleaned up.',
|
||||
log: 'Activity log for troubleshooting.',
|
||||
site: 'Sitewide banner, version, and changelog details.',
|
||||
site: 'Sitewide banner and version details. The changelog is generated from git history during release builds.',
|
||||
}
|
||||
|
||||
const SETTINGS_SECTION_MAP: Record<string, string | null> = {
|
||||
@@ -555,7 +555,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
const isCacheSection = section === 'cache'
|
||||
const cacheSettingKeys = new Set(['requests_sync_ttl_minutes', 'requests_data_source'])
|
||||
const artworkSettingKeys = new Set(['artwork_cache_mode'])
|
||||
const hiddenSettingKeys = new Set([...cacheSettingKeys, ...artworkSettingKeys])
|
||||
const generatedSettingKeys = new Set(['site_changelog'])
|
||||
const hiddenSettingKeys = new Set([...cacheSettingKeys, ...artworkSettingKeys, ...generatedSettingKeys])
|
||||
const requestSettingOrder = [
|
||||
'requests_poll_interval_seconds',
|
||||
'requests_delta_sync_interval_minutes',
|
||||
@@ -608,7 +609,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
items: (() => {
|
||||
const sectionItems = groupedSettings[sectionKey] ?? []
|
||||
const filtered =
|
||||
sectionKey === 'requests' || sectionKey === 'artwork'
|
||||
sectionKey === 'requests' || sectionKey === 'artwork' || sectionKey === 'site'
|
||||
? sectionItems.filter((setting) => !hiddenSettingKeys.has(setting.key))
|
||||
: sectionItems
|
||||
if (sectionKey === 'requests') {
|
||||
@@ -940,8 +941,10 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
setSectionFeedback((current) => ({
|
||||
...current,
|
||||
[sectionGroup.key]: {
|
||||
tone: 'status',
|
||||
message: `Test email sent to ${data?.recipient_email ?? 'the configured mailbox'}.`,
|
||||
tone: data?.warning ? 'error' : 'status',
|
||||
message: data?.warning
|
||||
? `SMTP accepted a relay-mode test for ${data?.recipient_email ?? 'the configured mailbox'}, but delivery is not guaranteed. ${data.warning}`
|
||||
: `Test email sent to ${data?.recipient_email ?? 'the configured mailbox'}.`,
|
||||
},
|
||||
}))
|
||||
return
|
||||
|
||||
@@ -106,9 +106,9 @@ export default function AdminSystemGuidePage() {
|
||||
const rail = (
|
||||
<div className="admin-rail-stack">
|
||||
<div className="admin-rail-card">
|
||||
<span className="admin-rail-eyebrow">Guide map</span>
|
||||
<h2>Quick path</h2>
|
||||
<p>Identity → Intake → Queue → Download → Import → Playback.</p>
|
||||
<span className="admin-rail-eyebrow">How it works</span>
|
||||
<h2>Admin flow map</h2>
|
||||
<p>Identity → Request intake → Queue orchestration → Download → Import → Playback.</p>
|
||||
<span className="small-pill">Admin only</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -116,8 +116,8 @@ export default function AdminSystemGuidePage() {
|
||||
|
||||
return (
|
||||
<AdminShell
|
||||
title="System guide"
|
||||
subtitle="Admin-only architecture and operational flow for Magent."
|
||||
title="How it works"
|
||||
subtitle="Admin-only service wiring, control areas, and recovery flow for Magent."
|
||||
rail={rail}
|
||||
actions={
|
||||
<button type="button" onClick={() => router.push('/admin')}>
|
||||
@@ -129,7 +129,8 @@ export default function AdminSystemGuidePage() {
|
||||
<div className="admin-panel">
|
||||
<h2>End-to-end system flow</h2>
|
||||
<p className="lede">
|
||||
This is the exact runtime path for request processing and availability in the current build.
|
||||
This is the runtime path the platform follows from authentication through to playback
|
||||
availability.
|
||||
</p>
|
||||
<div className="system-flow-track">
|
||||
{REQUEST_FLOW.map((stage, index) => (
|
||||
@@ -155,6 +156,51 @@ export default function AdminSystemGuidePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-panel">
|
||||
<h2>What each service is responsible for</h2>
|
||||
<div className="system-guide-grid">
|
||||
<article className="system-guide-card">
|
||||
<h3>Magent</h3>
|
||||
<p>
|
||||
Handles authentication, request pages, live event updates, invite workflows,
|
||||
diagnostics, notifications, and admin operations.
|
||||
</p>
|
||||
</article>
|
||||
<article className="system-guide-card">
|
||||
<h3>Seerr</h3>
|
||||
<p>
|
||||
Stores the request itself and remains the request-state source for approval and
|
||||
media request metadata.
|
||||
</p>
|
||||
</article>
|
||||
<article className="system-guide-card">
|
||||
<h3>Jellyfin</h3>
|
||||
<p>
|
||||
Provides user sign-in identity and the final playback destination once content is
|
||||
available.
|
||||
</p>
|
||||
</article>
|
||||
<article className="system-guide-card">
|
||||
<h3>Sonarr / Radarr</h3>
|
||||
<p>
|
||||
Control queue placement, quality-profile decisions, import handling, and release
|
||||
monitoring.
|
||||
</p>
|
||||
</article>
|
||||
<article className="system-guide-card">
|
||||
<h3>Prowlarr</h3>
|
||||
<p>Provides search/indexer coverage for Arr-side release searches.</p>
|
||||
</article>
|
||||
<article className="system-guide-card">
|
||||
<h3>qBittorrent</h3>
|
||||
<p>
|
||||
Executes the download and exposes live progress, paused states, and queue
|
||||
visibility.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-panel">
|
||||
<h2>Operational controls by area</h2>
|
||||
<div className="system-guide-grid">
|
||||
@@ -172,19 +218,48 @@ export default function AdminSystemGuidePage() {
|
||||
</article>
|
||||
<article className="system-guide-card">
|
||||
<h3>Invite management</h3>
|
||||
<p>Master template, profile assignment, invite access policy, and invite trace map lineage.</p>
|
||||
<p>
|
||||
Master template, profile assignment, invite access policy, invite emails, and trace
|
||||
map lineage.
|
||||
</p>
|
||||
</article>
|
||||
<article className="system-guide-card">
|
||||
<h3>Requests + cache</h3>
|
||||
<p>All-requests view, sync controls, cached request records, and maintenance operations.</p>
|
||||
</article>
|
||||
<article className="system-guide-card">
|
||||
<h3>Live request page</h3>
|
||||
<p>Event-stream updates for state, action history, and torrent progress without page refresh.</p>
|
||||
<h3>Maintenance + diagnostics</h3>
|
||||
<p>
|
||||
Connectivity checks, live diagnostics, database repair, cleanup, log review, and
|
||||
nuclear flush/resync operations.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-panel">
|
||||
<h2>User and invite model</h2>
|
||||
<ol className="system-decision-list">
|
||||
<li>
|
||||
Jellyfin is used for sign-in identity and user presence across the platform.
|
||||
</li>
|
||||
<li>
|
||||
Seerr provides request ownership and request-state data for Magent request pages.
|
||||
</li>
|
||||
<li>
|
||||
Invite links, invite profiles, blanket rules, and invite-access controls are managed
|
||||
inside Magent.
|
||||
</li>
|
||||
<li>
|
||||
If invite tracing is enabled, the lineage view shows who invited whom and how the
|
||||
chain branches.
|
||||
</li>
|
||||
<li>
|
||||
Cross-system removal and ban flows are initiated from Magent admin controls.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="admin-panel">
|
||||
<h2>Stall recovery path (decision flow)</h2>
|
||||
<ol className="system-decision-list">
|
||||
@@ -205,6 +280,24 @@ export default function AdminSystemGuidePage() {
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="admin-panel">
|
||||
<h2>Live update surfaces</h2>
|
||||
<div className="system-guide-grid">
|
||||
<article className="system-guide-card">
|
||||
<h3>Landing page</h3>
|
||||
<p>Recent requests and service summaries refresh live for signed-in users.</p>
|
||||
</article>
|
||||
<article className="system-guide-card">
|
||||
<h3>Request pages</h3>
|
||||
<p>Timeline state, queue activity, and torrent progress are pushed live without refresh.</p>
|
||||
</article>
|
||||
<article className="system-guide-card">
|
||||
<h3>Admin views</h3>
|
||||
<p>Diagnostics, logs, sync state, and maintenance surfaces stream live operational data.</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</AdminShell>
|
||||
)
|
||||
|
||||
@@ -8,15 +8,42 @@ type SiteInfo = {
|
||||
changelog?: string
|
||||
}
|
||||
|
||||
const parseChangelog = (raw: string) =>
|
||||
raw
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
type ChangelogGroup = {
|
||||
date: string
|
||||
entries: string[]
|
||||
}
|
||||
|
||||
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
|
||||
|
||||
const parseChangelog = (raw: string): ChangelogGroup[] => {
|
||||
const groups: ChangelogGroup[] = []
|
||||
for (const rawLine of raw.split('\n')) {
|
||||
const line = rawLine.trim()
|
||||
if (!line) continue
|
||||
const [candidateDate, ...messageParts] = line.split('|')
|
||||
if (DATE_PATTERN.test(candidateDate) && messageParts.length > 0) {
|
||||
const message = messageParts.join('|').trim()
|
||||
if (!message) continue
|
||||
const currentGroup = groups[groups.length - 1]
|
||||
if (currentGroup?.date === candidateDate) {
|
||||
currentGroup.entries.push(message)
|
||||
} else {
|
||||
groups.push({ date: candidateDate, entries: [message] })
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (groups.length === 0) {
|
||||
groups.push({ date: 'Updates', entries: [line] })
|
||||
} else {
|
||||
groups[groups.length - 1].entries.push(line)
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
export default function ChangelogPage() {
|
||||
const router = useRouter()
|
||||
const [entries, setEntries] = useState<string[]>([])
|
||||
const [groups, setGroups] = useState<ChangelogGroup[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -40,11 +67,11 @@ export default function ChangelogPage() {
|
||||
}
|
||||
const data: SiteInfo = await response.json()
|
||||
if (!active) return
|
||||
setEntries(parseChangelog(data?.changelog ?? ''))
|
||||
setGroups(parseChangelog(data?.changelog ?? ''))
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
if (!active) return
|
||||
setEntries([])
|
||||
setGroups([])
|
||||
} finally {
|
||||
if (active) setLoading(false)
|
||||
}
|
||||
@@ -59,17 +86,24 @@ export default function ChangelogPage() {
|
||||
if (loading) {
|
||||
return <div className="loading-text">Loading changelog...</div>
|
||||
}
|
||||
if (entries.length === 0) {
|
||||
if (groups.length === 0) {
|
||||
return <div className="meta">No updates posted yet.</div>
|
||||
}
|
||||
return (
|
||||
<ul className="changelog-list">
|
||||
{entries.map((entry, index) => (
|
||||
<li key={`${entry}-${index}`}>{entry}</li>
|
||||
<div className="changelog-groups">
|
||||
{groups.map((group) => (
|
||||
<section key={group.date} className="changelog-group">
|
||||
<h2>{group.date}</h2>
|
||||
<ul className="changelog-list">
|
||||
{group.entries.map((entry, index) => (
|
||||
<li key={`${group.date}-${entry}-${index}`}>{entry}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}, [entries, loading])
|
||||
}, [groups, loading])
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
|
||||
@@ -2369,6 +2369,31 @@ button span {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.changelog-groups {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.changelog-group {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.changelog-group:first-child {
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.changelog-group h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Professional UI Refresh (graphite / silver / black + subtle blue accents) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
@@ -3669,16 +3694,28 @@ button:disabled {
|
||||
.error-banner,
|
||||
.status-banner {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
border: 1px solid rgba(244, 114, 114, 0.2);
|
||||
background: rgba(244, 114, 114, 0.1);
|
||||
color: var(--error-ink);
|
||||
}
|
||||
|
||||
[data-theme='dark'] .error-banner,
|
||||
[data-theme='dark'] .status-banner {
|
||||
.status-banner {
|
||||
border: 1px solid rgba(74, 222, 128, 0.24);
|
||||
background: rgba(74, 222, 128, 0.12);
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .error-banner {
|
||||
color: #ffd9d9;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .status-banner {
|
||||
color: #dcfce7;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
max-width: 520px;
|
||||
margin-inline: auto;
|
||||
@@ -6332,7 +6369,7 @@ textarea {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 52px;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, var(--accent-2), rgba(255, 255, 255, 0));
|
||||
@@ -6409,3 +6446,62 @@ textarea {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Final header action layout */
|
||||
.header-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-actions .header-cta--left {
|
||||
grid-column: 1;
|
||||
justify-self: start;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.header-actions-center {
|
||||
grid-column: 2;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.header-actions-right {
|
||||
grid-column: 3;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.header-actions {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.header-actions .header-cta--left {
|
||||
grid-column: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-actions-center,
|
||||
.header-actions-right {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
width: 100%;
|
||||
justify-self: stretch;
|
||||
}
|
||||
|
||||
.header-actions-center {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.header-actions-right {
|
||||
grid-column: 1;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,220 +4,181 @@ export default function HowItWorksPage() {
|
||||
return (
|
||||
<main className="card how-page">
|
||||
<header className="how-hero">
|
||||
<p className="eyebrow">How this works</p>
|
||||
<h1>How Magent works now</h1>
|
||||
<p className="eyebrow">How it works</p>
|
||||
<h1>How Magent works for users</h1>
|
||||
<p className="lede">
|
||||
End-to-end request flow, live status updates, and the exact tools available to users and
|
||||
admins.
|
||||
Use Magent to find a request, watch it move through the pipeline, and know when it is
|
||||
ready without constantly refreshing the page.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="how-grid">
|
||||
<article className="how-card">
|
||||
<h2>Seerr</h2>
|
||||
<p className="how-title">The request box</p>
|
||||
<p>
|
||||
This is where you ask for a movie or show. It keeps the request and whether it is
|
||||
approved.
|
||||
</p>
|
||||
</article>
|
||||
<article className="how-card">
|
||||
<h2>Sonarr / Radarr</h2>
|
||||
<p className="how-title">The library manager</p>
|
||||
<p>
|
||||
These add the request to the library list and decide what quality to look for.
|
||||
</p>
|
||||
</article>
|
||||
<article className="how-card">
|
||||
<h2>Prowlarr</h2>
|
||||
<p className="how-title">The search helper</p>
|
||||
<p>
|
||||
This checks your search sources and reports back what it finds.
|
||||
</p>
|
||||
</article>
|
||||
<article className="how-card">
|
||||
<h2>qBittorrent</h2>
|
||||
<p className="how-title">The downloader</p>
|
||||
<p>
|
||||
This downloads the file. Magent can tell if it is downloading, paused, or finished.
|
||||
</p>
|
||||
</article>
|
||||
<article className="how-card">
|
||||
<h2>Jellyfin</h2>
|
||||
<p className="how-title">The place you watch</p>
|
||||
<p>
|
||||
When the file is ready, Jellyfin shows it in your library so you can watch it.
|
||||
</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="how-flow">
|
||||
<h2>The pipeline (request to ready)</h2>
|
||||
<ol className="how-steps">
|
||||
<li>
|
||||
<strong>Request created</strong> in Seerr.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Approved</strong> and sent to Sonarr/Radarr.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Search runs</strong> against indexers via Prowlarr.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Grabbed</strong> and downloaded by qBittorrent.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Imported</strong> by Sonarr/Radarr.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Available</strong> in Jellyfin.
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section className="how-flow">
|
||||
<h2>Live updates (no refresh needed)</h2>
|
||||
<div className="how-step-grid">
|
||||
<article className="how-step-card step-arr">
|
||||
<div className="step-badge">1</div>
|
||||
<h3>Request page updates in real time</h3>
|
||||
<p className="step-note">
|
||||
Status, timeline hops, and action history update automatically while you are viewing
|
||||
the request.
|
||||
<h2>What Magent is for</h2>
|
||||
<div className="how-grid">
|
||||
<article className="how-card">
|
||||
<h3>Track requests</h3>
|
||||
<p>
|
||||
Search by title, year, or request number to open the request page and see where an
|
||||
item is up to.
|
||||
</p>
|
||||
</article>
|
||||
<article className="how-step-card step-qbit">
|
||||
<div className="step-badge">2</div>
|
||||
<h3>Download progress updates live</h3>
|
||||
<p className="step-note">
|
||||
Torrent progress, queue state, and downloader details refresh automatically so users
|
||||
do not need to hard refresh.
|
||||
<article className="how-card">
|
||||
<h3>See live progress</h3>
|
||||
<p>
|
||||
Request status, timeline events, and download progress update live while you are
|
||||
viewing the page.
|
||||
</p>
|
||||
</article>
|
||||
<article className="how-step-card step-jellyfin">
|
||||
<div className="step-badge">3</div>
|
||||
<h3>Ready state appears as soon as import finishes</h3>
|
||||
<p className="step-note">
|
||||
As soon as Sonarr/Radarr import completes and Jellyfin can serve it, the request page
|
||||
shows it as ready.
|
||||
<article className="how-card">
|
||||
<h3>Know when it is ready</h3>
|
||||
<p>
|
||||
When the request is fully imported and available, Magent shows it as ready and links
|
||||
you through to Jellyfin.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="how-flow">
|
||||
<h2>Request actions and when to use them</h2>
|
||||
<h2>The request pipeline</h2>
|
||||
<ol className="how-steps">
|
||||
<li>
|
||||
<strong>You request a movie or show</strong> through Seerr.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Magent picks up the request</strong> and shows its current state.
|
||||
</li>
|
||||
<li>
|
||||
<strong>The automation stack searches and downloads it</strong> if it can find a valid
|
||||
release.
|
||||
</li>
|
||||
<li>
|
||||
<strong>The file is imported into the library</strong>.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Jellyfin serves it</strong> once it is ready to watch.
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section className="how-flow">
|
||||
<h2>What the statuses usually mean</h2>
|
||||
<div className="how-grid">
|
||||
<article className="how-card">
|
||||
<h3>Pending</h3>
|
||||
<p>The request exists, but it is still waiting for approval or the next step.</p>
|
||||
</article>
|
||||
<article className="how-card">
|
||||
<h3>Approved / Processing</h3>
|
||||
<p>The request has been accepted and the automation tools are working on it.</p>
|
||||
</article>
|
||||
<article className="how-card">
|
||||
<h3>Downloading</h3>
|
||||
<p>Magent can show live progress while the content is still being downloaded.</p>
|
||||
</article>
|
||||
<article className="how-card">
|
||||
<h3>Ready</h3>
|
||||
<p>The item has been imported and should now be available in Jellyfin.</p>
|
||||
</article>
|
||||
<article className="how-card">
|
||||
<h3>Partial / Waiting</h3>
|
||||
<p>
|
||||
Part of the workflow completed, but the request is still waiting on another service or
|
||||
on content becoming available.
|
||||
</p>
|
||||
</article>
|
||||
<article className="how-card">
|
||||
<h3>Declined</h3>
|
||||
<p>The request was rejected or cannot proceed in its current form.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="how-flow">
|
||||
<h2>Live updates you can expect</h2>
|
||||
<div className="how-step-grid">
|
||||
<article className="how-step-card step-seerr">
|
||||
<div className="step-badge">1</div>
|
||||
<h3>Re-add to Arr</h3>
|
||||
<p className="step-note">Use when a request is approved but never entered the Arr queue.</p>
|
||||
<div className="step-fix-title">Best for</div>
|
||||
<ul className="step-fix-list">
|
||||
<li>Missing NEEDS_ADD / ADDED state transitions</li>
|
||||
<li>Queue repair after Arr-side cleanup</li>
|
||||
</ul>
|
||||
<h3>Recent requests refresh automatically</h3>
|
||||
<p className="step-note">
|
||||
Your request list and landing-page activity update automatically while you are signed
|
||||
in.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article className="how-step-card step-arr">
|
||||
<div className="step-badge">2</div>
|
||||
<h3>Search releases</h3>
|
||||
<p className="step-note">Runs a search and shows concrete release options.</p>
|
||||
<div className="step-fix-title">Best for</div>
|
||||
<ul className="step-fix-list">
|
||||
<li>Manual selection of a specific release/indexer</li>
|
||||
<li>Checking whether results currently exist</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article className="how-step-card step-prowlarr">
|
||||
<div className="step-badge">3</div>
|
||||
<h3>Search + auto-download</h3>
|
||||
<p className="step-note">Runs search and lets Arr pick/grab automatically.</p>
|
||||
<div className="step-fix-title">Best for</div>
|
||||
<ul className="step-fix-list">
|
||||
<li>Fast recovery when users have auto-search access</li>
|
||||
<li>Hands-off retry of stalled requests</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article className="how-step-card step-qbit">
|
||||
<div className="step-badge">4</div>
|
||||
<h3>Resume download</h3>
|
||||
<p className="step-note">Resumes a paused/stopped torrent in qBittorrent.</p>
|
||||
<div className="step-fix-title">Best for</div>
|
||||
<ul className="step-fix-list">
|
||||
<li>Paused queue entries</li>
|
||||
<li>Downloader restarts</li>
|
||||
</ul>
|
||||
<div className="step-badge">2</div>
|
||||
<h3>Request pages update in real time</h3>
|
||||
<p className="step-note">
|
||||
State changes, timeline steps, and downloader progress are pushed to the page live.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article className="how-step-card step-jellyfin">
|
||||
<div className="step-badge">5</div>
|
||||
<h3>Open in Jellyfin</h3>
|
||||
<p className="step-note">Available when the item is imported and linked to Jellyfin.</p>
|
||||
<div className="step-fix-title">Best for</div>
|
||||
<ul className="step-fix-list">
|
||||
<li>Immediate playback confirmation</li>
|
||||
<li>User handoff from request tracking to watching</li>
|
||||
</ul>
|
||||
<div className="step-badge">3</div>
|
||||
<h3>Ready state appears as soon as the import completes</h3>
|
||||
<p className="step-note">
|
||||
Once the content is actually available, Magent updates the request page without a hard
|
||||
refresh.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="how-flow">
|
||||
<h2>Invite and account flow</h2>
|
||||
<h2>User actions you may see</h2>
|
||||
<div className="how-grid">
|
||||
<article className="how-card">
|
||||
<h3>Open request</h3>
|
||||
<p>Jump into the full request page to inspect the current state and activity.</p>
|
||||
</article>
|
||||
<article className="how-card">
|
||||
<h3>Open in Jellyfin</h3>
|
||||
<p>Appears when the request is ready and Magent can link you through for playback.</p>
|
||||
</article>
|
||||
<article className="how-card">
|
||||
<h3>Search + auto-download</h3>
|
||||
<p>
|
||||
Only appears for accounts that have been granted self-service download access by the
|
||||
admin team.
|
||||
</p>
|
||||
</article>
|
||||
<article className="how-card">
|
||||
<h3>My invites</h3>
|
||||
<p>
|
||||
If your account is allowed to invite others, you can create and manage invite links
|
||||
from your profile.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="how-flow">
|
||||
<h2>Invites and signup</h2>
|
||||
<ol className="how-steps">
|
||||
<li>
|
||||
<strong>Invite created</strong> by admin or eligible user.
|
||||
<strong>You receive an invite link</strong> by email or directly from the person who
|
||||
invited you.
|
||||
</li>
|
||||
<li>
|
||||
<strong>User signs up</strong> and Magent creates/links the account.
|
||||
<strong>You sign up through Magent</strong> and your account is linked into the media
|
||||
stack.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Profile/defaults apply</strong> (role, auto-search, expiry, invite access).
|
||||
<strong>Your account defaults apply</strong> based on the invite or your assigned
|
||||
profile.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Admin trace map</strong> can show inviter → invited lineage.
|
||||
<strong>You sign in and track requests</strong> from the landing page and your request
|
||||
pages.
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section className="how-flow">
|
||||
<h2>Admin controls available</h2>
|
||||
<div className="how-grid">
|
||||
<article className="how-card">
|
||||
<h3>General</h3>
|
||||
<p>App URL/port, API URL/port, bind host, proxy base URL, and manual SSL bind options.</p>
|
||||
</article>
|
||||
<article className="how-card">
|
||||
<h3>Notifications</h3>
|
||||
<p>Email, Discord, Telegram, push/mobile, and generic webhook provider settings.</p>
|
||||
</article>
|
||||
<article className="how-card">
|
||||
<h3>Users</h3>
|
||||
<p>Bulk auto-search control, invite access control, per-user roles/profile/expiry, and system actions.</p>
|
||||
</article>
|
||||
<article className="how-card">
|
||||
<h3>Invite management</h3>
|
||||
<p>Profiles, invites, blanket rules, master template, and trace map (list/graph with lineage).</p>
|
||||
</article>
|
||||
<article className="how-card">
|
||||
<h3>Request sync + cache</h3>
|
||||
<p>Control refresh/sync behavior, view all requests, and manage cached request records.</p>
|
||||
</article>
|
||||
<article className="how-card">
|
||||
<h3>Maintenance + logs</h3>
|
||||
<p>Run cleanup/sync tasks, inspect operations, and diagnose pipeline issues quickly.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="how-callout">
|
||||
<h2>Why a request can still wait</h2>
|
||||
<h2>If a request looks stuck</h2>
|
||||
<p>
|
||||
If indexers do not return a valid release yet, Magent will show waiting/search states.
|
||||
That usually means content availability is the blocker, not a broken pipeline.
|
||||
A waiting request usually means no usable release has been found yet, the download is
|
||||
still in progress, or the import has not completed. Magent will keep updating as the
|
||||
underlying services move forward.
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -9,6 +9,8 @@ type ProfileInfo = {
|
||||
role: string
|
||||
auth_provider: string
|
||||
invite_management_enabled?: boolean
|
||||
password_change_supported?: boolean
|
||||
password_provider?: 'local' | 'jellyfin' | null
|
||||
}
|
||||
|
||||
type ProfileStats = {
|
||||
@@ -81,7 +83,8 @@ export default function ProfilePage() {
|
||||
const [activity, setActivity] = useState<ProfileActivity | null>(null)
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [status, setStatus] = useState<string | null>(null)
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [status, setStatus] = useState<{ tone: 'status' | 'error'; message: string } | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<ProfileTab>('overview')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
@@ -124,12 +127,17 @@ export default function ProfilePage() {
|
||||
role: user?.role ?? 'user',
|
||||
auth_provider: user?.auth_provider ?? 'local',
|
||||
invite_management_enabled: Boolean(user?.invite_management_enabled ?? false),
|
||||
password_change_supported: Boolean(user?.password_change_supported ?? false),
|
||||
password_provider:
|
||||
user?.password_provider === 'jellyfin' || user?.password_provider === 'local'
|
||||
? user.password_provider
|
||||
: null,
|
||||
})
|
||||
setStats(data?.stats ?? null)
|
||||
setActivity(data?.activity ?? null)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setStatus('Could not load your profile.')
|
||||
setStatus({ tone: 'error', message: 'Could not load your profile.' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -141,7 +149,11 @@ export default function ProfilePage() {
|
||||
event.preventDefault()
|
||||
setStatus(null)
|
||||
if (!currentPassword || !newPassword) {
|
||||
setStatus('Enter your current password and a new password.')
|
||||
setStatus({ tone: 'error', message: 'Enter your current password and a new password.' })
|
||||
return
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setStatus({ tone: 'error', message: 'New password and confirmation do not match.' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -170,28 +182,32 @@ export default function ProfilePage() {
|
||||
const data = await response.json().catch(() => ({}))
|
||||
setCurrentPassword('')
|
||||
setNewPassword('')
|
||||
setStatus(
|
||||
data?.provider === 'jellyfin'
|
||||
? 'Password updated in Jellyfin (and Magent cache).'
|
||||
: 'Password updated.'
|
||||
)
|
||||
setConfirmPassword('')
|
||||
setStatus({
|
||||
tone: 'status',
|
||||
message:
|
||||
data?.provider === 'jellyfin'
|
||||
? 'Password updated across Jellyfin and Magent. Seerr continues to use the same Jellyfin password.'
|
||||
: 'Password updated.',
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
if (err instanceof Error && err.message) {
|
||||
setStatus(`Could not update password. ${err.message}`)
|
||||
setStatus({ tone: 'error', message: `Could not update password. ${err.message}` })
|
||||
} else {
|
||||
setStatus('Could not update password. Check your current password.')
|
||||
setStatus({ tone: 'error', message: 'Could not update password. Check your current password.' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const authProvider = profile?.auth_provider ?? 'local'
|
||||
const passwordProvider = profile?.password_provider ?? (authProvider === 'jellyfin' ? 'jellyfin' : 'local')
|
||||
const canManageInvites = profile?.role === 'admin' || Boolean(profile?.invite_management_enabled)
|
||||
const canChangePassword = authProvider === 'local' || authProvider === 'jellyfin'
|
||||
const canChangePassword = Boolean(profile?.password_change_supported ?? (authProvider === 'local' || authProvider === 'jellyfin'))
|
||||
const securityHelpText =
|
||||
authProvider === 'jellyfin'
|
||||
? 'Changing your password here updates your Jellyfin account and refreshes Magent’s cached sign-in.'
|
||||
: authProvider === 'local'
|
||||
passwordProvider === 'jellyfin'
|
||||
? 'Reset your password here once. Magent updates Jellyfin directly, Seerr continues to use Jellyfin authentication, and Magent keeps the same password in sync.'
|
||||
: passwordProvider === 'local'
|
||||
? 'Change your Magent account password.'
|
||||
: 'Password changes are not available for this sign-in provider.'
|
||||
|
||||
@@ -206,11 +222,18 @@ export default function ProfilePage() {
|
||||
<h1>My profile</h1>
|
||||
<p className="lede">Review your account, activity, and security settings.</p>
|
||||
</div>
|
||||
{canManageInvites ? (
|
||||
{canManageInvites || canChangePassword ? (
|
||||
<div className="admin-inline-actions">
|
||||
<button type="button" className="ghost-button" onClick={() => router.push(inviteLink)}>
|
||||
Open invite page
|
||||
</button>
|
||||
{canManageInvites ? (
|
||||
<button type="button" className="ghost-button" onClick={() => router.push(inviteLink)}>
|
||||
Open invite page
|
||||
</button>
|
||||
) : null}
|
||||
{canChangePassword ? (
|
||||
<button type="button" onClick={() => selectTab('security')}>
|
||||
{passwordProvider === 'jellyfin' ? 'Reset Jellyfin password' : 'Change password'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -254,7 +277,7 @@ export default function ProfilePage() {
|
||||
className={activeTab === 'security' ? 'is-active' : ''}
|
||||
onClick={() => selectTab('security')}
|
||||
>
|
||||
Security
|
||||
Password
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -276,6 +299,23 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{canChangePassword ? (
|
||||
<div className="profile-quick-link-card">
|
||||
<div>
|
||||
<h2>{passwordProvider === 'jellyfin' ? 'Jellyfin password' : 'Password'}</h2>
|
||||
<p className="lede">
|
||||
{passwordProvider === 'jellyfin'
|
||||
? 'Update your shared Jellyfin, Seerr, and Magent password without leaving Magent.'
|
||||
: 'Update your Magent account password.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="admin-inline-actions">
|
||||
<button type="button" onClick={() => selectTab('security')}>
|
||||
{passwordProvider === 'jellyfin' ? 'Reset Jellyfin password' : 'Change password'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<h2>Account stats</h2>
|
||||
<div className="stat-grid">
|
||||
<div className="stat-card">
|
||||
@@ -367,12 +407,12 @@ export default function ProfilePage() {
|
||||
|
||||
{activeTab === 'security' && (
|
||||
<section className="profile-section profile-tab-panel">
|
||||
<h2>Security</h2>
|
||||
<h2>{passwordProvider === 'jellyfin' ? 'Jellyfin password reset' : 'Password'}</h2>
|
||||
<div className="status-banner">{securityHelpText}</div>
|
||||
{canChangePassword ? (
|
||||
<form onSubmit={submit} className="auth-form profile-security-form">
|
||||
<label>
|
||||
Current password
|
||||
{passwordProvider === 'jellyfin' ? 'Current Jellyfin password' : 'Current password'}
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
@@ -381,7 +421,7 @@ export default function ProfilePage() {
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
New password
|
||||
{passwordProvider === 'jellyfin' ? 'New Jellyfin password' : 'New password'}
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
@@ -389,10 +429,23 @@ export default function ProfilePage() {
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
{status && <div className="status-banner">{status}</div>}
|
||||
<label>
|
||||
Confirm new password
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
{status ? (
|
||||
<div className={status.tone === 'error' ? 'error-banner' : 'status-banner'}>
|
||||
{status.message}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="auth-actions">
|
||||
<button type="submit">
|
||||
{authProvider === 'jellyfin' ? 'Update Jellyfin password' : 'Update password'}
|
||||
{passwordProvider === 'jellyfin' ? 'Reset Jellyfin password' : 'Update password'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -27,7 +27,7 @@ const NAV_GROUPS = [
|
||||
title: 'Admin',
|
||||
items: [
|
||||
{ href: '/admin/notifications', label: 'Notifications' },
|
||||
{ href: '/admin/system', label: 'System guide' },
|
||||
{ href: '/admin/system', label: 'How it works' },
|
||||
{ href: '/admin/site', label: 'Site' },
|
||||
{ href: '/users', label: 'Users' },
|
||||
{ href: '/admin/invites', label: 'Invite management' },
|
||||
|
||||
@@ -5,7 +5,6 @@ import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||
|
||||
export default function HeaderActions() {
|
||||
const [signedIn, setSignedIn] = useState(false)
|
||||
const [role, setRole] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const token = getToken()
|
||||
@@ -20,11 +19,9 @@ export default function HeaderActions() {
|
||||
if (!response.ok) {
|
||||
clearToken()
|
||||
setSignedIn(false)
|
||||
setRole(null)
|
||||
return
|
||||
}
|
||||
const data = await response.json()
|
||||
setRole(data?.role ?? null)
|
||||
await response.json()
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
@@ -39,9 +36,13 @@ export default function HeaderActions() {
|
||||
return (
|
||||
<div className="header-actions">
|
||||
<a className="header-cta header-cta--left" href="/feedback">Send feedback</a>
|
||||
<a href="/">Requests</a>
|
||||
<a href="/how-it-works">How it works</a>
|
||||
{role === 'admin' && <a href="/admin">Settings</a>}
|
||||
<div className="header-actions-center">
|
||||
<a href="/how-it-works">How it works</a>
|
||||
</div>
|
||||
<div className="header-actions-right">
|
||||
<a href="/">Requests</a>
|
||||
<a href="/profile/invites">Invites</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -75,9 +75,11 @@ export default function HeaderIdentity() {
|
||||
<a href="/profile" onClick={() => setOpen(false)}>
|
||||
My profile
|
||||
</a>
|
||||
<a href="/profile/invites" onClick={() => setOpen(false)}>
|
||||
My invites
|
||||
</a>
|
||||
{identity.role === 'admin' ? (
|
||||
<a href="/admin" onClick={() => setOpen(false)}>
|
||||
Settings
|
||||
</a>
|
||||
) : null}
|
||||
<a href="/changelog" onClick={() => setOpen(false)}>
|
||||
Changelog
|
||||
</a>
|
||||
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "magent-frontend",
|
||||
"version": "0203261610",
|
||||
"version": "0203261953",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "magent-frontend",
|
||||
"version": "0203261610",
|
||||
"version": "0203261953",
|
||||
"dependencies": {
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.4",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "magent-frontend",
|
||||
"private": true,
|
||||
"version": "0203261610",
|
||||
"version": "0203261953",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
|
||||
@@ -149,19 +149,25 @@ function Wait-ForHttp {
|
||||
throw $lastError
|
||||
}
|
||||
|
||||
function Get-GitChangelogLiteral {
|
||||
$scriptPath = Join-Path $repoRoot "scripts/render_git_changelog.py"
|
||||
$literal = python $scriptPath --python-literal
|
||||
Assert-LastExitCode -CommandName "python scripts/render_git_changelog.py --python-literal"
|
||||
return ($literal | Out-String).Trim()
|
||||
}
|
||||
|
||||
function Update-BuildFiles {
|
||||
param([Parameter(Mandatory = $true)][string]$BuildNumber)
|
||||
|
||||
Write-TextFile -Path ".build_number" -Content "$BuildNumber`n"
|
||||
|
||||
$buildInfo = Read-TextFile -Path "backend/app/build_info.py"
|
||||
$updatedBuildInfo = [regex]::Replace(
|
||||
$buildInfo,
|
||||
'^BUILD_NUMBER = "\d+"$',
|
||||
"BUILD_NUMBER = `"$BuildNumber`"",
|
||||
[System.Text.RegularExpressions.RegexOptions]::Multiline
|
||||
)
|
||||
Write-TextFile -Path "backend/app/build_info.py" -Content $updatedBuildInfo
|
||||
$changelogLiteral = Get-GitChangelogLiteral
|
||||
$buildInfoContent = @(
|
||||
"BUILD_NUMBER = `"$BuildNumber`""
|
||||
"CHANGELOG = $changelogLiteral"
|
||||
""
|
||||
) -join "`n"
|
||||
Write-TextFile -Path "backend/app/build_info.py" -Content $buildInfoContent
|
||||
|
||||
$envPath = Join-Path $repoRoot ".env"
|
||||
if (Test-Path $envPath) {
|
||||
|
||||
45
scripts/render_git_changelog.py
Normal file
45
scripts/render_git_changelog.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def build_git_changelog(repo_root: Path, max_count: int) -> str:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"log",
|
||||
f"--max-count={max_count}",
|
||||
"--date=short",
|
||||
"--pretty=format:%cs|%s",
|
||||
"--",
|
||||
".",
|
||||
],
|
||||
cwd=repo_root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
lines = [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--max-count", type=int, default=200)
|
||||
parser.add_argument("--python-literal", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
changelog = build_git_changelog(repo_root, max_count=args.max_count)
|
||||
if args.python_literal:
|
||||
print(repr(changelog))
|
||||
else:
|
||||
sys.stdout.write(changelog)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user