Compare commits
5 Commits
aae2c3d418
...
9c69d9fd17
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c69d9fd17 | |||
| b0ef455498 | |||
| 821f518bb3 | |||
| eeba143b41 | |||
| b068a6066e |
@@ -1 +1 @@
|
|||||||
0103262231
|
0203261953
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ from typing import Dict, Any, Optional
|
|||||||
from fastapi import Depends, HTTPException, status, Request
|
from fastapi import Depends, HTTPException, status, Request
|
||||||
from fastapi.security import OAuth2PasswordBearer
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
|
||||||
from .db import get_user_by_username, upsert_user_activity
|
from .db import get_user_by_username, set_user_auth_provider, upsert_user_activity
|
||||||
from .security import safe_decode_token, TokenError
|
from .security import safe_decode_token, TokenError, verify_password
|
||||||
|
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
|
||||||
|
|
||||||
@@ -38,6 +38,42 @@ def _extract_client_ip(request: Request) -> str:
|
|||||||
return "unknown"
|
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(
|
def _load_current_user_from_token(
|
||||||
token: str,
|
token: str,
|
||||||
request: Optional[Request] = None,
|
request: Optional[Request] = None,
|
||||||
@@ -63,6 +99,8 @@ def _load_current_user_from_token(
|
|||||||
if _is_expired(user.get("expires_at")):
|
if _is_expired(user.get("expires_at")):
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired")
|
||||||
|
|
||||||
|
user = normalize_user_auth_provider(user)
|
||||||
|
|
||||||
if request is not None:
|
if request is not None:
|
||||||
ip = _extract_client_ip(request)
|
ip = _extract_client_ip(request)
|
||||||
user_agent = request.headers.get("user-agent", "unknown")
|
user_agent = request.headers.get("user-agent", "unknown")
|
||||||
@@ -78,6 +116,8 @@ def _load_current_user_from_token(
|
|||||||
"profile_id": user.get("profile_id"),
|
"profile_id": user.get("profile_id"),
|
||||||
"expires_at": user.get("expires_at"),
|
"expires_at": user.get("expires_at"),
|
||||||
"is_expired": bool(user.get("is_expired", False)),
|
"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,7 +1,2 @@
|
|||||||
BUILD_NUMBER = "0103262231"
|
BUILD_NUMBER = "0203261953"
|
||||||
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'
|
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'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,17 @@ class ApiClient:
|
|||||||
def headers(self) -> Dict[str, str]:
|
def headers(self) -> Dict[str, str]:
|
||||||
return {"X-Api-Key": self.api_key} if self.api_key else {}
|
return {"X-Api-Key": self.api_key} if self.api_key else {}
|
||||||
|
|
||||||
|
def _response_summary(self, response: Optional[httpx.Response]) -> Optional[Any]:
|
||||||
|
if response is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
payload = sanitize_value(response.json())
|
||||||
|
except ValueError:
|
||||||
|
payload = sanitize_value(response.text)
|
||||||
|
if isinstance(payload, str) and len(payload) > 500:
|
||||||
|
return f"{payload[:500]}..."
|
||||||
|
return payload
|
||||||
|
|
||||||
async def _request(
|
async def _request(
|
||||||
self,
|
self,
|
||||||
method: str,
|
method: str,
|
||||||
@@ -60,6 +71,20 @@ class ApiClient:
|
|||||||
if not response.content:
|
if not response.content:
|
||||||
return None
|
return None
|
||||||
return response.json()
|
return response.json()
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||||
|
response = exc.response
|
||||||
|
status = response.status_code if response is not None else "unknown"
|
||||||
|
log_fn = self.logger.error if isinstance(status, int) and status >= 500 else self.logger.warning
|
||||||
|
log_fn(
|
||||||
|
"outbound request returned error method=%s url=%s status=%s duration_ms=%s response=%s",
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
status,
|
||||||
|
duration_ms,
|
||||||
|
self._response_summary(response),
|
||||||
|
)
|
||||||
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||||
self.logger.exception(
|
self.logger.exception(
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ from .security import hash_password, verify_password
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SEERR_MEDIA_FAILURE_SHORT_SUPPRESS_HOURS = 6
|
||||||
|
SEERR_MEDIA_FAILURE_RETRY_SUPPRESS_HOURS = 24
|
||||||
|
SEERR_MEDIA_FAILURE_PERSISTENT_SUPPRESS_DAYS = 30
|
||||||
|
SEERR_MEDIA_FAILURE_PERSISTENT_THRESHOLD = 3
|
||||||
|
|
||||||
|
|
||||||
def _db_path() -> str:
|
def _db_path() -> str:
|
||||||
path = settings.sqlite_path or "data/magent.db"
|
path = settings.sqlite_path or "data/magent.db"
|
||||||
@@ -271,6 +276,22 @@ def init_db() -> None:
|
|||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS seerr_media_failures (
|
||||||
|
media_type TEXT NOT NULL,
|
||||||
|
tmdb_id INTEGER NOT NULL,
|
||||||
|
status_code INTEGER,
|
||||||
|
error_message TEXT,
|
||||||
|
failure_count INTEGER NOT NULL DEFAULT 1,
|
||||||
|
first_failed_at TEXT NOT NULL,
|
||||||
|
last_failed_at TEXT NOT NULL,
|
||||||
|
suppress_until TEXT NOT NULL,
|
||||||
|
is_persistent INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (media_type, tmdb_id)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
CREATE INDEX IF NOT EXISTS idx_requests_cache_created_at
|
CREATE INDEX IF NOT EXISTS idx_requests_cache_created_at
|
||||||
@@ -289,6 +310,12 @@ def init_db() -> None:
|
|||||||
ON artwork_cache_status (updated_at)
|
ON artwork_cache_status (updated_at)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_seerr_media_failures_suppress_until
|
||||||
|
ON seerr_media_failures (suppress_until)
|
||||||
|
"""
|
||||||
|
)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS user_activity (
|
CREATE TABLE IF NOT EXISTS user_activity (
|
||||||
@@ -1371,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:
|
def set_jellyfin_auth_cache(username: str, password: str) -> None:
|
||||||
if not username or not password:
|
if not username or not password:
|
||||||
return
|
return
|
||||||
@@ -2226,6 +2271,154 @@ def get_settings_overrides() -> Dict[str, str]:
|
|||||||
return overrides
|
return overrides
|
||||||
|
|
||||||
|
|
||||||
|
def get_seerr_media_failure(media_type: Optional[str], tmdb_id: Optional[int]) -> Optional[Dict[str, Any]]:
|
||||||
|
if not media_type or not tmdb_id:
|
||||||
|
return None
|
||||||
|
normalized_media_type = str(media_type).strip().lower()
|
||||||
|
try:
|
||||||
|
normalized_tmdb_id = int(tmdb_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
with _connect() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT media_type, tmdb_id, status_code, error_message, failure_count,
|
||||||
|
first_failed_at, last_failed_at, suppress_until, is_persistent
|
||||||
|
FROM seerr_media_failures
|
||||||
|
WHERE media_type = ? AND tmdb_id = ?
|
||||||
|
""",
|
||||||
|
(normalized_media_type, normalized_tmdb_id),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"media_type": row[0],
|
||||||
|
"tmdb_id": row[1],
|
||||||
|
"status_code": row[2],
|
||||||
|
"error_message": row[3],
|
||||||
|
"failure_count": row[4],
|
||||||
|
"first_failed_at": row[5],
|
||||||
|
"last_failed_at": row[6],
|
||||||
|
"suppress_until": row[7],
|
||||||
|
"is_persistent": bool(row[8]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_seerr_media_failure_suppressed(media_type: Optional[str], tmdb_id: Optional[int]) -> bool:
|
||||||
|
record = get_seerr_media_failure(media_type, tmdb_id)
|
||||||
|
if not record:
|
||||||
|
return False
|
||||||
|
suppress_until = _parse_datetime_value(record.get("suppress_until"))
|
||||||
|
if suppress_until and suppress_until > datetime.now(timezone.utc):
|
||||||
|
return True
|
||||||
|
clear_seerr_media_failure(media_type, tmdb_id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def record_seerr_media_failure(
|
||||||
|
media_type: Optional[str],
|
||||||
|
tmdb_id: Optional[int],
|
||||||
|
*,
|
||||||
|
status_code: Optional[int] = None,
|
||||||
|
error_message: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
if not media_type or not tmdb_id:
|
||||||
|
return {}
|
||||||
|
normalized_media_type = str(media_type).strip().lower()
|
||||||
|
normalized_tmdb_id = int(tmdb_id)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
existing = get_seerr_media_failure(normalized_media_type, normalized_tmdb_id)
|
||||||
|
failure_count = int(existing.get("failure_count", 0)) + 1 if existing else 1
|
||||||
|
is_persistent = failure_count >= SEERR_MEDIA_FAILURE_PERSISTENT_THRESHOLD
|
||||||
|
if is_persistent:
|
||||||
|
suppress_until = now + timedelta(days=SEERR_MEDIA_FAILURE_PERSISTENT_SUPPRESS_DAYS)
|
||||||
|
elif failure_count >= 2:
|
||||||
|
suppress_until = now + timedelta(hours=SEERR_MEDIA_FAILURE_RETRY_SUPPRESS_HOURS)
|
||||||
|
else:
|
||||||
|
suppress_until = now + timedelta(hours=SEERR_MEDIA_FAILURE_SHORT_SUPPRESS_HOURS)
|
||||||
|
payload = {
|
||||||
|
"media_type": normalized_media_type,
|
||||||
|
"tmdb_id": normalized_tmdb_id,
|
||||||
|
"status_code": status_code,
|
||||||
|
"error_message": error_message,
|
||||||
|
"failure_count": failure_count,
|
||||||
|
"first_failed_at": existing.get("first_failed_at") if existing else now.isoformat(),
|
||||||
|
"last_failed_at": now.isoformat(),
|
||||||
|
"suppress_until": suppress_until.isoformat(),
|
||||||
|
"is_persistent": is_persistent,
|
||||||
|
}
|
||||||
|
with _connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO seerr_media_failures (
|
||||||
|
media_type,
|
||||||
|
tmdb_id,
|
||||||
|
status_code,
|
||||||
|
error_message,
|
||||||
|
failure_count,
|
||||||
|
first_failed_at,
|
||||||
|
last_failed_at,
|
||||||
|
suppress_until,
|
||||||
|
is_persistent
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(media_type, tmdb_id) DO UPDATE SET
|
||||||
|
status_code = excluded.status_code,
|
||||||
|
error_message = excluded.error_message,
|
||||||
|
failure_count = excluded.failure_count,
|
||||||
|
first_failed_at = excluded.first_failed_at,
|
||||||
|
last_failed_at = excluded.last_failed_at,
|
||||||
|
suppress_until = excluded.suppress_until,
|
||||||
|
is_persistent = excluded.is_persistent
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
payload["media_type"],
|
||||||
|
payload["tmdb_id"],
|
||||||
|
payload["status_code"],
|
||||||
|
payload["error_message"],
|
||||||
|
payload["failure_count"],
|
||||||
|
payload["first_failed_at"],
|
||||||
|
payload["last_failed_at"],
|
||||||
|
payload["suppress_until"],
|
||||||
|
1 if payload["is_persistent"] else 0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
logger.warning(
|
||||||
|
"seerr_media_failure upsert: media_type=%s tmdb_id=%s status=%s failure_count=%s suppress_until=%s persistent=%s",
|
||||||
|
payload["media_type"],
|
||||||
|
payload["tmdb_id"],
|
||||||
|
payload["status_code"],
|
||||||
|
payload["failure_count"],
|
||||||
|
payload["suppress_until"],
|
||||||
|
payload["is_persistent"],
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def clear_seerr_media_failure(media_type: Optional[str], tmdb_id: Optional[int]) -> None:
|
||||||
|
if not media_type or not tmdb_id:
|
||||||
|
return
|
||||||
|
normalized_media_type = str(media_type).strip().lower()
|
||||||
|
try:
|
||||||
|
normalized_tmdb_id = int(tmdb_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return
|
||||||
|
with _connect() as conn:
|
||||||
|
deleted = conn.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM seerr_media_failures
|
||||||
|
WHERE media_type = ? AND tmdb_id = ?
|
||||||
|
""",
|
||||||
|
(normalized_media_type, normalized_tmdb_id),
|
||||||
|
).rowcount
|
||||||
|
if deleted:
|
||||||
|
logger.info(
|
||||||
|
"seerr_media_failure cleared: media_type=%s tmdb_id=%s",
|
||||||
|
normalized_media_type,
|
||||||
|
normalized_tmdb_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def run_integrity_check() -> str:
|
def run_integrity_check() -> str:
|
||||||
with _connect() as conn:
|
with _connect() as conn:
|
||||||
row = conn.execute("PRAGMA integrity_check").fetchone()
|
row = conn.execute("PRAGMA integrity_check").fetchone()
|
||||||
|
|||||||
@@ -12,7 +12,13 @@ from urllib.parse import urlparse, urlunparse
|
|||||||
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, Request
|
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, Request
|
||||||
from fastapi.responses import StreamingResponse
|
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 ..config import settings as env_settings
|
||||||
from ..db import (
|
from ..db import (
|
||||||
delete_setting,
|
delete_setting,
|
||||||
@@ -40,7 +46,7 @@ from ..db import (
|
|||||||
set_user_profile_id,
|
set_user_profile_id,
|
||||||
set_user_expires_at,
|
set_user_expires_at,
|
||||||
set_user_password,
|
set_user_password,
|
||||||
set_jellyfin_auth_cache,
|
sync_jellyfin_password_state,
|
||||||
set_user_role,
|
set_user_role,
|
||||||
run_integrity_check,
|
run_integrity_check,
|
||||||
vacuum_db,
|
vacuum_db,
|
||||||
@@ -85,6 +91,7 @@ from ..services.invite_email import (
|
|||||||
reset_invite_email_template,
|
reset_invite_email_template,
|
||||||
save_invite_email_template,
|
save_invite_email_template,
|
||||||
send_test_email,
|
send_test_email,
|
||||||
|
smtp_email_delivery_warning,
|
||||||
send_templated_email,
|
send_templated_email,
|
||||||
smtp_email_config_ready,
|
smtp_email_config_ready,
|
||||||
)
|
)
|
||||||
@@ -1451,7 +1458,8 @@ async def update_user_password(username: str, payload: Dict[str, Any]) -> Dict[s
|
|||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
new_password_clean = new_password.strip()
|
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":
|
if auth_provider == "local":
|
||||||
set_user_password(username, new_password_clean)
|
set_user_password(username, new_password_clean)
|
||||||
return {"status": "ok", "username": username, "provider": "local"}
|
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)
|
await client.set_user_password(user_id, new_password_clean)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=502, detail=f"Jellyfin password update failed: {exc}") from 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"}
|
return {"status": "ok", "username": username, "provider": "jellyfin"}
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
@@ -1691,11 +1699,12 @@ async def update_invite_policy(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
@router.get("/invites/email/templates")
|
@router.get("/invites/email/templates")
|
||||||
async def get_invite_email_template_settings() -> Dict[str, Any]:
|
async def get_invite_email_template_settings() -> Dict[str, Any]:
|
||||||
ready, detail = smtp_email_config_ready()
|
ready, detail = smtp_email_config_ready()
|
||||||
|
warning = smtp_email_delivery_warning()
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"email": {
|
"email": {
|
||||||
"configured": ready,
|
"configured": ready,
|
||||||
"detail": detail,
|
"detail": warning or detail,
|
||||||
},
|
},
|
||||||
"templates": list(get_invite_email_templates().values()),
|
"templates": list(get_invite_email_templates().values()),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ from ..db import (
|
|||||||
get_user_by_username,
|
get_user_by_username,
|
||||||
get_users_by_username_ci,
|
get_users_by_username_ci,
|
||||||
set_user_password,
|
set_user_password,
|
||||||
set_jellyfin_auth_cache,
|
|
||||||
set_user_jellyseerr_id,
|
set_user_jellyseerr_id,
|
||||||
set_user_auth_provider,
|
set_user_auth_provider,
|
||||||
get_signup_invite_by_code,
|
get_signup_invite_by_code,
|
||||||
@@ -35,13 +34,14 @@ from ..db import (
|
|||||||
get_global_request_leader,
|
get_global_request_leader,
|
||||||
get_global_request_total,
|
get_global_request_total,
|
||||||
get_setting,
|
get_setting,
|
||||||
|
sync_jellyfin_password_state,
|
||||||
)
|
)
|
||||||
from ..runtime import get_runtime_settings
|
from ..runtime import get_runtime_settings
|
||||||
from ..clients.jellyfin import JellyfinClient
|
from ..clients.jellyfin import JellyfinClient
|
||||||
from ..clients.jellyseerr import JellyseerrClient
|
from ..clients.jellyseerr import JellyseerrClient
|
||||||
from ..security import create_access_token, verify_password
|
from ..security import create_access_token, verify_password
|
||||||
from ..security import create_stream_token
|
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 ..config import settings
|
||||||
from ..services.user_cache import (
|
from ..services.user_cache import (
|
||||||
build_jellyseerr_candidate_map,
|
build_jellyseerr_candidate_map,
|
||||||
@@ -599,7 +599,7 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm
|
|||||||
save_jellyfin_users_cache(users)
|
save_jellyfin_users_cache(users)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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:
|
if user and user.get("jellyseerr_user_id") is None and candidate_map:
|
||||||
matched_id = match_jellyseerr_user_id(canonical_username, candidate_map)
|
matched_id = match_jellyseerr_user_id(canonical_username, candidate_map)
|
||||||
if matched_id is not None:
|
if matched_id is not None:
|
||||||
@@ -781,7 +781,7 @@ async def signup(payload: dict) -> dict:
|
|||||||
if jellyfin_client.configured():
|
if jellyfin_client.configured():
|
||||||
logger.info("signup provisioning jellyfin username=%s", username)
|
logger.info("signup provisioning jellyfin username=%s", username)
|
||||||
auth_provider = "jellyfin"
|
auth_provider = "jellyfin"
|
||||||
local_password_value = "jellyfin-user"
|
local_password_value = password_value
|
||||||
try:
|
try:
|
||||||
await jellyfin_client.create_user_with_password(username, password_value)
|
await jellyfin_client.create_user_with_password(username, password_value)
|
||||||
except httpx.HTTPStatusError as exc:
|
except httpx.HTTPStatusError as exc:
|
||||||
@@ -838,7 +838,7 @@ async def signup(payload: dict) -> dict:
|
|||||||
increment_signup_invite_use(int(invite["id"]))
|
increment_signup_invite_use(int(invite["id"]))
|
||||||
created_user = get_user_by_username(username)
|
created_user = get_user_by_username(username)
|
||||||
if auth_provider == "jellyfin":
|
if auth_provider == "jellyfin":
|
||||||
set_jellyfin_auth_cache(username, password_value)
|
sync_jellyfin_password_state(username, password_value)
|
||||||
if (
|
if (
|
||||||
created_user
|
created_user
|
||||||
and created_user.get("jellyseerr_user_id") is None
|
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."
|
status_code=status.HTTP_400_BAD_REQUEST, detail="Password must be at least 8 characters."
|
||||||
)
|
)
|
||||||
username = str(current_user.get("username") or "").strip()
|
username = str(current_user.get("username") or "").strip()
|
||||||
auth_provider = str(current_user.get("auth_provider") or "local").lower()
|
|
||||||
if not username:
|
if not username:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user")
|
||||||
new_password_clean = new_password.strip()
|
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":
|
if auth_provider == "local":
|
||||||
user = verify_user_password(username, current_password)
|
user = verify_user_password(username, current_password)
|
||||||
if not user:
|
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")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect")
|
||||||
set_user_password(username, new_password_clean)
|
set_user_password(username, new_password_clean)
|
||||||
|
logger.info("password change completed username=%s provider=local", username)
|
||||||
return {"status": "ok", "provider": "local"}
|
return {"status": "ok", "provider": "local"}
|
||||||
|
|
||||||
if auth_provider == "jellyfin":
|
if auth_provider == "jellyfin":
|
||||||
@@ -1152,6 +1156,7 @@ async def change_password(payload: dict, current_user: dict = Depends(get_curren
|
|||||||
try:
|
try:
|
||||||
auth_result = await client.authenticate_by_name(username, current_password)
|
auth_result = await client.authenticate_by_name(username, current_password)
|
||||||
if not isinstance(auth_result, dict) or not auth_result.get("User"):
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect"
|
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
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
detail = _extract_http_error_detail(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}:
|
if isinstance(exc, httpx.HTTPStatusError) and exc.response is not None and exc.response.status_code in {401, 403}:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect"
|
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)
|
await client.set_user_password(user_id, new_password_clean)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
detail = _extract_http_error_detail(exc)
|
detail = _extract_http_error_detail(exc)
|
||||||
|
logger.warning("password change update failed username=%s provider=jellyfin detail=%s", username, detail)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
detail=f"Jellyfin password update failed: {detail}",
|
detail=f"Jellyfin password update failed: {detail}",
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
# Keep Magent's Jellyfin auth cache in sync for faster subsequent sign-ins.
|
# Keep Magent's password hash and Jellyfin auth cache aligned with Jellyfin.
|
||||||
set_jellyfin_auth_cache(username, new_password_clean)
|
sync_jellyfin_password_state(username, new_password_clean)
|
||||||
|
logger.info("password change completed username=%s provider=jellyfin", username)
|
||||||
return {"status": "ok", "provider": "jellyfin"}
|
return {"status": "ok", "provider": "jellyfin"}
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ from ..db import (
|
|||||||
set_setting,
|
set_setting,
|
||||||
update_artwork_cache_stats,
|
update_artwork_cache_stats,
|
||||||
cleanup_history,
|
cleanup_history,
|
||||||
|
is_seerr_media_failure_suppressed,
|
||||||
|
record_seerr_media_failure,
|
||||||
|
clear_seerr_media_failure,
|
||||||
)
|
)
|
||||||
from ..models import Snapshot, TriageResult, RequestType
|
from ..models import Snapshot, TriageResult, RequestType
|
||||||
from ..services.snapshot import build_snapshot
|
from ..services.snapshot import build_snapshot
|
||||||
@@ -50,6 +53,8 @@ router = APIRouter(prefix="/requests", tags=["requests"], dependencies=[Depends(
|
|||||||
|
|
||||||
CACHE_TTL_SECONDS = 600
|
CACHE_TTL_SECONDS = 600
|
||||||
_detail_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
|
_detail_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
|
||||||
|
FAILED_DETAIL_CACHE_TTL_SECONDS = 3600
|
||||||
|
_failed_detail_cache: Dict[str, float] = {}
|
||||||
REQUEST_CACHE_TTL_SECONDS = 600
|
REQUEST_CACHE_TTL_SECONDS = 600
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
_sync_state: Dict[str, Any] = {
|
_sync_state: Dict[str, Any] = {
|
||||||
@@ -100,6 +105,45 @@ def _cache_get(key: str) -> Optional[Dict[str, Any]]:
|
|||||||
|
|
||||||
def _cache_set(key: str, payload: Dict[str, Any]) -> None:
|
def _cache_set(key: str, payload: Dict[str, Any]) -> None:
|
||||||
_detail_cache[key] = (time.time() + CACHE_TTL_SECONDS, payload)
|
_detail_cache[key] = (time.time() + CACHE_TTL_SECONDS, payload)
|
||||||
|
_failed_detail_cache.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _failure_cache_has(key: str) -> bool:
|
||||||
|
expires_at = _failed_detail_cache.get(key)
|
||||||
|
if not expires_at:
|
||||||
|
return False
|
||||||
|
if expires_at < time.time():
|
||||||
|
_failed_detail_cache.pop(key, None)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _failure_cache_set(key: str, ttl_seconds: int = FAILED_DETAIL_CACHE_TTL_SECONDS) -> None:
|
||||||
|
_failed_detail_cache[key] = time.time() + ttl_seconds
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_http_error_message(exc: httpx.HTTPStatusError) -> Optional[str]:
|
||||||
|
response = exc.response
|
||||||
|
if response is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
payload = response.json()
|
||||||
|
except ValueError:
|
||||||
|
payload = response.text
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
message = payload.get("message") or payload.get("error")
|
||||||
|
return str(message).strip() if message else json.dumps(payload, ensure_ascii=True)
|
||||||
|
if isinstance(payload, str):
|
||||||
|
trimmed = payload.strip()
|
||||||
|
return trimmed or None
|
||||||
|
return str(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _should_persist_seerr_media_failure(exc: httpx.HTTPStatusError) -> bool:
|
||||||
|
response = exc.response
|
||||||
|
if response is None:
|
||||||
|
return False
|
||||||
|
return response.status_code == 404 or response.status_code >= 500
|
||||||
|
|
||||||
|
|
||||||
def _status_label(value: Any) -> str:
|
def _status_label(value: Any) -> str:
|
||||||
@@ -383,9 +427,12 @@ async def _get_request_details(client: JellyseerrClient, request_id: int) -> Opt
|
|||||||
cached = _cache_get(cache_key)
|
cached = _cache_get(cache_key)
|
||||||
if isinstance(cached, dict):
|
if isinstance(cached, dict):
|
||||||
return cached
|
return cached
|
||||||
|
if _failure_cache_has(cache_key):
|
||||||
|
return None
|
||||||
try:
|
try:
|
||||||
fetched = await client.get_request(str(request_id))
|
fetched = await client.get_request(str(request_id))
|
||||||
except httpx.HTTPStatusError:
|
except httpx.HTTPStatusError:
|
||||||
|
_failure_cache_set(cache_key)
|
||||||
return None
|
return None
|
||||||
if isinstance(fetched, dict):
|
if isinstance(fetched, dict):
|
||||||
_cache_set(cache_key, fetched)
|
_cache_set(cache_key, fetched)
|
||||||
@@ -393,54 +440,80 @@ async def _get_request_details(client: JellyseerrClient, request_id: int) -> Opt
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_media_details(
|
||||||
|
client: JellyseerrClient, media_type: Optional[str], tmdb_id: Optional[int]
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
if not tmdb_id or not media_type:
|
||||||
|
return None
|
||||||
|
normalized_media_type = str(media_type).strip().lower()
|
||||||
|
if normalized_media_type not in {"movie", "tv"}:
|
||||||
|
return None
|
||||||
|
cache_key = f"media:{normalized_media_type}:{int(tmdb_id)}"
|
||||||
|
cached = _cache_get(cache_key)
|
||||||
|
if isinstance(cached, dict):
|
||||||
|
return cached
|
||||||
|
if is_seerr_media_failure_suppressed(normalized_media_type, int(tmdb_id)):
|
||||||
|
logger.debug(
|
||||||
|
"Seerr media hydration suppressed from db: media_type=%s tmdb_id=%s",
|
||||||
|
normalized_media_type,
|
||||||
|
tmdb_id,
|
||||||
|
)
|
||||||
|
_failure_cache_set(cache_key, ttl_seconds=FAILED_DETAIL_CACHE_TTL_SECONDS)
|
||||||
|
return None
|
||||||
|
if _failure_cache_has(cache_key):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
if normalized_media_type == "movie":
|
||||||
|
fetched = await client.get_movie(int(tmdb_id))
|
||||||
|
else:
|
||||||
|
fetched = await client.get_tv(int(tmdb_id))
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
_failure_cache_set(cache_key)
|
||||||
|
if _should_persist_seerr_media_failure(exc):
|
||||||
|
record_seerr_media_failure(
|
||||||
|
normalized_media_type,
|
||||||
|
int(tmdb_id),
|
||||||
|
status_code=exc.response.status_code if exc.response is not None else None,
|
||||||
|
error_message=_extract_http_error_message(exc),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
if isinstance(fetched, dict):
|
||||||
|
clear_seerr_media_failure(normalized_media_type, int(tmdb_id))
|
||||||
|
_cache_set(cache_key, fetched)
|
||||||
|
return fetched
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def _hydrate_title_from_tmdb(
|
async def _hydrate_title_from_tmdb(
|
||||||
client: JellyseerrClient, media_type: Optional[str], tmdb_id: Optional[int]
|
client: JellyseerrClient, media_type: Optional[str], tmdb_id: Optional[int]
|
||||||
) -> tuple[Optional[str], Optional[int]]:
|
) -> tuple[Optional[str], Optional[int]]:
|
||||||
if not tmdb_id or not media_type:
|
details = await _get_media_details(client, media_type, tmdb_id)
|
||||||
|
if not isinstance(details, dict):
|
||||||
return None, None
|
return None, None
|
||||||
try:
|
normalized_media_type = str(media_type).strip().lower() if media_type else None
|
||||||
if media_type == "movie":
|
if normalized_media_type == "movie":
|
||||||
details = await client.get_movie(int(tmdb_id))
|
|
||||||
if isinstance(details, dict):
|
|
||||||
title = details.get("title")
|
title = details.get("title")
|
||||||
release_date = details.get("releaseDate")
|
release_date = details.get("releaseDate")
|
||||||
year = int(release_date[:4]) if release_date else None
|
year = int(release_date[:4]) if release_date else None
|
||||||
return title, year
|
return title, year
|
||||||
if media_type == "tv":
|
if normalized_media_type == "tv":
|
||||||
details = await client.get_tv(int(tmdb_id))
|
|
||||||
if isinstance(details, dict):
|
|
||||||
title = details.get("name") or details.get("title")
|
title = details.get("name") or details.get("title")
|
||||||
first_air = details.get("firstAirDate")
|
first_air = details.get("firstAirDate")
|
||||||
year = int(first_air[:4]) if first_air else None
|
year = int(first_air[:4]) if first_air else None
|
||||||
return title, year
|
return title, year
|
||||||
except httpx.HTTPStatusError:
|
|
||||||
return None, None
|
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
async def _hydrate_artwork_from_tmdb(
|
async def _hydrate_artwork_from_tmdb(
|
||||||
client: JellyseerrClient, media_type: Optional[str], tmdb_id: Optional[int]
|
client: JellyseerrClient, media_type: Optional[str], tmdb_id: Optional[int]
|
||||||
) -> tuple[Optional[str], Optional[str]]:
|
) -> tuple[Optional[str], Optional[str]]:
|
||||||
if not tmdb_id or not media_type:
|
details = await _get_media_details(client, media_type, tmdb_id)
|
||||||
|
if not isinstance(details, dict):
|
||||||
return None, None
|
return None, None
|
||||||
try:
|
|
||||||
if media_type == "movie":
|
|
||||||
details = await client.get_movie(int(tmdb_id))
|
|
||||||
if isinstance(details, dict):
|
|
||||||
return (
|
return (
|
||||||
details.get("posterPath") or details.get("poster_path"),
|
details.get("posterPath") or details.get("poster_path"),
|
||||||
details.get("backdropPath") or details.get("backdrop_path"),
|
details.get("backdropPath") or details.get("backdrop_path"),
|
||||||
)
|
)
|
||||||
if media_type == "tv":
|
|
||||||
details = await client.get_tv(int(tmdb_id))
|
|
||||||
if isinstance(details, dict):
|
|
||||||
return (
|
|
||||||
details.get("posterPath") or details.get("poster_path"),
|
|
||||||
details.get("backdropPath") or details.get("backdrop_path"),
|
|
||||||
)
|
|
||||||
except httpx.HTTPStatusError:
|
|
||||||
return None, None
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
|
|
||||||
def _artwork_url(path: Optional[str], size: str, cache_mode: str) -> Optional[str]:
|
def _artwork_url(path: Optional[str], size: str, cache_mode: str) -> Optional[str]:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from typing import Any, Dict
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
from ..auth import get_current_user
|
from ..auth import get_current_user
|
||||||
|
from ..build_info import BUILD_NUMBER, CHANGELOG
|
||||||
from ..runtime import get_runtime_settings
|
from ..runtime import get_runtime_settings
|
||||||
|
|
||||||
router = APIRouter(prefix="/site", tags=["site"])
|
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:
|
if tone not in _BANNER_TONES:
|
||||||
tone = "info"
|
tone = "info"
|
||||||
info = {
|
info = {
|
||||||
"buildNumber": (runtime.site_build_number or "").strip(),
|
"buildNumber": (runtime.site_build_number or BUILD_NUMBER or "").strip(),
|
||||||
"banner": {
|
"banner": {
|
||||||
"enabled": bool(runtime.site_banner_enabled and banner_message),
|
"enabled": bool(runtime.site_banner_enabled and banner_message),
|
||||||
"message": banner_message,
|
"message": banner_message,
|
||||||
@@ -25,7 +26,7 @@ def _build_site_info(include_changelog: bool) -> Dict[str, Any]:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
if include_changelog:
|
if include_changelog:
|
||||||
info["changelog"] = (runtime.site_changelog or "").strip()
|
info["changelog"] = (CHANGELOG or "").strip()
|
||||||
return info
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from ..clients.sonarr import SonarrClient
|
|||||||
from ..config import settings as env_settings
|
from ..config import settings as env_settings
|
||||||
from ..db import run_integrity_check
|
from ..db import run_integrity_check
|
||||||
from ..runtime import get_runtime_settings
|
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]]]
|
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]:
|
async def _run_email_check(recipient_email: Optional[str] = None) -> Dict[str, Any]:
|
||||||
result = await send_test_email(recipient_email=recipient_email)
|
result = await send_test_email(recipient_email=recipient_email)
|
||||||
recipient = _clean_text(result.get("recipient_email"), "configured recipient")
|
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}
|
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)
|
push_target = _url_target(runtime.magent_notify_push_base_url)
|
||||||
|
|
||||||
email_ready, email_detail = smtp_email_config_ready()
|
email_ready, email_detail = smtp_email_config_ready()
|
||||||
|
email_warning = smtp_email_delivery_warning()
|
||||||
discord_ready, discord_detail = _discord_config_ready(runtime)
|
discord_ready, discord_detail = _discord_config_ready(runtime)
|
||||||
telegram_ready, telegram_detail = _telegram_config_ready(runtime)
|
telegram_ready, telegram_detail = _telegram_config_ready(runtime)
|
||||||
push_ready, push_detail = _push_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.",
|
description="Sends a live test email using the configured SMTP provider.",
|
||||||
live_safe=False,
|
live_safe=False,
|
||||||
configured=email_ready,
|
configured=email_ready,
|
||||||
config_detail=email_detail,
|
config_detail=email_warning or email_detail,
|
||||||
target=smtp_target,
|
target=smtp_target,
|
||||||
runner=lambda recipient_email=recipient_email: _run_email_check(recipient_email),
|
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"
|
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:
|
def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body_html: str) -> None:
|
||||||
runtime = get_runtime_settings()
|
runtime = get_runtime_settings()
|
||||||
host = _normalize_display_text(runtime.magent_notify_email_smtp_host)
|
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)
|
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_tls = bool(runtime.magent_notify_email_use_tls)
|
||||||
use_ssl = bool(runtime.magent_notify_email_use_ssl)
|
use_ssl = bool(runtime.magent_notify_email_use_ssl)
|
||||||
|
delivery_warning = smtp_email_delivery_warning()
|
||||||
if not host or not from_address:
|
if not host or not from_address:
|
||||||
raise RuntimeError("SMTP email settings are incomplete.")
|
raise RuntimeError("SMTP email settings are incomplete.")
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -405,6 +420,8 @@ def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body
|
|||||||
bool(username and password),
|
bool(username and password),
|
||||||
subject,
|
subject,
|
||||||
)
|
)
|
||||||
|
if delivery_warning:
|
||||||
|
logger.warning("smtp delivery warning host=%s detail=%s", host, delivery_warning)
|
||||||
|
|
||||||
message = EmailMessage()
|
message = EmailMessage()
|
||||||
message["Subject"] = subject
|
message["Subject"] = subject
|
||||||
@@ -515,4 +532,8 @@ async def send_test_email(recipient_email: Optional[str] = None) -> Dict[str, st
|
|||||||
body_html=body_html,
|
body_html=body_html,
|
||||||
)
|
)
|
||||||
logger.info("SMTP test email sent: recipient=%s", resolved_email)
|
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
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
import httpx
|
||||||
|
|
||||||
from ..clients.jellyseerr import JellyseerrClient
|
from ..clients.jellyseerr import JellyseerrClient
|
||||||
from ..clients.jellyfin import JellyfinClient
|
from ..clients.jellyfin import JellyfinClient
|
||||||
@@ -18,6 +19,9 @@ from ..db import (
|
|||||||
get_recent_snapshots,
|
get_recent_snapshots,
|
||||||
get_setting,
|
get_setting,
|
||||||
set_setting,
|
set_setting,
|
||||||
|
is_seerr_media_failure_suppressed,
|
||||||
|
record_seerr_media_failure,
|
||||||
|
clear_seerr_media_failure,
|
||||||
)
|
)
|
||||||
from ..models import ActionOption, NormalizedState, RequestType, Snapshot, TimelineHop
|
from ..models import ActionOption, NormalizedState, RequestType, Snapshot, TimelineHop
|
||||||
|
|
||||||
@@ -53,6 +57,59 @@ def _pick_first(value: Any) -> Optional[Dict[str, Any]]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_http_error_message(exc: httpx.HTTPStatusError) -> Optional[str]:
|
||||||
|
response = exc.response
|
||||||
|
if response is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
payload = response.json()
|
||||||
|
except ValueError:
|
||||||
|
payload = response.text
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
message = payload.get("message") or payload.get("error")
|
||||||
|
return str(message).strip() if message else str(payload)
|
||||||
|
if isinstance(payload, str):
|
||||||
|
trimmed = payload.strip()
|
||||||
|
return trimmed or None
|
||||||
|
return str(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _should_persist_seerr_media_failure(exc: httpx.HTTPStatusError) -> bool:
|
||||||
|
response = exc.response
|
||||||
|
if response is None:
|
||||||
|
return False
|
||||||
|
return response.status_code == 404 or response.status_code >= 500
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_seerr_media_details(
|
||||||
|
jellyseerr: JellyseerrClient, request_type: RequestType, tmdb_id: int
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
media_type = request_type.value
|
||||||
|
if media_type not in {"movie", "tv"}:
|
||||||
|
return None
|
||||||
|
if is_seerr_media_failure_suppressed(media_type, tmdb_id):
|
||||||
|
logger.debug("Seerr snapshot hydration suppressed: media_type=%s tmdb_id=%s", media_type, tmdb_id)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
if request_type == RequestType.movie:
|
||||||
|
details = await jellyseerr.get_movie(int(tmdb_id))
|
||||||
|
else:
|
||||||
|
details = await jellyseerr.get_tv(int(tmdb_id))
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
if _should_persist_seerr_media_failure(exc):
|
||||||
|
record_seerr_media_failure(
|
||||||
|
media_type,
|
||||||
|
int(tmdb_id),
|
||||||
|
status_code=exc.response.status_code if exc.response is not None else None,
|
||||||
|
error_message=_extract_http_error_message(exc),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
if isinstance(details, dict):
|
||||||
|
clear_seerr_media_failure(media_type, int(tmdb_id))
|
||||||
|
return details
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def _maybe_refresh_jellyfin(snapshot: Snapshot) -> None:
|
async def _maybe_refresh_jellyfin(snapshot: Snapshot) -> None:
|
||||||
if snapshot.state not in {NormalizedState.available, NormalizedState.completed}:
|
if snapshot.state not in {NormalizedState.available, NormalizedState.completed}:
|
||||||
return
|
return
|
||||||
@@ -300,22 +357,13 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
|||||||
if snapshot.title in {None, "", "Unknown"} and allow_remote:
|
if snapshot.title in {None, "", "Unknown"} and allow_remote:
|
||||||
tmdb_id = jelly_request.get("media", {}).get("tmdbId")
|
tmdb_id = jelly_request.get("media", {}).get("tmdbId")
|
||||||
if tmdb_id:
|
if tmdb_id:
|
||||||
try:
|
details = await _get_seerr_media_details(jellyseerr, snapshot.request_type, int(tmdb_id))
|
||||||
if snapshot.request_type == RequestType.movie:
|
|
||||||
details = await jellyseerr.get_movie(int(tmdb_id))
|
|
||||||
if isinstance(details, dict):
|
if isinstance(details, dict):
|
||||||
|
if snapshot.request_type == RequestType.movie:
|
||||||
snapshot.title = details.get("title") or snapshot.title
|
snapshot.title = details.get("title") or snapshot.title
|
||||||
release_date = details.get("releaseDate")
|
release_date = details.get("releaseDate")
|
||||||
snapshot.year = int(release_date[:4]) if release_date else snapshot.year
|
snapshot.year = int(release_date[:4]) if release_date else snapshot.year
|
||||||
poster_path = poster_path or details.get("posterPath") or details.get("poster_path")
|
|
||||||
backdrop_path = (
|
|
||||||
backdrop_path
|
|
||||||
or details.get("backdropPath")
|
|
||||||
or details.get("backdrop_path")
|
|
||||||
)
|
|
||||||
elif snapshot.request_type == RequestType.tv:
|
elif snapshot.request_type == RequestType.tv:
|
||||||
details = await jellyseerr.get_tv(int(tmdb_id))
|
|
||||||
if isinstance(details, dict):
|
|
||||||
snapshot.title = details.get("name") or details.get("title") or snapshot.title
|
snapshot.title = details.get("name") or details.get("title") or snapshot.title
|
||||||
first_air = details.get("firstAirDate")
|
first_air = details.get("firstAirDate")
|
||||||
snapshot.year = int(first_air[:4]) if first_air else snapshot.year
|
snapshot.year = int(first_air[:4]) if first_air else snapshot.year
|
||||||
@@ -325,8 +373,6 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
|||||||
or details.get("backdropPath")
|
or details.get("backdropPath")
|
||||||
or details.get("backdrop_path")
|
or details.get("backdrop_path")
|
||||||
)
|
)
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
cache_mode = (runtime.artwork_cache_mode or "remote").lower()
|
cache_mode = (runtime.artwork_cache_mode or "remote").lower()
|
||||||
snapshot.artwork = {
|
snapshot.artwork = {
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ const SECTION_DESCRIPTIONS: Record<string, string> = {
|
|||||||
qbittorrent: 'Downloader connection settings.',
|
qbittorrent: 'Downloader connection settings.',
|
||||||
requests: 'Control how often requests are refreshed and cleaned up.',
|
requests: 'Control how often requests are refreshed and cleaned up.',
|
||||||
log: 'Activity log for troubleshooting.',
|
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> = {
|
const SETTINGS_SECTION_MAP: Record<string, string | null> = {
|
||||||
@@ -555,7 +555,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
const isCacheSection = section === 'cache'
|
const isCacheSection = section === 'cache'
|
||||||
const cacheSettingKeys = new Set(['requests_sync_ttl_minutes', 'requests_data_source'])
|
const cacheSettingKeys = new Set(['requests_sync_ttl_minutes', 'requests_data_source'])
|
||||||
const artworkSettingKeys = new Set(['artwork_cache_mode'])
|
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 = [
|
const requestSettingOrder = [
|
||||||
'requests_poll_interval_seconds',
|
'requests_poll_interval_seconds',
|
||||||
'requests_delta_sync_interval_minutes',
|
'requests_delta_sync_interval_minutes',
|
||||||
@@ -608,7 +609,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
items: (() => {
|
items: (() => {
|
||||||
const sectionItems = groupedSettings[sectionKey] ?? []
|
const sectionItems = groupedSettings[sectionKey] ?? []
|
||||||
const filtered =
|
const filtered =
|
||||||
sectionKey === 'requests' || sectionKey === 'artwork'
|
sectionKey === 'requests' || sectionKey === 'artwork' || sectionKey === 'site'
|
||||||
? sectionItems.filter((setting) => !hiddenSettingKeys.has(setting.key))
|
? sectionItems.filter((setting) => !hiddenSettingKeys.has(setting.key))
|
||||||
: sectionItems
|
: sectionItems
|
||||||
if (sectionKey === 'requests') {
|
if (sectionKey === 'requests') {
|
||||||
@@ -940,8 +941,10 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
setSectionFeedback((current) => ({
|
setSectionFeedback((current) => ({
|
||||||
...current,
|
...current,
|
||||||
[sectionGroup.key]: {
|
[sectionGroup.key]: {
|
||||||
tone: 'status',
|
tone: data?.warning ? 'error' : 'status',
|
||||||
message: `Test email sent to ${data?.recipient_email ?? 'the configured mailbox'}.`,
|
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
|
return
|
||||||
@@ -1613,11 +1616,11 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
>
|
>
|
||||||
{status && <div className="error-banner">{status}</div>}
|
{status && <div className="error-banner">{status}</div>}
|
||||||
{settingsSections.length > 0 ? (
|
{settingsSections.length > 0 ? (
|
||||||
<div className="admin-form">
|
<div className="admin-form admin-zone-stack">
|
||||||
{settingsSections
|
{settingsSections
|
||||||
.filter(shouldRenderSection)
|
.filter(shouldRenderSection)
|
||||||
.map((sectionGroup) => (
|
.map((sectionGroup) => (
|
||||||
<section key={sectionGroup.key} className="admin-section">
|
<section key={sectionGroup.key} className="admin-section admin-zone">
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<h2>
|
<h2>
|
||||||
{sectionGroup.key === 'requests' ? 'Request sync controls' : sectionGroup.title}
|
{sectionGroup.key === 'requests' ? 'Request sync controls' : sectionGroup.title}
|
||||||
@@ -2228,6 +2231,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
) : null}
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
className="settings-action-button"
|
||||||
onClick={() => void saveSettingGroup(sectionGroup)}
|
onClick={() => void saveSettingGroup(sectionGroup)}
|
||||||
disabled={sectionSaving[sectionGroup.key] || sectionTesting[sectionGroup.key]}
|
disabled={sectionSaving[sectionGroup.key] || sectionTesting[sectionGroup.key]}
|
||||||
>
|
>
|
||||||
@@ -2236,7 +2240,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
{getSectionTestLabel(sectionGroup.key) ? (
|
{getSectionTestLabel(sectionGroup.key) ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="ghost-button"
|
className="ghost-button settings-action-button"
|
||||||
onClick={() => void testSettingGroup(sectionGroup)}
|
onClick={() => void testSettingGroup(sectionGroup)}
|
||||||
disabled={sectionSaving[sectionGroup.key] || sectionTesting[sectionGroup.key]}
|
disabled={sectionSaving[sectionGroup.key] || sectionTesting[sectionGroup.key]}
|
||||||
>
|
>
|
||||||
@@ -2257,7 +2261,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showLogs && (
|
{showLogs && (
|
||||||
<section className="admin-section" id="logs">
|
<section className="admin-section admin-zone" id="logs">
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<h2>Activity log</h2>
|
<h2>Activity log</h2>
|
||||||
<div className="log-actions">
|
<div className="log-actions">
|
||||||
@@ -2283,7 +2287,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{showCacheExtras && (
|
{showCacheExtras && (
|
||||||
<section className="admin-section" id="cache">
|
<section className="admin-section admin-zone" id="cache">
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<h2>Saved requests (cache)</h2>
|
<h2>Saved requests (cache)</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -2312,7 +2316,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{showMaintenance && (
|
{showMaintenance && (
|
||||||
<section className="admin-section" id="maintenance">
|
<section className="admin-section admin-zone" id="maintenance">
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<h2>Maintenance</h2>
|
<h2>Maintenance</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -2379,7 +2383,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{showRequestsExtras && (
|
{showRequestsExtras && (
|
||||||
<section className="admin-section" id="schedules">
|
<section className="admin-section admin-zone" id="schedules">
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<h2>Scheduled tasks</h2>
|
<h2>Scheduled tasks</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -106,9 +106,9 @@ export default function AdminSystemGuidePage() {
|
|||||||
const rail = (
|
const rail = (
|
||||||
<div className="admin-rail-stack">
|
<div className="admin-rail-stack">
|
||||||
<div className="admin-rail-card">
|
<div className="admin-rail-card">
|
||||||
<span className="admin-rail-eyebrow">Guide map</span>
|
<span className="admin-rail-eyebrow">How it works</span>
|
||||||
<h2>Quick path</h2>
|
<h2>Admin flow map</h2>
|
||||||
<p>Identity → Intake → Queue → Download → Import → Playback.</p>
|
<p>Identity → Request intake → Queue orchestration → Download → Import → Playback.</p>
|
||||||
<span className="small-pill">Admin only</span>
|
<span className="small-pill">Admin only</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -116,8 +116,8 @@ export default function AdminSystemGuidePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminShell
|
<AdminShell
|
||||||
title="System guide"
|
title="How it works"
|
||||||
subtitle="Admin-only architecture and operational flow for Magent."
|
subtitle="Admin-only service wiring, control areas, and recovery flow for Magent."
|
||||||
rail={rail}
|
rail={rail}
|
||||||
actions={
|
actions={
|
||||||
<button type="button" onClick={() => router.push('/admin')}>
|
<button type="button" onClick={() => router.push('/admin')}>
|
||||||
@@ -129,7 +129,8 @@ export default function AdminSystemGuidePage() {
|
|||||||
<div className="admin-panel">
|
<div className="admin-panel">
|
||||||
<h2>End-to-end system flow</h2>
|
<h2>End-to-end system flow</h2>
|
||||||
<p className="lede">
|
<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>
|
</p>
|
||||||
<div className="system-flow-track">
|
<div className="system-flow-track">
|
||||||
{REQUEST_FLOW.map((stage, index) => (
|
{REQUEST_FLOW.map((stage, index) => (
|
||||||
@@ -155,6 +156,51 @@ export default function AdminSystemGuidePage() {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="admin-panel">
|
||||||
<h2>Operational controls by area</h2>
|
<h2>Operational controls by area</h2>
|
||||||
<div className="system-guide-grid">
|
<div className="system-guide-grid">
|
||||||
@@ -172,19 +218,48 @@ export default function AdminSystemGuidePage() {
|
|||||||
</article>
|
</article>
|
||||||
<article className="system-guide-card">
|
<article className="system-guide-card">
|
||||||
<h3>Invite management</h3>
|
<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>
|
||||||
<article className="system-guide-card">
|
<article className="system-guide-card">
|
||||||
<h3>Requests + cache</h3>
|
<h3>Requests + cache</h3>
|
||||||
<p>All-requests view, sync controls, cached request records, and maintenance operations.</p>
|
<p>All-requests view, sync controls, cached request records, and maintenance operations.</p>
|
||||||
</article>
|
</article>
|
||||||
<article className="system-guide-card">
|
<article className="system-guide-card">
|
||||||
<h3>Live request page</h3>
|
<h3>Maintenance + diagnostics</h3>
|
||||||
<p>Event-stream updates for state, action history, and torrent progress without page refresh.</p>
|
<p>
|
||||||
|
Connectivity checks, live diagnostics, database repair, cleanup, log review, and
|
||||||
|
nuclear flush/resync operations.
|
||||||
|
</p>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="admin-panel">
|
||||||
<h2>Stall recovery path (decision flow)</h2>
|
<h2>Stall recovery path (decision flow)</h2>
|
||||||
<ol className="system-decision-list">
|
<ol className="system-decision-list">
|
||||||
@@ -205,6 +280,24 @@ export default function AdminSystemGuidePage() {
|
|||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
</AdminShell>
|
</AdminShell>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,15 +8,42 @@ type SiteInfo = {
|
|||||||
changelog?: string
|
changelog?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseChangelog = (raw: string) =>
|
type ChangelogGroup = {
|
||||||
raw
|
date: string
|
||||||
.split('\n')
|
entries: string[]
|
||||||
.map((line) => line.trim())
|
}
|
||||||
.filter(Boolean)
|
|
||||||
|
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() {
|
export default function ChangelogPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [entries, setEntries] = useState<string[]>([])
|
const [groups, setGroups] = useState<ChangelogGroup[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -40,11 +67,11 @@ export default function ChangelogPage() {
|
|||||||
}
|
}
|
||||||
const data: SiteInfo = await response.json()
|
const data: SiteInfo = await response.json()
|
||||||
if (!active) return
|
if (!active) return
|
||||||
setEntries(parseChangelog(data?.changelog ?? ''))
|
setGroups(parseChangelog(data?.changelog ?? ''))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
if (!active) return
|
if (!active) return
|
||||||
setEntries([])
|
setGroups([])
|
||||||
} finally {
|
} finally {
|
||||||
if (active) setLoading(false)
|
if (active) setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -59,17 +86,24 @@ export default function ChangelogPage() {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="loading-text">Loading changelog...</div>
|
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 <div className="meta">No updates posted yet.</div>
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
|
<div className="changelog-groups">
|
||||||
|
{groups.map((group) => (
|
||||||
|
<section key={group.date} className="changelog-group">
|
||||||
|
<h2>{group.date}</h2>
|
||||||
<ul className="changelog-list">
|
<ul className="changelog-list">
|
||||||
{entries.map((entry, index) => (
|
{group.entries.map((entry, index) => (
|
||||||
<li key={`${entry}-${index}`}>{entry}</li>
|
<li key={`${group.date}-${entry}-${index}`}>{entry}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}, [entries, loading])
|
}, [groups, loading])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
|
|||||||
@@ -1545,6 +1545,13 @@ button span {
|
|||||||
align-items: end;
|
align-items: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-section-actions .settings-action-button {
|
||||||
|
width: 190px;
|
||||||
|
min-width: 190px;
|
||||||
|
flex: 0 0 190px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-inline-field {
|
.settings-inline-field {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -2362,6 +2369,31 @@ button span {
|
|||||||
font-size: 15px;
|
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) */
|
/* Professional UI Refresh (graphite / silver / black + subtle blue accents) */
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
@@ -3662,16 +3694,28 @@ button:disabled {
|
|||||||
.error-banner,
|
.error-banner,
|
||||||
.status-banner {
|
.status-banner {
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
border: 1px solid rgba(244, 114, 114, 0.2);
|
border: 1px solid rgba(244, 114, 114, 0.2);
|
||||||
background: rgba(244, 114, 114, 0.1);
|
background: rgba(244, 114, 114, 0.1);
|
||||||
color: var(--error-ink);
|
color: var(--error-ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme='dark'] .error-banner,
|
.status-banner {
|
||||||
[data-theme='dark'] .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;
|
color: #ffd9d9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] .status-banner {
|
||||||
|
color: #dcfce7;
|
||||||
|
}
|
||||||
|
|
||||||
.auth-card {
|
.auth-card {
|
||||||
max-width: 520px;
|
max-width: 520px;
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
@@ -5221,6 +5265,26 @@ textarea {
|
|||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-quick-link-card {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: start;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
background: rgba(255, 255, 255, 0.018);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-quick-link-card h2 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-quick-link-card .lede {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.profile-invites-section {
|
.profile-invites-section {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -5391,6 +5455,10 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
|
.profile-quick-link-card {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
.profile-invites-layout {
|
.profile-invites-layout {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -6070,3 +6138,370 @@ textarea {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Final responsive admin shell stabilization */
|
||||||
|
.admin-shell,
|
||||||
|
.admin-shell-nav,
|
||||||
|
.admin-card,
|
||||||
|
.admin-shell-rail,
|
||||||
|
.admin-sidebar,
|
||||||
|
.admin-panel {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1280px) {
|
||||||
|
.admin-shell {
|
||||||
|
grid-template-columns: minmax(220px, 250px) minmax(0, 1fr);
|
||||||
|
grid-template-areas:
|
||||||
|
"nav main"
|
||||||
|
"nav rail";
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell-nav {
|
||||||
|
grid-area: nav;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-card {
|
||||||
|
grid-area: main;
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell-rail {
|
||||||
|
grid-area: rail;
|
||||||
|
grid-column: auto;
|
||||||
|
position: static;
|
||||||
|
top: auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1080px) {
|
||||||
|
.page {
|
||||||
|
width: min(100%, calc(100vw - 12px));
|
||||||
|
max-width: none;
|
||||||
|
padding-inline: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card,
|
||||||
|
.admin-card {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
grid-template-areas:
|
||||||
|
"nav"
|
||||||
|
"main"
|
||||||
|
"rail";
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell-nav,
|
||||||
|
.admin-card,
|
||||||
|
.admin-shell-rail {
|
||||||
|
grid-column: auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell-nav {
|
||||||
|
position: static;
|
||||||
|
top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar,
|
||||||
|
.admin-rail-stack,
|
||||||
|
.admin-rail-card,
|
||||||
|
.maintenance-layout,
|
||||||
|
.maintenance-tools-panel,
|
||||||
|
.cache-table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-grid,
|
||||||
|
.users-page-toolbar-grid,
|
||||||
|
.users-summary-grid,
|
||||||
|
.users-page-overview-grid,
|
||||||
|
.maintenance-action-grid,
|
||||||
|
.schedule-grid,
|
||||||
|
.diagnostics-inline-summary,
|
||||||
|
.diagnostics-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav,
|
||||||
|
.settings-links {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-group {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-links a {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section-actions,
|
||||||
|
.diagnostics-control-panel,
|
||||||
|
.diagnostics-control-actions,
|
||||||
|
.log-actions {
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section-actions > *,
|
||||||
|
.diagnostics-control-actions > *,
|
||||||
|
.log-actions > * {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section-actions .settings-action-button {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
flex-basis: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-meta,
|
||||||
|
.diagnostic-card-top,
|
||||||
|
.diagnostics-category-header,
|
||||||
|
.users-summary-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-row span {
|
||||||
|
white-space: normal;
|
||||||
|
overflow: visible;
|
||||||
|
text-overflow: clip;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Final admin shell + settings section cleanup */
|
||||||
|
.admin-shell,
|
||||||
|
.admin-shell-nav,
|
||||||
|
.admin-card,
|
||||||
|
.admin-shell-rail,
|
||||||
|
.admin-sidebar,
|
||||||
|
.admin-panel {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, 260px) minmax(0, 1fr);
|
||||||
|
grid-template-areas: "nav main";
|
||||||
|
gap: 22px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell.admin-shell--with-rail {
|
||||||
|
grid-template-columns: minmax(220px, 260px) minmax(0, 1fr) minmax(300px, 380px);
|
||||||
|
grid-template-areas: "nav main rail";
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell-nav {
|
||||||
|
grid-area: nav;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-card {
|
||||||
|
grid-area: main;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell-rail {
|
||||||
|
grid-area: rail;
|
||||||
|
position: sticky;
|
||||||
|
top: 20px;
|
||||||
|
align-self: start;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-zone-stack {
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-zone {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.015)),
|
||||||
|
rgba(255, 255, 255, 0.012);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.025);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='light'] .admin-zone {
|
||||||
|
border-color: rgba(17, 19, 24, 0.08);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(255, 255, 255, 0.72)),
|
||||||
|
rgba(17, 19, 24, 0.018);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-zone .section-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='light'] .admin-zone .section-header {
|
||||||
|
border-bottom-color: rgba(17, 19, 24, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-zone .section-header h2 {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-zone .section-header h2::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(90deg, var(--accent-2), rgba(255, 255, 255, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-zone .section-subtitle {
|
||||||
|
margin-top: -4px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1280px) {
|
||||||
|
.admin-shell {
|
||||||
|
grid-template-columns: minmax(220px, 250px) minmax(0, 1fr);
|
||||||
|
grid-template-areas: "nav main";
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell.admin-shell--with-rail {
|
||||||
|
grid-template-areas:
|
||||||
|
"nav main"
|
||||||
|
"nav rail";
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell-rail {
|
||||||
|
position: static;
|
||||||
|
top: auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1080px) {
|
||||||
|
.admin-shell,
|
||||||
|
.admin-shell.admin-shell--with-rail {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell {
|
||||||
|
grid-template-areas:
|
||||||
|
"nav"
|
||||||
|
"main";
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell.admin-shell--with-rail {
|
||||||
|
grid-template-areas:
|
||||||
|
"nav"
|
||||||
|
"main"
|
||||||
|
"rail";
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell-nav,
|
||||||
|
.admin-card,
|
||||||
|
.admin-shell-rail {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell-nav {
|
||||||
|
position: static;
|
||||||
|
top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-grid,
|
||||||
|
.users-page-toolbar-grid,
|
||||||
|
.users-summary-grid,
|
||||||
|
.users-page-overview-grid,
|
||||||
|
.maintenance-action-grid,
|
||||||
|
.schedule-grid,
|
||||||
|
.diagnostics-inline-summary,
|
||||||
|
.diagnostics-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-zone {
|
||||||
|
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 (
|
return (
|
||||||
<main className="card how-page">
|
<main className="card how-page">
|
||||||
<header className="how-hero">
|
<header className="how-hero">
|
||||||
<p className="eyebrow">How this works</p>
|
<p className="eyebrow">How it works</p>
|
||||||
<h1>How Magent works now</h1>
|
<h1>How Magent works for users</h1>
|
||||||
<p className="lede">
|
<p className="lede">
|
||||||
End-to-end request flow, live status updates, and the exact tools available to users and
|
Use Magent to find a request, watch it move through the pipeline, and know when it is
|
||||||
admins.
|
ready without constantly refreshing the page.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</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">
|
<section className="how-flow">
|
||||||
<h2>The pipeline (request to ready)</h2>
|
<h2>What Magent is for</h2>
|
||||||
<ol className="how-steps">
|
<div className="how-grid">
|
||||||
<li>
|
<article className="how-card">
|
||||||
<strong>Request created</strong> in Seerr.
|
<h3>Track requests</h3>
|
||||||
</li>
|
<p>
|
||||||
<li>
|
Search by title, year, or request number to open the request page and see where an
|
||||||
<strong>Approved</strong> and sent to Sonarr/Radarr.
|
item is up to.
|
||||||
</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.
|
|
||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
<article className="how-step-card step-qbit">
|
<article className="how-card">
|
||||||
<div className="step-badge">2</div>
|
<h3>See live progress</h3>
|
||||||
<h3>Download progress updates live</h3>
|
<p>
|
||||||
<p className="step-note">
|
Request status, timeline events, and download progress update live while you are
|
||||||
Torrent progress, queue state, and downloader details refresh automatically so users
|
viewing the page.
|
||||||
do not need to hard refresh.
|
|
||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
<article className="how-step-card step-jellyfin">
|
<article className="how-card">
|
||||||
<div className="step-badge">3</div>
|
<h3>Know when it is ready</h3>
|
||||||
<h3>Ready state appears as soon as import finishes</h3>
|
<p>
|
||||||
<p className="step-note">
|
When the request is fully imported and available, Magent shows it as ready and links
|
||||||
As soon as Sonarr/Radarr import completes and Jellyfin can serve it, the request page
|
you through to Jellyfin.
|
||||||
shows it as ready.
|
|
||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="how-flow">
|
<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">
|
<div className="how-step-grid">
|
||||||
<article className="how-step-card step-seerr">
|
<article className="how-step-card step-seerr">
|
||||||
<div className="step-badge">1</div>
|
<div className="step-badge">1</div>
|
||||||
<h3>Re-add to Arr</h3>
|
<h3>Recent requests refresh automatically</h3>
|
||||||
<p className="step-note">Use when a request is approved but never entered the Arr queue.</p>
|
<p className="step-note">
|
||||||
<div className="step-fix-title">Best for</div>
|
Your request list and landing-page activity update automatically while you are signed
|
||||||
<ul className="step-fix-list">
|
in.
|
||||||
<li>Missing NEEDS_ADD / ADDED state transitions</li>
|
</p>
|
||||||
<li>Queue repair after Arr-side cleanup</li>
|
|
||||||
</ul>
|
|
||||||
</article>
|
</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">
|
<article className="how-step-card step-qbit">
|
||||||
<div className="step-badge">4</div>
|
<div className="step-badge">2</div>
|
||||||
<h3>Resume download</h3>
|
<h3>Request pages update in real time</h3>
|
||||||
<p className="step-note">Resumes a paused/stopped torrent in qBittorrent.</p>
|
<p className="step-note">
|
||||||
<div className="step-fix-title">Best for</div>
|
State changes, timeline steps, and downloader progress are pushed to the page live.
|
||||||
<ul className="step-fix-list">
|
</p>
|
||||||
<li>Paused queue entries</li>
|
|
||||||
<li>Downloader restarts</li>
|
|
||||||
</ul>
|
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article className="how-step-card step-jellyfin">
|
<article className="how-step-card step-jellyfin">
|
||||||
<div className="step-badge">5</div>
|
<div className="step-badge">3</div>
|
||||||
<h3>Open in Jellyfin</h3>
|
<h3>Ready state appears as soon as the import completes</h3>
|
||||||
<p className="step-note">Available when the item is imported and linked to Jellyfin.</p>
|
<p className="step-note">
|
||||||
<div className="step-fix-title">Best for</div>
|
Once the content is actually available, Magent updates the request page without a hard
|
||||||
<ul className="step-fix-list">
|
refresh.
|
||||||
<li>Immediate playback confirmation</li>
|
</p>
|
||||||
<li>User handoff from request tracking to watching</li>
|
|
||||||
</ul>
|
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="how-flow">
|
<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">
|
<ol className="how-steps">
|
||||||
<li>
|
<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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
</section>
|
</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">
|
<section className="how-callout">
|
||||||
<h2>Why a request can still wait</h2>
|
<h2>If a request looks stuck</h2>
|
||||||
<p>
|
<p>
|
||||||
If indexers do not return a valid release yet, Magent will show waiting/search states.
|
A waiting request usually means no usable release has been found yet, the download is
|
||||||
That usually means content availability is the blocker, not a broken pipeline.
|
still in progress, or the import has not completed. Magent will keep updating as the
|
||||||
|
underlying services move forward.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
624
frontend/app/profile/invites/page.tsx
Normal file
624
frontend/app/profile/invites/page.tsx
Normal file
@@ -0,0 +1,624 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
|
||||||
|
|
||||||
|
type ProfileInfo = {
|
||||||
|
username: string
|
||||||
|
role: string
|
||||||
|
auth_provider: string
|
||||||
|
invite_management_enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProfileResponse = {
|
||||||
|
user: ProfileInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
type OwnedInvite = {
|
||||||
|
id: number
|
||||||
|
code: string
|
||||||
|
label?: string | null
|
||||||
|
description?: string | null
|
||||||
|
recipient_email?: string | null
|
||||||
|
max_uses?: number | null
|
||||||
|
use_count: number
|
||||||
|
remaining_uses?: number | null
|
||||||
|
enabled: boolean
|
||||||
|
expires_at?: string | null
|
||||||
|
is_expired?: boolean
|
||||||
|
is_usable?: boolean
|
||||||
|
created_at?: string | null
|
||||||
|
updated_at?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type OwnedInvitesResponse = {
|
||||||
|
invites?: OwnedInvite[]
|
||||||
|
count?: number
|
||||||
|
invite_access?: {
|
||||||
|
enabled?: boolean
|
||||||
|
managed_by_master?: boolean
|
||||||
|
}
|
||||||
|
master_invite?: {
|
||||||
|
id: number
|
||||||
|
code: string
|
||||||
|
label?: string | null
|
||||||
|
description?: string | null
|
||||||
|
max_uses?: number | null
|
||||||
|
enabled?: boolean
|
||||||
|
expires_at?: string | null
|
||||||
|
is_usable?: boolean
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type OwnedInviteForm = {
|
||||||
|
code: string
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
recipient_email: string
|
||||||
|
max_uses: string
|
||||||
|
expires_at: string
|
||||||
|
enabled: boolean
|
||||||
|
send_email: boolean
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOwnedInviteForm = (): OwnedInviteForm => ({
|
||||||
|
code: '',
|
||||||
|
label: '',
|
||||||
|
description: '',
|
||||||
|
recipient_email: '',
|
||||||
|
max_uses: '',
|
||||||
|
expires_at: '',
|
||||||
|
enabled: true,
|
||||||
|
send_email: false,
|
||||||
|
message: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatDate = (value?: string | null) => {
|
||||||
|
if (!value) return 'Never'
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.valueOf())) return value
|
||||||
|
return date.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProfileInvitesPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [profile, setProfile] = useState<ProfileInfo | null>(null)
|
||||||
|
const [inviteStatus, setInviteStatus] = useState<string | null>(null)
|
||||||
|
const [inviteError, setInviteError] = useState<string | null>(null)
|
||||||
|
const [invites, setInvites] = useState<OwnedInvite[]>([])
|
||||||
|
const [inviteSaving, setInviteSaving] = useState(false)
|
||||||
|
const [inviteEditingId, setInviteEditingId] = useState<number | null>(null)
|
||||||
|
const [inviteForm, setInviteForm] = useState<OwnedInviteForm>(defaultOwnedInviteForm())
|
||||||
|
const [inviteAccessEnabled, setInviteAccessEnabled] = useState(false)
|
||||||
|
const [inviteManagedByMaster, setInviteManagedByMaster] = useState(false)
|
||||||
|
const [masterInviteTemplate, setMasterInviteTemplate] = useState<OwnedInvitesResponse['master_invite']>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const signupBaseUrl = useMemo(() => {
|
||||||
|
if (typeof window === 'undefined') return '/signup'
|
||||||
|
return `${window.location.origin}/signup`
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadPage = async () => {
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
const [profileResponse, invitesResponse] = await Promise.all([
|
||||||
|
authFetch(`${baseUrl}/auth/profile`),
|
||||||
|
authFetch(`${baseUrl}/auth/profile/invites`),
|
||||||
|
])
|
||||||
|
if (!profileResponse.ok || !invitesResponse.ok) {
|
||||||
|
if (profileResponse.status === 401 || invitesResponse.status === 401) {
|
||||||
|
clearToken()
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw new Error('Could not load invite tools.')
|
||||||
|
}
|
||||||
|
const [profileData, inviteData] = (await Promise.all([
|
||||||
|
profileResponse.json(),
|
||||||
|
invitesResponse.json(),
|
||||||
|
])) as [ProfileResponse, OwnedInvitesResponse]
|
||||||
|
const user = profileData?.user ?? {}
|
||||||
|
setProfile({
|
||||||
|
username: user?.username ?? 'Unknown',
|
||||||
|
role: user?.role ?? 'user',
|
||||||
|
auth_provider: user?.auth_provider ?? 'local',
|
||||||
|
invite_management_enabled: Boolean(user?.invite_management_enabled ?? false),
|
||||||
|
})
|
||||||
|
setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : [])
|
||||||
|
setInviteAccessEnabled(Boolean(inviteData?.invite_access?.enabled ?? false))
|
||||||
|
setInviteManagedByMaster(Boolean(inviteData?.invite_access?.managed_by_master ?? false))
|
||||||
|
setMasterInviteTemplate(inviteData?.master_invite ?? null)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!getToken()) {
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
await loadPage()
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setInviteError(err instanceof Error ? err.message : 'Could not load invite tools.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void load()
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
const resetInviteEditor = () => {
|
||||||
|
setInviteEditingId(null)
|
||||||
|
setInviteForm(defaultOwnedInviteForm())
|
||||||
|
}
|
||||||
|
|
||||||
|
const editInvite = (invite: OwnedInvite) => {
|
||||||
|
setInviteEditingId(invite.id)
|
||||||
|
setInviteError(null)
|
||||||
|
setInviteStatus(null)
|
||||||
|
setInviteForm({
|
||||||
|
code: invite.code ?? '',
|
||||||
|
label: invite.label ?? '',
|
||||||
|
description: invite.description ?? '',
|
||||||
|
recipient_email: invite.recipient_email ?? '',
|
||||||
|
max_uses: typeof invite.max_uses === 'number' ? String(invite.max_uses) : '',
|
||||||
|
expires_at: invite.expires_at ?? '',
|
||||||
|
enabled: invite.enabled !== false,
|
||||||
|
send_email: false,
|
||||||
|
message: '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const reloadInvites = async () => {
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
const response = await authFetch(`${baseUrl}/auth/profile/invites`)
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
clearToken()
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw new Error(`Invite refresh failed: ${response.status}`)
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as OwnedInvitesResponse
|
||||||
|
setInvites(Array.isArray(data?.invites) ? data.invites : [])
|
||||||
|
setInviteAccessEnabled(Boolean(data?.invite_access?.enabled ?? false))
|
||||||
|
setInviteManagedByMaster(Boolean(data?.invite_access?.managed_by_master ?? false))
|
||||||
|
setMasterInviteTemplate(data?.master_invite ?? null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveInvite = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setInviteSaving(true)
|
||||||
|
setInviteError(null)
|
||||||
|
setInviteStatus(null)
|
||||||
|
try {
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
const response = await authFetch(
|
||||||
|
inviteEditingId == null
|
||||||
|
? `${baseUrl}/auth/profile/invites`
|
||||||
|
: `${baseUrl}/auth/profile/invites/${inviteEditingId}`,
|
||||||
|
{
|
||||||
|
method: inviteEditingId == null ? 'POST' : 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: inviteForm.code || null,
|
||||||
|
label: inviteForm.label || null,
|
||||||
|
description: inviteForm.description || null,
|
||||||
|
recipient_email: inviteForm.recipient_email || null,
|
||||||
|
max_uses: inviteForm.max_uses || null,
|
||||||
|
expires_at: inviteForm.expires_at || null,
|
||||||
|
enabled: inviteForm.enabled,
|
||||||
|
send_email: inviteForm.send_email,
|
||||||
|
message: inviteForm.message || null,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
clearToken()
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const text = await response.text()
|
||||||
|
throw new Error(text || 'Invite save failed')
|
||||||
|
}
|
||||||
|
const data = await response.json().catch(() => ({}))
|
||||||
|
if (data?.email?.status === 'ok') {
|
||||||
|
setInviteStatus(
|
||||||
|
`${inviteEditingId == null ? 'Invite created' : 'Invite updated'} and email sent to ${data.email.recipient_email}.`
|
||||||
|
)
|
||||||
|
} else if (data?.email?.status === 'error') {
|
||||||
|
setInviteStatus(
|
||||||
|
`${inviteEditingId == null ? 'Invite created' : 'Invite updated'}, but email failed: ${data.email.detail}`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
setInviteStatus(inviteEditingId == null ? 'Invite created.' : 'Invite updated.')
|
||||||
|
}
|
||||||
|
resetInviteEditor()
|
||||||
|
await reloadInvites()
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setInviteError(err instanceof Error ? err.message : 'Could not save invite.')
|
||||||
|
} finally {
|
||||||
|
setInviteSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteInvite = async (invite: OwnedInvite) => {
|
||||||
|
if (!window.confirm(`Delete invite "${invite.code}"?`)) return
|
||||||
|
setInviteError(null)
|
||||||
|
setInviteStatus(null)
|
||||||
|
try {
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
const response = await authFetch(`${baseUrl}/auth/profile/invites/${invite.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
clearToken()
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const text = await response.text()
|
||||||
|
throw new Error(text || 'Invite delete failed')
|
||||||
|
}
|
||||||
|
if (inviteEditingId === invite.id) {
|
||||||
|
resetInviteEditor()
|
||||||
|
}
|
||||||
|
setInviteStatus(`Deleted invite ${invite.code}.`)
|
||||||
|
await reloadInvites()
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setInviteError(err instanceof Error ? err.message : 'Could not delete invite.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyInviteLink = async (invite: OwnedInvite) => {
|
||||||
|
const url = `${signupBaseUrl}?code=${encodeURIComponent(invite.code)}`
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(url)
|
||||||
|
setInviteStatus(`Copied invite link for ${invite.code}.`)
|
||||||
|
} else {
|
||||||
|
window.prompt('Copy invite link', url)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
window.prompt('Copy invite link', url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canManageInvites = profile?.role === 'admin' || inviteAccessEnabled
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <main className="card">Loading invite tools...</main>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="card">
|
||||||
|
<div className="user-directory-panel-header profile-page-header">
|
||||||
|
<div>
|
||||||
|
<h1>My invites</h1>
|
||||||
|
<p className="lede">Create invite links, email them directly, and track who you have invited.</p>
|
||||||
|
</div>
|
||||||
|
<div className="admin-inline-actions">
|
||||||
|
<button type="button" className="ghost-button" onClick={() => router.push('/profile')}>
|
||||||
|
Back to profile
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{profile ? (
|
||||||
|
<div className="status-banner">
|
||||||
|
Signed in as <strong>{profile.username}</strong> ({profile.role}).
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="profile-tabbar">
|
||||||
|
<div className="admin-segmented" role="tablist" aria-label="Profile sections">
|
||||||
|
<button type="button" role="tab" aria-selected={false} onClick={() => router.push('/profile')}>
|
||||||
|
Overview
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={false}
|
||||||
|
onClick={() => router.push('/profile?tab=activity')}
|
||||||
|
>
|
||||||
|
Activity
|
||||||
|
</button>
|
||||||
|
<button type="button" role="tab" aria-selected className="is-active">
|
||||||
|
My invites
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={false}
|
||||||
|
onClick={() => router.push('/profile?tab=security')}
|
||||||
|
>
|
||||||
|
Security
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{inviteError && <div className="error-banner">{inviteError}</div>}
|
||||||
|
{inviteStatus && <div className="status-banner">{inviteStatus}</div>}
|
||||||
|
|
||||||
|
{!canManageInvites ? (
|
||||||
|
<section className="profile-section profile-tab-panel">
|
||||||
|
<h2>Invite access is disabled</h2>
|
||||||
|
<p className="lede">
|
||||||
|
Your account is not currently allowed to create self-service invites. Ask an administrator to enable invite access for your profile.
|
||||||
|
</p>
|
||||||
|
<div className="admin-inline-actions">
|
||||||
|
<button type="button" onClick={() => router.push('/profile')}>
|
||||||
|
Return to profile
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<section className="profile-section profile-invites-section profile-tab-panel">
|
||||||
|
<div className="user-directory-panel-header">
|
||||||
|
<div>
|
||||||
|
<h2>Invite workspace</h2>
|
||||||
|
<p className="lede">
|
||||||
|
{inviteManagedByMaster
|
||||||
|
? 'Create and manage invite links you have issued. New invites use the admin master invite rule.'
|
||||||
|
: 'Create and manage invite links you have issued. New invites use your account defaults.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="profile-invites-layout">
|
||||||
|
<div className="profile-invite-form-card">
|
||||||
|
<h3>{inviteEditingId == null ? 'Create invite' : 'Edit invite'}</h3>
|
||||||
|
<p className="meta profile-invite-form-lede">
|
||||||
|
Save a recipient email, send the invite immediately, and keep the generated link ready to copy.
|
||||||
|
</p>
|
||||||
|
{inviteManagedByMaster && masterInviteTemplate ? (
|
||||||
|
<div className="status-banner profile-invite-master-banner">
|
||||||
|
Using master invite rule <code>{masterInviteTemplate.code}</code>
|
||||||
|
{masterInviteTemplate.label ? ` (${masterInviteTemplate.label})` : ''}. Limits and status are managed by admin.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<form onSubmit={saveInvite} className="admin-form compact-form invite-form-layout profile-form-layout">
|
||||||
|
<div className="invite-form-row">
|
||||||
|
<div className="invite-form-row-label">
|
||||||
|
<span>Identity</span>
|
||||||
|
<small>Optional code and label for easier tracking.</small>
|
||||||
|
</div>
|
||||||
|
<div className="invite-form-row-control invite-form-row-grid">
|
||||||
|
<label>
|
||||||
|
<span>Code (optional)</span>
|
||||||
|
<input
|
||||||
|
value={inviteForm.code}
|
||||||
|
onChange={(event) =>
|
||||||
|
setInviteForm((current) => ({ ...current, code: event.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="Leave blank to auto-generate"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Label</span>
|
||||||
|
<input
|
||||||
|
value={inviteForm.label}
|
||||||
|
onChange={(event) =>
|
||||||
|
setInviteForm((current) => ({ ...current, label: event.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="Family invite"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="invite-form-row">
|
||||||
|
<div className="invite-form-row-label">
|
||||||
|
<span>Description</span>
|
||||||
|
<small>Optional note shown on the signup page.</small>
|
||||||
|
</div>
|
||||||
|
<div className="invite-form-row-control">
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={inviteForm.description}
|
||||||
|
onChange={(event) =>
|
||||||
|
setInviteForm((current) => ({
|
||||||
|
...current,
|
||||||
|
description: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="Optional note shown on the signup page"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="invite-form-row">
|
||||||
|
<div className="invite-form-row-label">
|
||||||
|
<span>Delivery</span>
|
||||||
|
<small>Save a recipient email and optionally send the invite immediately.</small>
|
||||||
|
</div>
|
||||||
|
<div className="invite-form-row-control invite-form-row-control--stacked">
|
||||||
|
<label>
|
||||||
|
<span>Recipient email</span>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={inviteForm.recipient_email}
|
||||||
|
onChange={(event) =>
|
||||||
|
setInviteForm((current) => ({
|
||||||
|
...current,
|
||||||
|
recipient_email: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="friend@example.com"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Delivery note</span>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={inviteForm.message}
|
||||||
|
onChange={(event) =>
|
||||||
|
setInviteForm((current) => ({
|
||||||
|
...current,
|
||||||
|
message: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="Optional note to include in the email"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="inline-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={inviteForm.send_email}
|
||||||
|
onChange={(event) =>
|
||||||
|
setInviteForm((current) => ({
|
||||||
|
...current,
|
||||||
|
send_email: event.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Send "You have been invited" email after saving
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="invite-form-row">
|
||||||
|
<div className="invite-form-row-label">
|
||||||
|
<span>Limits</span>
|
||||||
|
<small>Usage cap and optional expiry date/time.</small>
|
||||||
|
</div>
|
||||||
|
<div className="invite-form-row-control invite-form-row-grid">
|
||||||
|
<label>
|
||||||
|
<span>Max uses</span>
|
||||||
|
<input
|
||||||
|
value={inviteForm.max_uses}
|
||||||
|
onChange={(event) =>
|
||||||
|
setInviteForm((current) => ({ ...current, max_uses: event.target.value }))
|
||||||
|
}
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder="Blank = unlimited"
|
||||||
|
disabled={inviteManagedByMaster}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Invite expiry (ISO datetime)</span>
|
||||||
|
<input
|
||||||
|
value={inviteForm.expires_at}
|
||||||
|
onChange={(event) =>
|
||||||
|
setInviteForm((current) => ({ ...current, expires_at: event.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="2026-03-01T12:00:00+00:00"
|
||||||
|
disabled={inviteManagedByMaster}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="invite-form-row">
|
||||||
|
<div className="invite-form-row-label">
|
||||||
|
<span>Status</span>
|
||||||
|
<small>Enable or disable this invite before sharing.</small>
|
||||||
|
</div>
|
||||||
|
<div className="invite-form-row-control invite-form-row-control--stacked">
|
||||||
|
<label className="inline-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={inviteForm.enabled}
|
||||||
|
onChange={(event) =>
|
||||||
|
setInviteForm((current) => ({
|
||||||
|
...current,
|
||||||
|
enabled: event.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={inviteManagedByMaster}
|
||||||
|
/>
|
||||||
|
Invite is enabled
|
||||||
|
</label>
|
||||||
|
<div className="admin-inline-actions">
|
||||||
|
<button type="submit" disabled={inviteSaving}>
|
||||||
|
{inviteSaving
|
||||||
|
? 'Saving…'
|
||||||
|
: inviteEditingId == null
|
||||||
|
? 'Create invite'
|
||||||
|
: 'Save invite'}
|
||||||
|
</button>
|
||||||
|
{inviteEditingId != null && (
|
||||||
|
<button type="button" className="ghost-button" onClick={resetInviteEditor}>
|
||||||
|
Cancel edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div className="meta profile-invite-hint">
|
||||||
|
Invite URL format: <code>{signupBaseUrl}?code=INVITECODE</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="profile-invites-list">
|
||||||
|
{invites.length === 0 ? (
|
||||||
|
<div className="status-banner">You have not created any invites yet.</div>
|
||||||
|
) : (
|
||||||
|
<div className="admin-list">
|
||||||
|
{invites.map((invite) => (
|
||||||
|
<div key={invite.id} className="admin-list-item">
|
||||||
|
<div className="admin-list-item-main">
|
||||||
|
<div className="admin-list-item-title-row">
|
||||||
|
<code className="invite-code">{invite.code}</code>
|
||||||
|
<span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>
|
||||||
|
{invite.is_usable ? 'Usable' : 'Unavailable'}
|
||||||
|
</span>
|
||||||
|
<span className="small-pill is-muted">
|
||||||
|
{invite.remaining_uses == null ? 'Unlimited' : `${invite.remaining_uses} left`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{invite.label && <p className="admin-list-item-text">{invite.label}</p>}
|
||||||
|
{invite.description && (
|
||||||
|
<p className="admin-list-item-text admin-list-item-text--muted">
|
||||||
|
{invite.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="admin-meta-row">
|
||||||
|
<span>Recipient: {invite.recipient_email || 'Not set'}</span>
|
||||||
|
<span>
|
||||||
|
Uses: {invite.use_count}
|
||||||
|
{typeof invite.max_uses === 'number' ? ` / ${invite.max_uses}` : ''}
|
||||||
|
</span>
|
||||||
|
<span>Expires: {formatDate(invite.expires_at)}</span>
|
||||||
|
<span>Created: {formatDate(invite.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-inline-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-button"
|
||||||
|
onClick={() => copyInviteLink(invite)}
|
||||||
|
>
|
||||||
|
Copy link
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-button"
|
||||||
|
onClick={() => editInvite(invite)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => deleteInvite(invite)}>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ type ProfileInfo = {
|
|||||||
role: string
|
role: string
|
||||||
auth_provider: string
|
auth_provider: string
|
||||||
invite_management_enabled?: boolean
|
invite_management_enabled?: boolean
|
||||||
|
password_change_supported?: boolean
|
||||||
|
password_provider?: 'local' | 'jellyfin' | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProfileStats = {
|
type ProfileStats = {
|
||||||
@@ -48,68 +50,15 @@ type ProfileResponse = {
|
|||||||
activity: ProfileActivity
|
activity: ProfileActivity
|
||||||
}
|
}
|
||||||
|
|
||||||
type OwnedInvite = {
|
type ProfileTab = 'overview' | 'activity' | 'security'
|
||||||
id: number
|
|
||||||
code: string
|
|
||||||
label?: string | null
|
|
||||||
description?: string | null
|
|
||||||
recipient_email?: string | null
|
|
||||||
max_uses?: number | null
|
|
||||||
use_count: number
|
|
||||||
remaining_uses?: number | null
|
|
||||||
enabled: boolean
|
|
||||||
expires_at?: string | null
|
|
||||||
is_expired?: boolean
|
|
||||||
is_usable?: boolean
|
|
||||||
created_at?: string | null
|
|
||||||
updated_at?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
type OwnedInvitesResponse = {
|
const normalizeProfileTab = (value?: string | null): ProfileTab => {
|
||||||
invites?: OwnedInvite[]
|
if (value === 'activity' || value === 'security') {
|
||||||
count?: number
|
return value
|
||||||
invite_access?: {
|
|
||||||
enabled?: boolean
|
|
||||||
managed_by_master?: boolean
|
|
||||||
}
|
}
|
||||||
master_invite?: {
|
return 'overview'
|
||||||
id: number
|
|
||||||
code: string
|
|
||||||
label?: string | null
|
|
||||||
description?: string | null
|
|
||||||
max_uses?: number | null
|
|
||||||
enabled?: boolean
|
|
||||||
expires_at?: string | null
|
|
||||||
is_usable?: boolean
|
|
||||||
} | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type OwnedInviteForm = {
|
|
||||||
code: string
|
|
||||||
label: string
|
|
||||||
description: string
|
|
||||||
recipient_email: string
|
|
||||||
max_uses: string
|
|
||||||
expires_at: string
|
|
||||||
enabled: boolean
|
|
||||||
send_email: boolean
|
|
||||||
message: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProfileTab = 'overview' | 'activity' | 'invites' | 'security'
|
|
||||||
|
|
||||||
const defaultOwnedInviteForm = (): OwnedInviteForm => ({
|
|
||||||
code: '',
|
|
||||||
label: '',
|
|
||||||
description: '',
|
|
||||||
recipient_email: '',
|
|
||||||
max_uses: '',
|
|
||||||
expires_at: '',
|
|
||||||
enabled: true,
|
|
||||||
send_email: false,
|
|
||||||
message: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const formatDate = (value?: string | null) => {
|
const formatDate = (value?: string | null) => {
|
||||||
if (!value) return 'Never'
|
if (!value) return 'Never'
|
||||||
const date = new Date(value)
|
const date = new Date(value)
|
||||||
@@ -134,24 +83,29 @@ export default function ProfilePage() {
|
|||||||
const [activity, setActivity] = useState<ProfileActivity | null>(null)
|
const [activity, setActivity] = useState<ProfileActivity | null>(null)
|
||||||
const [currentPassword, setCurrentPassword] = useState('')
|
const [currentPassword, setCurrentPassword] = useState('')
|
||||||
const [newPassword, setNewPassword] = useState('')
|
const [newPassword, setNewPassword] = useState('')
|
||||||
const [status, setStatus] = useState<string | null>(null)
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
const [inviteStatus, setInviteStatus] = useState<string | null>(null)
|
const [status, setStatus] = useState<{ tone: 'status' | 'error'; message: string } | null>(null)
|
||||||
const [inviteError, setInviteError] = useState<string | null>(null)
|
|
||||||
const [invites, setInvites] = useState<OwnedInvite[]>([])
|
|
||||||
const [inviteSaving, setInviteSaving] = useState(false)
|
|
||||||
const [inviteEditingId, setInviteEditingId] = useState<number | null>(null)
|
|
||||||
const [inviteForm, setInviteForm] = useState<OwnedInviteForm>(defaultOwnedInviteForm())
|
|
||||||
const [activeTab, setActiveTab] = useState<ProfileTab>('overview')
|
const [activeTab, setActiveTab] = useState<ProfileTab>('overview')
|
||||||
const [inviteAccessEnabled, setInviteAccessEnabled] = useState(false)
|
|
||||||
const [inviteManagedByMaster, setInviteManagedByMaster] = useState(false)
|
|
||||||
const [masterInviteTemplate, setMasterInviteTemplate] = useState<OwnedInvitesResponse['master_invite']>(null)
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
const signupBaseUrl = useMemo(() => {
|
const inviteLink = useMemo(() => '/profile/invites', [])
|
||||||
if (typeof window === 'undefined') return '/signup'
|
|
||||||
return `${window.location.origin}/signup`
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
const syncTabFromLocation = () => {
|
||||||
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
setActiveTab(normalizeProfileTab(params.get('tab')))
|
||||||
|
}
|
||||||
|
syncTabFromLocation()
|
||||||
|
window.addEventListener('popstate', syncTabFromLocation)
|
||||||
|
return () => window.removeEventListener('popstate', syncTabFromLocation)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const selectTab = (tab: ProfileTab) => {
|
||||||
|
setActiveTab(tab)
|
||||||
|
router.replace(tab === 'overview' ? '/profile' : `/profile?tab=${tab}`)
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!getToken()) {
|
if (!getToken()) {
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
@@ -160,35 +114,30 @@ export default function ProfilePage() {
|
|||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const baseUrl = getApiBase()
|
const baseUrl = getApiBase()
|
||||||
const [profileResponse, invitesResponse] = await Promise.all([
|
const profileResponse = await authFetch(`${baseUrl}/auth/profile`)
|
||||||
authFetch(`${baseUrl}/auth/profile`),
|
if (!profileResponse.ok) {
|
||||||
authFetch(`${baseUrl}/auth/profile/invites`),
|
|
||||||
])
|
|
||||||
if (!profileResponse.ok || !invitesResponse.ok) {
|
|
||||||
clearToken()
|
clearToken()
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const [data, inviteData] = (await Promise.all([
|
const data = (await profileResponse.json()) as ProfileResponse
|
||||||
profileResponse.json(),
|
|
||||||
invitesResponse.json(),
|
|
||||||
])) as [ProfileResponse, OwnedInvitesResponse]
|
|
||||||
const user = data?.user ?? {}
|
const user = data?.user ?? {}
|
||||||
setProfile({
|
setProfile({
|
||||||
username: user?.username ?? 'Unknown',
|
username: user?.username ?? 'Unknown',
|
||||||
role: user?.role ?? 'user',
|
role: user?.role ?? 'user',
|
||||||
auth_provider: user?.auth_provider ?? 'local',
|
auth_provider: user?.auth_provider ?? 'local',
|
||||||
invite_management_enabled: Boolean(user?.invite_management_enabled ?? false),
|
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)
|
setStats(data?.stats ?? null)
|
||||||
setActivity(data?.activity ?? null)
|
setActivity(data?.activity ?? null)
|
||||||
setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : [])
|
|
||||||
setInviteAccessEnabled(Boolean(inviteData?.invite_access?.enabled ?? false))
|
|
||||||
setInviteManagedByMaster(Boolean(inviteData?.invite_access?.managed_by_master ?? false))
|
|
||||||
setMasterInviteTemplate(inviteData?.master_invite ?? null)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
setStatus('Could not load your profile.')
|
setStatus({ tone: 'error', message: 'Could not load your profile.' })
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -200,7 +149,11 @@ export default function ProfilePage() {
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setStatus(null)
|
setStatus(null)
|
||||||
if (!currentPassword || !newPassword) {
|
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
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -229,192 +182,69 @@ export default function ProfilePage() {
|
|||||||
const data = await response.json().catch(() => ({}))
|
const data = await response.json().catch(() => ({}))
|
||||||
setCurrentPassword('')
|
setCurrentPassword('')
|
||||||
setNewPassword('')
|
setNewPassword('')
|
||||||
setStatus(
|
setConfirmPassword('')
|
||||||
|
setStatus({
|
||||||
|
tone: 'status',
|
||||||
|
message:
|
||||||
data?.provider === 'jellyfin'
|
data?.provider === 'jellyfin'
|
||||||
? 'Password updated in Jellyfin (and Magent cache).'
|
? 'Password updated across Jellyfin and Magent. Seerr continues to use the same Jellyfin password.'
|
||||||
: 'Password updated.'
|
: 'Password updated.',
|
||||||
)
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
if (err instanceof Error && err.message) {
|
if (err instanceof Error && err.message) {
|
||||||
setStatus(`Could not update password. ${err.message}`)
|
setStatus({ tone: 'error', message: `Could not update password. ${err.message}` })
|
||||||
} else {
|
} else {
|
||||||
setStatus('Could not update password. Check your current password.')
|
setStatus({ tone: 'error', message: 'Could not update password. Check your current password.' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetInviteEditor = () => {
|
|
||||||
setInviteEditingId(null)
|
|
||||||
setInviteForm(defaultOwnedInviteForm())
|
|
||||||
}
|
|
||||||
|
|
||||||
const editInvite = (invite: OwnedInvite) => {
|
|
||||||
setInviteEditingId(invite.id)
|
|
||||||
setInviteError(null)
|
|
||||||
setInviteStatus(null)
|
|
||||||
setInviteForm({
|
|
||||||
code: invite.code ?? '',
|
|
||||||
label: invite.label ?? '',
|
|
||||||
description: invite.description ?? '',
|
|
||||||
recipient_email: invite.recipient_email ?? '',
|
|
||||||
max_uses: typeof invite.max_uses === 'number' ? String(invite.max_uses) : '',
|
|
||||||
expires_at: invite.expires_at ?? '',
|
|
||||||
enabled: invite.enabled !== false,
|
|
||||||
send_email: false,
|
|
||||||
message: '',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const reloadInvites = async () => {
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const response = await authFetch(`${baseUrl}/auth/profile/invites`)
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 401) {
|
|
||||||
clearToken()
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
throw new Error(`Invite refresh failed: ${response.status}`)
|
|
||||||
}
|
|
||||||
const data = (await response.json()) as OwnedInvitesResponse
|
|
||||||
setInvites(Array.isArray(data?.invites) ? data.invites : [])
|
|
||||||
setInviteAccessEnabled(Boolean(data?.invite_access?.enabled ?? false))
|
|
||||||
setInviteManagedByMaster(Boolean(data?.invite_access?.managed_by_master ?? false))
|
|
||||||
setMasterInviteTemplate(data?.master_invite ?? null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveInvite = async (event: React.FormEvent) => {
|
|
||||||
event.preventDefault()
|
|
||||||
setInviteSaving(true)
|
|
||||||
setInviteError(null)
|
|
||||||
setInviteStatus(null)
|
|
||||||
try {
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const response = await authFetch(
|
|
||||||
inviteEditingId == null
|
|
||||||
? `${baseUrl}/auth/profile/invites`
|
|
||||||
: `${baseUrl}/auth/profile/invites/${inviteEditingId}`,
|
|
||||||
{
|
|
||||||
method: inviteEditingId == null ? 'POST' : 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
code: inviteForm.code || null,
|
|
||||||
label: inviteForm.label || null,
|
|
||||||
description: inviteForm.description || null,
|
|
||||||
recipient_email: inviteForm.recipient_email || null,
|
|
||||||
max_uses: inviteForm.max_uses || null,
|
|
||||||
expires_at: inviteForm.expires_at || null,
|
|
||||||
enabled: inviteForm.enabled,
|
|
||||||
send_email: inviteForm.send_email,
|
|
||||||
message: inviteForm.message || null,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 401) {
|
|
||||||
clearToken()
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const text = await response.text()
|
|
||||||
throw new Error(text || 'Invite save failed')
|
|
||||||
}
|
|
||||||
const data = await response.json().catch(() => ({}))
|
|
||||||
if (data?.email?.status === 'ok') {
|
|
||||||
setInviteStatus(
|
|
||||||
`${inviteEditingId == null ? 'Invite created' : 'Invite updated'} and email sent to ${data.email.recipient_email}.`
|
|
||||||
)
|
|
||||||
} else if (data?.email?.status === 'error') {
|
|
||||||
setInviteStatus(
|
|
||||||
`${inviteEditingId == null ? 'Invite created' : 'Invite updated'}, but email failed: ${data.email.detail}`
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
setInviteStatus(inviteEditingId == null ? 'Invite created.' : 'Invite updated.')
|
|
||||||
}
|
|
||||||
resetInviteEditor()
|
|
||||||
await reloadInvites()
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
setInviteError(err instanceof Error ? err.message : 'Could not save invite.')
|
|
||||||
} finally {
|
|
||||||
setInviteSaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteInvite = async (invite: OwnedInvite) => {
|
|
||||||
if (!window.confirm(`Delete invite "${invite.code}"?`)) return
|
|
||||||
setInviteError(null)
|
|
||||||
setInviteStatus(null)
|
|
||||||
try {
|
|
||||||
const baseUrl = getApiBase()
|
|
||||||
const response = await authFetch(`${baseUrl}/auth/profile/invites/${invite.id}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
})
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 401) {
|
|
||||||
clearToken()
|
|
||||||
router.push('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const text = await response.text()
|
|
||||||
throw new Error(text || 'Invite delete failed')
|
|
||||||
}
|
|
||||||
if (inviteEditingId === invite.id) {
|
|
||||||
resetInviteEditor()
|
|
||||||
}
|
|
||||||
setInviteStatus(`Deleted invite ${invite.code}.`)
|
|
||||||
await reloadInvites()
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
setInviteError(err instanceof Error ? err.message : 'Could not delete invite.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const copyInviteLink = async (invite: OwnedInvite) => {
|
|
||||||
const url = `${signupBaseUrl}?code=${encodeURIComponent(invite.code)}`
|
|
||||||
try {
|
|
||||||
if (navigator.clipboard?.writeText) {
|
|
||||||
await navigator.clipboard.writeText(url)
|
|
||||||
setInviteStatus(`Copied invite link for ${invite.code}.`)
|
|
||||||
} else {
|
|
||||||
window.prompt('Copy invite link', url)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
window.prompt('Copy invite link', url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const authProvider = profile?.auth_provider ?? 'local'
|
const authProvider = profile?.auth_provider ?? 'local'
|
||||||
const canManageInvites = profile?.role === 'admin' || inviteAccessEnabled
|
const passwordProvider = profile?.password_provider ?? (authProvider === 'jellyfin' ? 'jellyfin' : 'local')
|
||||||
const canChangePassword = authProvider === 'local' || authProvider === 'jellyfin'
|
const canManageInvites = profile?.role === 'admin' || Boolean(profile?.invite_management_enabled)
|
||||||
|
const canChangePassword = Boolean(profile?.password_change_supported ?? (authProvider === 'local' || authProvider === 'jellyfin'))
|
||||||
const securityHelpText =
|
const securityHelpText =
|
||||||
authProvider === 'jellyfin'
|
passwordProvider === 'jellyfin'
|
||||||
? 'Changing your password here updates your Jellyfin account and refreshes Magent’s cached sign-in.'
|
? 'Reset your password here once. Magent updates Jellyfin directly, Seerr continues to use Jellyfin authentication, and Magent keeps the same password in sync.'
|
||||||
: authProvider === 'local'
|
: passwordProvider === 'local'
|
||||||
? 'Change your Magent account password.'
|
? 'Change your Magent account password.'
|
||||||
: 'Password changes are not available for this sign-in provider.'
|
: 'Password changes are not available for this sign-in provider.'
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeTab === 'invites' && !canManageInvites) {
|
|
||||||
setActiveTab('overview')
|
|
||||||
}
|
|
||||||
}, [activeTab, canManageInvites])
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <main className="card">Loading profile...</main>
|
return <main className="card">Loading profile...</main>
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="card">
|
<main className="card">
|
||||||
|
<div className="user-directory-panel-header profile-page-header">
|
||||||
|
<div>
|
||||||
<h1>My profile</h1>
|
<h1>My profile</h1>
|
||||||
|
<p className="lede">Review your account, activity, and security settings.</p>
|
||||||
|
</div>
|
||||||
|
{canManageInvites || canChangePassword ? (
|
||||||
|
<div className="admin-inline-actions">
|
||||||
|
{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>
|
||||||
|
|
||||||
{profile && (
|
{profile && (
|
||||||
<div className="status-banner">
|
<div className="status-banner">
|
||||||
Signed in as <strong>{profile.username}</strong> ({profile.role}). Login type:{' '}
|
Signed in as <strong>{profile.username}</strong> ({profile.role}). Login type:{' '}
|
||||||
{profile.auth_provider}.
|
{profile.auth_provider}.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="profile-tabbar">
|
<div className="profile-tabbar">
|
||||||
<div className="admin-segmented" role="tablist" aria-label="Profile sections">
|
<div className="admin-segmented" role="tablist" aria-label="Profile sections">
|
||||||
<button
|
<button
|
||||||
@@ -422,7 +252,7 @@ export default function ProfilePage() {
|
|||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={activeTab === 'overview'}
|
aria-selected={activeTab === 'overview'}
|
||||||
className={activeTab === 'overview' ? 'is-active' : ''}
|
className={activeTab === 'overview' ? 'is-active' : ''}
|
||||||
onClick={() => setActiveTab('overview')}
|
onClick={() => selectTab('overview')}
|
||||||
>
|
>
|
||||||
Overview
|
Overview
|
||||||
</button>
|
</button>
|
||||||
@@ -431,18 +261,12 @@ export default function ProfilePage() {
|
|||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={activeTab === 'activity'}
|
aria-selected={activeTab === 'activity'}
|
||||||
className={activeTab === 'activity' ? 'is-active' : ''}
|
className={activeTab === 'activity' ? 'is-active' : ''}
|
||||||
onClick={() => setActiveTab('activity')}
|
onClick={() => selectTab('activity')}
|
||||||
>
|
>
|
||||||
Activity
|
Activity
|
||||||
</button>
|
</button>
|
||||||
{canManageInvites ? (
|
{canManageInvites ? (
|
||||||
<button
|
<button type="button" role="tab" aria-selected={false} onClick={() => router.push(inviteLink)}>
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected={activeTab === 'invites'}
|
|
||||||
className={activeTab === 'invites' ? 'is-active' : ''}
|
|
||||||
onClick={() => setActiveTab('invites')}
|
|
||||||
>
|
|
||||||
My invites
|
My invites
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -451,15 +275,47 @@ export default function ProfilePage() {
|
|||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={activeTab === 'security'}
|
aria-selected={activeTab === 'security'}
|
||||||
className={activeTab === 'security' ? 'is-active' : ''}
|
className={activeTab === 'security' ? 'is-active' : ''}
|
||||||
onClick={() => setActiveTab('security')}
|
onClick={() => selectTab('security')}
|
||||||
>
|
>
|
||||||
Security
|
Password
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeTab === 'overview' && (
|
{activeTab === 'overview' && (
|
||||||
<section className="profile-section profile-tab-panel">
|
<section className="profile-section profile-tab-panel">
|
||||||
|
{canManageInvites ? (
|
||||||
|
<div className="profile-quick-link-card">
|
||||||
|
<div>
|
||||||
|
<h2>Invite tools</h2>
|
||||||
|
<p className="lede">
|
||||||
|
Create invite links, send them by email, and track who you have invited from a dedicated page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="admin-inline-actions">
|
||||||
|
<button type="button" onClick={() => router.push(inviteLink)}>
|
||||||
|
Go to invites
|
||||||
|
</button>
|
||||||
|
</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>
|
<h2>Account stats</h2>
|
||||||
<div className="stat-grid">
|
<div className="stat-grid">
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
@@ -503,9 +359,7 @@ export default function ProfilePage() {
|
|||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<div className="stat-label">Share of all requests</div>
|
<div className="stat-label">Share of all requests</div>
|
||||||
<div className="stat-value">
|
<div className="stat-value">
|
||||||
{stats?.global_total
|
{stats?.global_total ? `${Math.round((stats.share || 0) * 1000) / 10}%` : '0%'}
|
||||||
? `${Math.round((stats.share || 0) * 1000) / 10}%`
|
|
||||||
: '0%'}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
@@ -551,274 +405,14 @@ export default function ProfilePage() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'invites' && (
|
|
||||||
<section className="profile-section profile-invites-section profile-tab-panel">
|
|
||||||
<div className="user-directory-panel-header">
|
|
||||||
<div>
|
|
||||||
<h2>My invites</h2>
|
|
||||||
<p className="lede">
|
|
||||||
{inviteManagedByMaster
|
|
||||||
? 'Create and manage invite links you’ve issued. New invites use the admin master invite rule.'
|
|
||||||
: 'Create and manage invite links you’ve issued. New invites use your account defaults.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{inviteError && <div className="error-banner">{inviteError}</div>}
|
|
||||||
{inviteStatus && <div className="status-banner">{inviteStatus}</div>}
|
|
||||||
<div className="profile-invites-layout">
|
|
||||||
<div className="profile-invite-form-card">
|
|
||||||
<h3>{inviteEditingId == null ? 'Create invite' : 'Edit invite'}</h3>
|
|
||||||
<p className="meta profile-invite-form-lede">
|
|
||||||
Share the generated signup link with the person you want to invite.
|
|
||||||
</p>
|
|
||||||
{inviteManagedByMaster && masterInviteTemplate ? (
|
|
||||||
<div className="status-banner profile-invite-master-banner">
|
|
||||||
Using master invite rule <code>{masterInviteTemplate.code}</code>
|
|
||||||
{masterInviteTemplate.label ? ` (${masterInviteTemplate.label})` : ''}. Limits/status are managed by admin.
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<form onSubmit={saveInvite} className="admin-form compact-form invite-form-layout">
|
|
||||||
<div className="invite-form-row">
|
|
||||||
<div className="invite-form-row-label">
|
|
||||||
<span>Identity</span>
|
|
||||||
<small>Optional code and label for easier tracking.</small>
|
|
||||||
</div>
|
|
||||||
<div className="invite-form-row-control invite-form-row-grid">
|
|
||||||
<label>
|
|
||||||
<span>Code (optional)</span>
|
|
||||||
<input
|
|
||||||
value={inviteForm.code}
|
|
||||||
onChange={(event) =>
|
|
||||||
setInviteForm((current) => ({ ...current, code: event.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="Leave blank to auto-generate"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>Label</span>
|
|
||||||
<input
|
|
||||||
value={inviteForm.label}
|
|
||||||
onChange={(event) =>
|
|
||||||
setInviteForm((current) => ({ ...current, label: event.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="Family invite"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="invite-form-row">
|
|
||||||
<div className="invite-form-row-label">
|
|
||||||
<span>Description</span>
|
|
||||||
<small>Optional note shown on the signup page.</small>
|
|
||||||
</div>
|
|
||||||
<div className="invite-form-row-control">
|
|
||||||
<textarea
|
|
||||||
rows={3}
|
|
||||||
value={inviteForm.description}
|
|
||||||
onChange={(event) =>
|
|
||||||
setInviteForm((current) => ({
|
|
||||||
...current,
|
|
||||||
description: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder="Optional note shown on the signup page"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="invite-form-row">
|
|
||||||
<div className="invite-form-row-label">
|
|
||||||
<span>Delivery</span>
|
|
||||||
<small>Save a recipient email and optionally send the invite immediately.</small>
|
|
||||||
</div>
|
|
||||||
<div className="invite-form-row-control invite-form-row-control--stacked">
|
|
||||||
<label>
|
|
||||||
<span>Recipient email</span>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={inviteForm.recipient_email}
|
|
||||||
onChange={(event) =>
|
|
||||||
setInviteForm((current) => ({
|
|
||||||
...current,
|
|
||||||
recipient_email: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder="friend@example.com"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>Delivery note</span>
|
|
||||||
<textarea
|
|
||||||
rows={3}
|
|
||||||
value={inviteForm.message}
|
|
||||||
onChange={(event) =>
|
|
||||||
setInviteForm((current) => ({
|
|
||||||
...current,
|
|
||||||
message: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder="Optional note to include in the email"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="inline-checkbox">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={inviteForm.send_email}
|
|
||||||
onChange={(event) =>
|
|
||||||
setInviteForm((current) => ({
|
|
||||||
...current,
|
|
||||||
send_email: event.target.checked,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
Send "You have been invited" email after saving
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="invite-form-row">
|
|
||||||
<div className="invite-form-row-label">
|
|
||||||
<span>Limits</span>
|
|
||||||
<small>Usage cap and optional expiry date/time.</small>
|
|
||||||
</div>
|
|
||||||
<div className="invite-form-row-control invite-form-row-grid">
|
|
||||||
<label>
|
|
||||||
<span>Max uses</span>
|
|
||||||
<input
|
|
||||||
value={inviteForm.max_uses}
|
|
||||||
onChange={(event) =>
|
|
||||||
setInviteForm((current) => ({ ...current, max_uses: event.target.value }))
|
|
||||||
}
|
|
||||||
inputMode="numeric"
|
|
||||||
placeholder="Blank = unlimited"
|
|
||||||
disabled={inviteManagedByMaster}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>Invite expiry (ISO datetime)</span>
|
|
||||||
<input
|
|
||||||
value={inviteForm.expires_at}
|
|
||||||
onChange={(event) =>
|
|
||||||
setInviteForm((current) => ({ ...current, expires_at: event.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="2026-03-01T12:00:00+00:00"
|
|
||||||
disabled={inviteManagedByMaster}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="invite-form-row">
|
|
||||||
<div className="invite-form-row-label">
|
|
||||||
<span>Status</span>
|
|
||||||
<small>Enable or disable this invite before sharing.</small>
|
|
||||||
</div>
|
|
||||||
<div className="invite-form-row-control invite-form-row-control--stacked">
|
|
||||||
<label className="inline-checkbox">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={inviteForm.enabled}
|
|
||||||
onChange={(event) =>
|
|
||||||
setInviteForm((current) => ({
|
|
||||||
...current,
|
|
||||||
enabled: event.target.checked,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
disabled={inviteManagedByMaster}
|
|
||||||
/>
|
|
||||||
Invite is enabled
|
|
||||||
</label>
|
|
||||||
<div className="admin-inline-actions">
|
|
||||||
<button type="submit" disabled={inviteSaving}>
|
|
||||||
{inviteSaving
|
|
||||||
? 'Saving…'
|
|
||||||
: inviteEditingId == null
|
|
||||||
? 'Create invite'
|
|
||||||
: 'Save invite'}
|
|
||||||
</button>
|
|
||||||
{inviteEditingId != null && (
|
|
||||||
<button type="button" className="ghost-button" onClick={resetInviteEditor}>
|
|
||||||
Cancel edit
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<div className="meta profile-invite-hint">
|
|
||||||
Invite URL format: <code>{signupBaseUrl}?code=INVITECODE</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="profile-invites-list">
|
|
||||||
{invites.length === 0 ? (
|
|
||||||
<div className="status-banner">You haven’t created any invites yet.</div>
|
|
||||||
) : (
|
|
||||||
<div className="admin-list">
|
|
||||||
{invites.map((invite) => (
|
|
||||||
<div key={invite.id} className="admin-list-item">
|
|
||||||
<div className="admin-list-item-main">
|
|
||||||
<div className="admin-list-item-title-row">
|
|
||||||
<code className="invite-code">{invite.code}</code>
|
|
||||||
<span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>
|
|
||||||
{invite.is_usable ? 'Usable' : 'Unavailable'}
|
|
||||||
</span>
|
|
||||||
<span className="small-pill is-muted">
|
|
||||||
{invite.remaining_uses == null ? 'Unlimited' : `${invite.remaining_uses} left`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{invite.label && <p className="admin-list-item-text">{invite.label}</p>}
|
|
||||||
{invite.description && (
|
|
||||||
<p className="admin-list-item-text admin-list-item-text--muted">
|
|
||||||
{invite.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="admin-meta-row">
|
|
||||||
<span>Recipient: {invite.recipient_email || 'Not set'}</span>
|
|
||||||
<span>
|
|
||||||
Uses: {invite.use_count}
|
|
||||||
{typeof invite.max_uses === 'number' ? ` / ${invite.max_uses}` : ''}
|
|
||||||
</span>
|
|
||||||
<span>Expires: {formatDate(invite.expires_at)}</span>
|
|
||||||
<span>Created: {formatDate(invite.created_at)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="admin-inline-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ghost-button"
|
|
||||||
onClick={() => copyInviteLink(invite)}
|
|
||||||
>
|
|
||||||
Copy link
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ghost-button"
|
|
||||||
onClick={() => editInvite(invite)}
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={() => deleteInvite(invite)}>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'security' && (
|
{activeTab === 'security' && (
|
||||||
<section className="profile-section profile-tab-panel">
|
<section className="profile-section profile-tab-panel">
|
||||||
<h2>Security</h2>
|
<h2>{passwordProvider === 'jellyfin' ? 'Jellyfin password reset' : 'Password'}</h2>
|
||||||
<div className="status-banner">{securityHelpText}</div>
|
<div className="status-banner">{securityHelpText}</div>
|
||||||
{canChangePassword ? (
|
{canChangePassword ? (
|
||||||
<form onSubmit={submit} className="auth-form profile-security-form">
|
<form onSubmit={submit} className="auth-form profile-security-form">
|
||||||
<label>
|
<label>
|
||||||
Current password
|
{passwordProvider === 'jellyfin' ? 'Current Jellyfin password' : 'Current password'}
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={currentPassword}
|
value={currentPassword}
|
||||||
@@ -827,7 +421,7 @@ export default function ProfilePage() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
New password
|
{passwordProvider === 'jellyfin' ? 'New Jellyfin password' : 'New password'}
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
@@ -835,10 +429,23 @@ export default function ProfilePage() {
|
|||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
</label>
|
</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">
|
<div className="auth-actions">
|
||||||
<button type="submit">
|
<button type="submit">
|
||||||
{authProvider === 'jellyfin' ? 'Update Jellyfin password' : 'Update password'}
|
{passwordProvider === 'jellyfin' ? 'Reset Jellyfin password' : 'Update password'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ type AdminShellProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminShell({ title, subtitle, actions, rail, children }: AdminShellProps) {
|
export default function AdminShell({ title, subtitle, actions, rail, children }: AdminShellProps) {
|
||||||
|
const hasRail = Boolean(rail)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-shell">
|
<div className={`admin-shell ${hasRail ? 'admin-shell--with-rail' : 'admin-shell--no-rail'}`}>
|
||||||
<aside className="admin-shell-nav">
|
<aside className="admin-shell-nav">
|
||||||
<AdminSidebar />
|
<AdminSidebar />
|
||||||
</aside>
|
</aside>
|
||||||
@@ -27,16 +29,7 @@ export default function AdminShell({ title, subtitle, actions, rail, children }:
|
|||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
<aside className="admin-shell-rail">
|
{hasRail ? <aside className="admin-shell-rail">{rail}</aside> : null}
|
||||||
{rail ?? (
|
|
||||||
<div className="admin-rail-card admin-rail-card--placeholder">
|
|
||||||
<span className="admin-rail-eyebrow">Insights</span>
|
|
||||||
<h2>Stats rail</h2>
|
|
||||||
<p>Use this column for counters, live status, and quick metrics for this page.</p>
|
|
||||||
<span className="small-pill">{title}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</aside>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const NAV_GROUPS = [
|
|||||||
title: 'Admin',
|
title: 'Admin',
|
||||||
items: [
|
items: [
|
||||||
{ href: '/admin/notifications', label: 'Notifications' },
|
{ href: '/admin/notifications', label: 'Notifications' },
|
||||||
{ href: '/admin/system', label: 'System guide' },
|
{ href: '/admin/system', label: 'How it works' },
|
||||||
{ href: '/admin/site', label: 'Site' },
|
{ href: '/admin/site', label: 'Site' },
|
||||||
{ href: '/users', label: 'Users' },
|
{ href: '/users', label: 'Users' },
|
||||||
{ href: '/admin/invites', label: 'Invite management' },
|
{ href: '/admin/invites', label: 'Invite management' },
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
|||||||
|
|
||||||
export default function HeaderActions() {
|
export default function HeaderActions() {
|
||||||
const [signedIn, setSignedIn] = useState(false)
|
const [signedIn, setSignedIn] = useState(false)
|
||||||
const [role, setRole] = useState<string | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
@@ -20,11 +19,9 @@ export default function HeaderActions() {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
clearToken()
|
clearToken()
|
||||||
setSignedIn(false)
|
setSignedIn(false)
|
||||||
setRole(null)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const data = await response.json()
|
await response.json()
|
||||||
setRole(data?.role ?? null)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
}
|
}
|
||||||
@@ -39,9 +36,13 @@ export default function HeaderActions() {
|
|||||||
return (
|
return (
|
||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
<a className="header-cta header-cta--left" href="/feedback">Send feedback</a>
|
<a className="header-cta header-cta--left" href="/feedback">Send feedback</a>
|
||||||
<a href="/">Requests</a>
|
<div className="header-actions-center">
|
||||||
<a href="/how-it-works">How it works</a>
|
<a href="/how-it-works">How it works</a>
|
||||||
{role === 'admin' && <a href="/admin">Settings</a>}
|
</div>
|
||||||
|
<div className="header-actions-right">
|
||||||
|
<a href="/">Requests</a>
|
||||||
|
<a href="/profile/invites">Invites</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,11 @@ export default function HeaderIdentity() {
|
|||||||
<a href="/profile" onClick={() => setOpen(false)}>
|
<a href="/profile" onClick={() => setOpen(false)}>
|
||||||
My profile
|
My profile
|
||||||
</a>
|
</a>
|
||||||
|
{identity.role === 'admin' ? (
|
||||||
|
<a href="/admin" onClick={() => setOpen(false)}>
|
||||||
|
Settings
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
<a href="/changelog" onClick={() => setOpen(false)}>
|
<a href="/changelog" onClick={() => setOpen(false)}>
|
||||||
Changelog
|
Changelog
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
5
frontend/package-lock.json
generated
5
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "magent-frontend",
|
"name": "magent-frontend",
|
||||||
"version": "0103262231",
|
"version": "0203261953",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "magent-frontend",
|
"name": "magent-frontend",
|
||||||
"version": "0103262231",
|
"version": "0203261953",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
@@ -977,3 +977,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "magent-frontend",
|
"name": "magent-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0103262231",
|
"version": "0203261953",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
@@ -23,3 +23,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ $repoRoot = Resolve-Path "$PSScriptRoot\\.."
|
|||||||
Set-Location $repoRoot
|
Set-Location $repoRoot
|
||||||
|
|
||||||
$now = Get-Date
|
$now = Get-Date
|
||||||
$buildNumber = "{0}{1}{2}{3}{4}" -f $now.ToString("dd"), $now.ToString("M"), $now.ToString("yy"), $now.ToString("HH"), $now.ToString("mm")
|
$buildNumber = "{0}{1}{2}{3}{4}" -f $now.ToString("dd"), $now.ToString("MM"), $now.ToString("yy"), $now.ToString("HH"), $now.ToString("mm")
|
||||||
|
|
||||||
Write-Host "Build number: $buildNumber"
|
Write-Host "Build number: $buildNumber"
|
||||||
|
|
||||||
|
|||||||
312
scripts/process1.ps1
Normal file
312
scripts/process1.ps1
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
param(
|
||||||
|
[string]$CommitMessage,
|
||||||
|
[switch]$SkipCommit,
|
||||||
|
[switch]$SkipDiscord
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$repoRoot = Resolve-Path "$PSScriptRoot\.."
|
||||||
|
Set-Location $repoRoot
|
||||||
|
|
||||||
|
$Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||||
|
$script:CurrentStep = "initializing"
|
||||||
|
|
||||||
|
function Write-TextFile {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$Path,
|
||||||
|
[Parameter(Mandatory = $true)][string]$Content
|
||||||
|
)
|
||||||
|
|
||||||
|
$fullPath = Join-Path $repoRoot $Path
|
||||||
|
$normalized = $Content -replace "`r`n", "`n"
|
||||||
|
[System.IO.File]::WriteAllText($fullPath, $normalized, $Utf8NoBom)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-LastExitCode {
|
||||||
|
param([Parameter(Mandatory = $true)][string]$CommandName)
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "$CommandName failed with exit code $LASTEXITCODE."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Read-TextFile {
|
||||||
|
param([Parameter(Mandatory = $true)][string]$Path)
|
||||||
|
|
||||||
|
$fullPath = Join-Path $repoRoot $Path
|
||||||
|
return [System.IO.File]::ReadAllText($fullPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-EnvFileValue {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$Path,
|
||||||
|
[Parameter(Mandatory = $true)][string]$Name
|
||||||
|
)
|
||||||
|
|
||||||
|
if (-not (Test-Path $Path)) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
$match = Select-String -Path $Path -Pattern "^$([regex]::Escape($Name))=(.*)$" | Select-Object -First 1
|
||||||
|
if (-not $match) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
return $match.Matches[0].Groups[1].Value.Trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-DiscordWebhookUrl {
|
||||||
|
$candidateNames = @(
|
||||||
|
"PROCESS_DISCORD_WEBHOOK_URL",
|
||||||
|
"MAGENT_NOTIFY_DISCORD_WEBHOOK_URL",
|
||||||
|
"DISCORD_WEBHOOK_URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
foreach ($name in $candidateNames) {
|
||||||
|
$value = [System.Environment]::GetEnvironmentVariable($name)
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($value)) {
|
||||||
|
return $value.Trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($name in $candidateNames) {
|
||||||
|
$value = Get-EnvFileValue -Path ".env" -Name $name
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($value)) {
|
||||||
|
return $value.Trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$configPath = Join-Path $repoRoot "backend/app/config.py"
|
||||||
|
if (Test-Path $configPath) {
|
||||||
|
$configContent = Read-TextFile -Path "backend/app/config.py"
|
||||||
|
$match = [regex]::Match(
|
||||||
|
$configContent,
|
||||||
|
'discord_webhook_url:\s*Optional\[str\]\s*=\s*Field\(\s*default="([^"]+)"',
|
||||||
|
[System.Text.RegularExpressions.RegexOptions]::Singleline
|
||||||
|
)
|
||||||
|
if ($match.Success) {
|
||||||
|
return $match.Groups[1].Value.Trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Send-DiscordUpdate {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$Title,
|
||||||
|
[Parameter(Mandatory = $true)][string]$Body
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($SkipDiscord) {
|
||||||
|
Write-Host "Skipping Discord notification."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$webhookUrl = Get-DiscordWebhookUrl
|
||||||
|
if ([string]::IsNullOrWhiteSpace($webhookUrl)) {
|
||||||
|
Write-Warning "Discord webhook not configured for Process 1."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = "**$Title**`n$Body"
|
||||||
|
Invoke-RestMethod -Method Post -Uri $webhookUrl -ContentType "application/json" -Body (@{ content = $content } | ConvertTo-Json -Compress) | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-BuildNumber {
|
||||||
|
$current = ""
|
||||||
|
if (Test-Path ".build_number") {
|
||||||
|
$current = (Get-Content ".build_number" -Raw).Trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidate = Get-Date
|
||||||
|
$buildNumber = $candidate.ToString("ddMMyyHHmm")
|
||||||
|
if ($buildNumber -eq $current) {
|
||||||
|
$buildNumber = $candidate.AddMinutes(1).ToString("ddMMyyHHmm")
|
||||||
|
}
|
||||||
|
|
||||||
|
return $buildNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
function Wait-ForHttp {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$Url,
|
||||||
|
[int]$Attempts = 30,
|
||||||
|
[int]$DelaySeconds = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
$lastError = $null
|
||||||
|
for ($attempt = 1; $attempt -le $Attempts; $attempt++) {
|
||||||
|
try {
|
||||||
|
return Invoke-RestMethod -Uri $Url -TimeoutSec 10
|
||||||
|
} catch {
|
||||||
|
$lastError = $_
|
||||||
|
Start-Sleep -Seconds $DelaySeconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
$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) {
|
||||||
|
$envContent = Read-TextFile -Path ".env"
|
||||||
|
if ($envContent -match '^BUILD_NUMBER=.*$') {
|
||||||
|
$updatedEnv = [regex]::Replace(
|
||||||
|
$envContent,
|
||||||
|
'^BUILD_NUMBER=.*$',
|
||||||
|
"BUILD_NUMBER=$BuildNumber",
|
||||||
|
[System.Text.RegularExpressions.RegexOptions]::Multiline
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
$updatedEnv = "BUILD_NUMBER=$BuildNumber`n$envContent"
|
||||||
|
}
|
||||||
|
Write-TextFile -Path ".env" -Content $updatedEnv
|
||||||
|
}
|
||||||
|
|
||||||
|
$packageJson = Read-TextFile -Path "frontend/package.json"
|
||||||
|
$packageJsonRegex = [regex]::new('"version"\s*:\s*"\d+"')
|
||||||
|
$updatedPackageJson = $packageJsonRegex.Replace(
|
||||||
|
$packageJson,
|
||||||
|
"`"version`": `"$BuildNumber`"",
|
||||||
|
1
|
||||||
|
)
|
||||||
|
Write-TextFile -Path "frontend/package.json" -Content $updatedPackageJson
|
||||||
|
|
||||||
|
$packageLock = Read-TextFile -Path "frontend/package-lock.json"
|
||||||
|
$packageLockVersionRegex = [regex]::new('"version"\s*:\s*"\d+"')
|
||||||
|
$updatedPackageLock = $packageLockVersionRegex.Replace(
|
||||||
|
$packageLock,
|
||||||
|
"`"version`": `"$BuildNumber`"",
|
||||||
|
1
|
||||||
|
)
|
||||||
|
$packageLockRootRegex = [regex]::new(
|
||||||
|
'(""\s*:\s*\{\s*"name"\s*:\s*"magent-frontend"\s*,\s*"version"\s*:\s*)"\d+"',
|
||||||
|
[System.Text.RegularExpressions.RegexOptions]::Singleline
|
||||||
|
)
|
||||||
|
$updatedPackageLock = $packageLockRootRegex.Replace(
|
||||||
|
$updatedPackageLock,
|
||||||
|
'$1"' + $BuildNumber + '"',
|
||||||
|
1
|
||||||
|
)
|
||||||
|
Write-TextFile -Path "frontend/package-lock.json" -Content $updatedPackageLock
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ChangedFilesSummary {
|
||||||
|
$files = git diff --cached --name-only
|
||||||
|
if (-not $files) {
|
||||||
|
return "No staged files"
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = ($files | Measure-Object).Count
|
||||||
|
$sample = $files | Select-Object -First 8
|
||||||
|
$summary = ($sample -join ", ")
|
||||||
|
if ($count -gt $sample.Count) {
|
||||||
|
$summary = "$summary, +$($count - $sample.Count) more"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "$count files: $summary"
|
||||||
|
}
|
||||||
|
|
||||||
|
$buildNumber = $null
|
||||||
|
$branch = $null
|
||||||
|
$commit = $null
|
||||||
|
$publicInfo = $null
|
||||||
|
$changedFiles = "No staged files"
|
||||||
|
|
||||||
|
try {
|
||||||
|
$branch = (git rev-parse --abbrev-ref HEAD).Trim()
|
||||||
|
$buildNumber = Get-BuildNumber
|
||||||
|
Write-Host "Process 1 build number: $buildNumber"
|
||||||
|
|
||||||
|
$script:CurrentStep = "updating build metadata"
|
||||||
|
Update-BuildFiles -BuildNumber $buildNumber
|
||||||
|
|
||||||
|
$script:CurrentStep = "rebuilding local docker stack"
|
||||||
|
docker compose up -d --build
|
||||||
|
Assert-LastExitCode -CommandName "docker compose up -d --build"
|
||||||
|
|
||||||
|
$script:CurrentStep = "verifying backend health"
|
||||||
|
$health = Wait-ForHttp -Url "http://127.0.0.1:8000/health"
|
||||||
|
if ($health.status -ne "ok") {
|
||||||
|
throw "Health endpoint returned unexpected payload: $($health | ConvertTo-Json -Compress)"
|
||||||
|
}
|
||||||
|
|
||||||
|
$script:CurrentStep = "verifying public build metadata"
|
||||||
|
$publicInfo = Wait-ForHttp -Url "http://127.0.0.1:8000/site/public"
|
||||||
|
if ($publicInfo.buildNumber -ne $buildNumber) {
|
||||||
|
throw "Public build number mismatch. Expected $buildNumber but got $($publicInfo.buildNumber)."
|
||||||
|
}
|
||||||
|
|
||||||
|
$script:CurrentStep = "committing changes"
|
||||||
|
git add -A
|
||||||
|
Assert-LastExitCode -CommandName "git add -A"
|
||||||
|
$changedFiles = Get-ChangedFilesSummary
|
||||||
|
if ((git status --short).Trim()) {
|
||||||
|
if (-not $SkipCommit) {
|
||||||
|
if ([string]::IsNullOrWhiteSpace($CommitMessage)) {
|
||||||
|
$CommitMessage = "Process 1 build $buildNumber"
|
||||||
|
}
|
||||||
|
git commit -m $CommitMessage
|
||||||
|
Assert-LastExitCode -CommandName "git commit"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$commit = (git rev-parse --short HEAD).Trim()
|
||||||
|
|
||||||
|
$body = @(
|
||||||
|
"Build: $buildNumber"
|
||||||
|
"Branch: $branch"
|
||||||
|
"Commit: $commit"
|
||||||
|
"Health: ok"
|
||||||
|
"Public build: $($publicInfo.buildNumber)"
|
||||||
|
"Changes: $changedFiles"
|
||||||
|
) -join "`n"
|
||||||
|
Send-DiscordUpdate -Title "Process 1 complete" -Body $body
|
||||||
|
|
||||||
|
Write-Host "Process 1 completed successfully."
|
||||||
|
} catch {
|
||||||
|
$failureCommit = ""
|
||||||
|
try {
|
||||||
|
$failureCommit = (git rev-parse --short HEAD).Trim()
|
||||||
|
} catch {
|
||||||
|
$failureCommit = "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
$failureBody = @(
|
||||||
|
"Build: $buildNumber"
|
||||||
|
"Branch: $branch"
|
||||||
|
"Commit: $failureCommit"
|
||||||
|
"Step: $script:CurrentStep"
|
||||||
|
"Error: $($_.Exception.Message)"
|
||||||
|
) -join "`n"
|
||||||
|
|
||||||
|
try {
|
||||||
|
Send-DiscordUpdate -Title "Process 1 failed" -Body $failureBody
|
||||||
|
} catch {
|
||||||
|
Write-Warning "Failed to send Discord failure notification: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
|
||||||
|
throw
|
||||||
|
}
|
||||||
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