4 Commits

Author SHA1 Message Date
22f90b7e07 Serve bundled branding assets by default 2026-01-25 18:20:30 +13:00
57a4883931 Seed branding logo from bundled assets 2026-01-25 18:01:54 +13:00
6ba41b854b Tidy request sync controls 2026-01-25 17:52:33 +13:00
580b335268 Add Jellyfin login cache and admin-only stats 2026-01-25 17:47:03 +13:00
9 changed files with 153 additions and 42 deletions

1
.build_number Normal file
View File

@@ -0,0 +1 @@
251261817

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -61,7 +61,9 @@ def init_db() -> None:
auth_provider TEXT NOT NULL DEFAULT 'local', auth_provider TEXT NOT NULL DEFAULT 'local',
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
last_login_at TEXT, last_login_at TEXT,
is_blocked INTEGER NOT NULL DEFAULT 0 is_blocked INTEGER NOT NULL DEFAULT 0,
jellyfin_password_hash TEXT,
last_jellyfin_auth_at TEXT
) )
""" """
) )
@@ -141,6 +143,14 @@ def init_db() -> None:
conn.execute("ALTER TABLE users ADD COLUMN auth_provider TEXT NOT NULL DEFAULT 'local'") conn.execute("ALTER TABLE users ADD COLUMN auth_provider TEXT NOT NULL DEFAULT 'local'")
except sqlite3.OperationalError: except sqlite3.OperationalError:
pass pass
try:
conn.execute("ALTER TABLE users ADD COLUMN jellyfin_password_hash TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN last_jellyfin_auth_at TEXT")
except sqlite3.OperationalError:
pass
_backfill_auth_providers() _backfill_auth_providers()
ensure_admin_user() ensure_admin_user()
@@ -277,7 +287,8 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
with _connect() as conn: with _connect() as conn:
row = conn.execute( row = conn.execute(
""" """
SELECT id, username, password_hash, role, auth_provider, created_at, last_login_at, is_blocked SELECT id, username, password_hash, role, auth_provider, created_at, last_login_at,
is_blocked, jellyfin_password_hash, last_jellyfin_auth_at
FROM users FROM users
WHERE username = ? WHERE username = ?
""", """,
@@ -294,6 +305,8 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
"created_at": row[5], "created_at": row[5],
"last_login_at": row[6], "last_login_at": row[6],
"is_blocked": bool(row[7]), "is_blocked": bool(row[7]),
"jellyfin_password_hash": row[8],
"last_jellyfin_auth_at": row[9],
} }
@@ -373,6 +386,22 @@ def set_user_password(username: str, password: str) -> None:
) )
def set_jellyfin_auth_cache(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 jellyfin_password_hash = ?, last_jellyfin_auth_at = ?
WHERE username = ?
""",
(password_hash, timestamp, username),
)
def _backfill_auth_providers() -> None: def _backfill_auth_providers() -> None:
with _connect() as conn: with _connect() as conn:
rows = conn.execute( rows = conn.execute(

View File

@@ -1,3 +1,5 @@
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, HTTPException, status, Depends from fastapi import APIRouter, HTTPException, status, Depends
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
@@ -7,6 +9,7 @@ from ..db import (
set_last_login, set_last_login,
get_user_by_username, get_user_by_username,
set_user_password, set_user_password,
set_jellyfin_auth_cache,
get_user_activity, get_user_activity,
get_user_activity_summary, get_user_activity_summary,
get_user_request_stats, get_user_request_stats,
@@ -16,7 +19,7 @@ from ..db import (
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 from ..security import create_access_token, verify_password
from ..auth import get_current_user from ..auth import get_current_user
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
@@ -26,6 +29,31 @@ def _normalize_username(value: str) -> str:
return value.strip().lower() return value.strip().lower()
def _is_recent_jellyfin_auth(last_auth_at: str) -> bool:
if not last_auth_at:
return False
try:
parsed = datetime.fromisoformat(last_auth_at)
except ValueError:
return False
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
age = datetime.now(timezone.utc) - parsed
return age <= timedelta(days=7)
def _has_valid_jellyfin_cache(user: dict, password: str) -> bool:
if not user or not password:
return False
cached_hash = user.get("jellyfin_password_hash")
last_auth_at = user.get("last_jellyfin_auth_at")
if not cached_hash or not last_auth_at:
return False
if not verify_password(password, cached_hash):
return False
return _is_recent_jellyfin_auth(last_auth_at)
@router.post("/login") @router.post("/login")
async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict: async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
user = verify_user_password(form_data.username, form_data.password) user = verify_user_password(form_data.username, form_data.password)
@@ -48,14 +76,23 @@ async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> di
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key) client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if not client.configured(): if not client.configured():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Jellyfin not configured") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Jellyfin not configured")
username = form_data.username
password = form_data.password
user = get_user_by_username(username)
if user and user.get("is_blocked"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
if user and _has_valid_jellyfin_cache(user, password):
token = create_access_token(username, "user")
set_last_login(username)
return {"access_token": token, "token_type": "bearer", "user": {"username": username, "role": "user"}}
try: try:
response = await client.authenticate_by_name(form_data.username, form_data.password) response = await client.authenticate_by_name(username, password)
except Exception as exc: except Exception as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
if not isinstance(response, dict) or not response.get("User"): if not isinstance(response, dict) or not response.get("User"):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials")
create_user_if_missing(form_data.username, "jellyfin-user", role="user", auth_provider="jellyfin") create_user_if_missing(username, "jellyfin-user", role="user", auth_provider="jellyfin")
user = get_user_by_username(form_data.username) user = get_user_by_username(username)
if user and user.get("is_blocked"): if user and user.get("is_blocked"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
try: try:
@@ -69,9 +106,10 @@ async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> di
create_user_if_missing(name, "jellyfin-user", role="user", auth_provider="jellyfin") create_user_if_missing(name, "jellyfin-user", role="user", auth_provider="jellyfin")
except Exception: except Exception:
pass pass
token = create_access_token(form_data.username, "user") set_jellyfin_auth_cache(username, password)
set_last_login(form_data.username) token = create_access_token(username, "user")
return {"access_token": token, "token_type": "bearer", "user": {"username": form_data.username, "role": "user"}} set_last_login(username)
return {"access_token": token, "token_type": "bearer", "user": {"username": username, "role": "user"}}
@router.post("/jellyseerr/login") @router.post("/jellyseerr/login")
@@ -107,18 +145,19 @@ async def profile(current_user: dict = Depends(get_current_user)) -> dict:
username_norm = _normalize_username(username) if username else "" username_norm = _normalize_username(username) if username else ""
stats = get_user_request_stats(username_norm) stats = get_user_request_stats(username_norm)
global_total = get_global_request_total() global_total = get_global_request_total()
leader = get_global_request_leader()
share = (stats.get("total", 0) / global_total) if global_total else 0 share = (stats.get("total", 0) / global_total) if global_total else 0
activity_summary = get_user_activity_summary(username) if username else {} activity_summary = get_user_activity_summary(username) if username else {}
activity_recent = get_user_activity(username, limit=5) if username else [] activity_recent = get_user_activity(username, limit=5) if username else []
return { stats_payload = {
"user": current_user,
"stats": {
**stats, **stats,
"share": share, "share": share,
"global_total": global_total, "global_total": global_total,
"most_active_user": leader, }
}, if current_user.get("role") == "admin":
stats_payload["most_active_user"] = get_global_request_leader()
return {
"user": current_user,
"stats": stats_payload,
"activity": { "activity": {
**activity_summary, **activity_summary,
"recent": activity_recent, "recent": activity_recent,

View File

@@ -11,6 +11,10 @@ router = APIRouter(prefix="/branding", tags=["branding"])
_BRANDING_DIR = os.path.join(os.getcwd(), "data", "branding") _BRANDING_DIR = os.path.join(os.getcwd(), "data", "branding")
_LOGO_PATH = os.path.join(_BRANDING_DIR, "logo.png") _LOGO_PATH = os.path.join(_BRANDING_DIR, "logo.png")
_FAVICON_PATH = os.path.join(_BRANDING_DIR, "favicon.ico") _FAVICON_PATH = os.path.join(_BRANDING_DIR, "favicon.ico")
_BUNDLED_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "assets", "branding"))
_BUNDLED_LOGO_PATH = os.path.join(_BUNDLED_DIR, "logo.png")
_BUNDLED_FAVICON_PATH = os.path.join(_BUNDLED_DIR, "favicon.ico")
_BRANDING_SOURCE = os.getenv("BRANDING_SOURCE", "bundled").lower()
def _ensure_branding_dir() -> None: def _ensure_branding_dir() -> None:
@@ -41,6 +45,18 @@ def _ensure_default_branding() -> None:
if os.path.exists(_LOGO_PATH) and os.path.exists(_FAVICON_PATH): if os.path.exists(_LOGO_PATH) and os.path.exists(_FAVICON_PATH):
return return
_ensure_branding_dir() _ensure_branding_dir()
if not os.path.exists(_LOGO_PATH) and os.path.exists(_BUNDLED_LOGO_PATH):
try:
with open(_BUNDLED_LOGO_PATH, "rb") as source, open(_LOGO_PATH, "wb") as target:
target.write(source.read())
except OSError:
pass
if not os.path.exists(_FAVICON_PATH) and os.path.exists(_BUNDLED_FAVICON_PATH):
try:
with open(_BUNDLED_FAVICON_PATH, "rb") as source, open(_FAVICON_PATH, "wb") as target:
target.write(source.read())
except OSError:
pass
if not os.path.exists(_LOGO_PATH): if not os.path.exists(_LOGO_PATH):
image = Image.new("RGBA", (300, 300), (12, 18, 28, 255)) image = Image.new("RGBA", (300, 300), (12, 18, 28, 255))
draw = ImageDraw.Draw(image) draw = ImageDraw.Draw(image)
@@ -65,24 +81,32 @@ def _ensure_default_branding() -> None:
favicon.save(_FAVICON_PATH, format="ICO") favicon.save(_FAVICON_PATH, format="ICO")
def _resolve_branding_paths() -> tuple[str, str]:
if _BRANDING_SOURCE == "data":
_ensure_default_branding()
return _LOGO_PATH, _FAVICON_PATH
if os.path.exists(_BUNDLED_LOGO_PATH) and os.path.exists(_BUNDLED_FAVICON_PATH):
return _BUNDLED_LOGO_PATH, _BUNDLED_FAVICON_PATH
_ensure_default_branding()
return _LOGO_PATH, _FAVICON_PATH
@router.get("/logo.png") @router.get("/logo.png")
async def branding_logo() -> FileResponse: async def branding_logo() -> FileResponse:
if not os.path.exists(_LOGO_PATH): logo_path, _ = _resolve_branding_paths()
_ensure_default_branding() if not os.path.exists(logo_path):
if not os.path.exists(_LOGO_PATH):
raise HTTPException(status_code=404, detail="Logo not found") raise HTTPException(status_code=404, detail="Logo not found")
headers = {"Cache-Control": "public, max-age=300"} headers = {"Cache-Control": "no-store"}
return FileResponse(_LOGO_PATH, media_type="image/png", headers=headers) return FileResponse(logo_path, media_type="image/png", headers=headers)
@router.get("/favicon.ico") @router.get("/favicon.ico")
async def branding_favicon() -> FileResponse: async def branding_favicon() -> FileResponse:
if not os.path.exists(_FAVICON_PATH): _, favicon_path = _resolve_branding_paths()
_ensure_default_branding() if not os.path.exists(favicon_path):
if not os.path.exists(_FAVICON_PATH):
raise HTTPException(status_code=404, detail="Favicon not found") raise HTTPException(status_code=404, detail="Favicon not found")
headers = {"Cache-Control": "public, max-age=300"} headers = {"Cache-Control": "no-store"}
return FileResponse(_FAVICON_PATH, media_type="image/x-icon", headers=headers) return FileResponse(favicon_path, media_type="image/x-icon", headers=headers)
async def save_branding_image(file: UploadFile) -> Dict[str, Any]: async def save_branding_image(file: UploadFile) -> Dict[str, Any]:

View File

@@ -714,7 +714,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
.map((sectionGroup) => ( .map((sectionGroup) => (
<section key={sectionGroup.key} className="admin-section"> <section key={sectionGroup.key} className="admin-section">
<div className="section-header"> <div className="section-header">
<h2>{sectionGroup.title}</h2> <h2>{sectionGroup.key === 'requests' ? 'Sync controls' : sectionGroup.title}</h2>
{sectionGroup.key === 'sonarr' && ( {sectionGroup.key === 'sonarr' && (
<button type="button" onClick={() => loadOptions('sonarr')}> <button type="button" onClick={() => loadOptions('sonarr')}>
Refresh Sonarr options Refresh Sonarr options
@@ -737,17 +737,22 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</button> </button>
) : null} ) : null}
{showRequestsExtras && sectionGroup.key === 'requests' && ( {showRequestsExtras && sectionGroup.key === 'requests' && (
<div className="sync-actions-block">
<div className="sync-actions"> <div className="sync-actions">
<button type="button" onClick={syncRequests}> <button type="button" onClick={syncRequests}>
Full refresh Full refresh (all requests)
</button> </button>
<button type="button" className="ghost-button" onClick={syncRequestsDelta}> <button type="button" className="ghost-button" onClick={syncRequestsDelta}>
Quick refresh (new changes) Quick refresh (delta changes)
</button> </button>
</div> </div>
<div className="meta sync-note">
Full refresh reloads the entire list. Quick refresh only checks recent changes.
</div>
</div>
)} )}
</div> </div>
{SECTION_DESCRIPTIONS[sectionGroup.key] && ( {SECTION_DESCRIPTIONS[sectionGroup.key] && !settingsSection && (
<p className="section-subtitle">{SECTION_DESCRIPTIONS[sectionGroup.key]}</p> <p className="section-subtitle">{SECTION_DESCRIPTIONS[sectionGroup.key]}</p>
)} )}
{sectionGroup.key === 'sonarr' && sonarrError && ( {sectionGroup.key === 'sonarr' && sonarrError && (

View File

@@ -1047,6 +1047,17 @@ button span {
flex-wrap: wrap; flex-wrap: wrap;
} }
.sync-actions-block {
display: grid;
gap: 6px;
justify-items: end;
text-align: right;
}
.sync-note {
margin-top: 0;
}
.section-header button { .section-header button {
background: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.08);
color: var(--ink); color: var(--ink);

View File

@@ -188,6 +188,7 @@ export default function ProfilePage() {
: '0%'} : '0%'}
</div> </div>
</div> </div>
{profile?.role === 'admin' ? (
<div className="stat-card"> <div className="stat-card">
<div className="stat-label">Most active user</div> <div className="stat-label">Most active user</div>
<div className="stat-value stat-value--small"> <div className="stat-value stat-value--small">
@@ -196,6 +197,7 @@ export default function ProfilePage() {
: 'N/A'} : 'N/A'}
</div> </div>
</div> </div>
) : null}
</div> </div>
</section> </section>
<section className="profile-section"> <section className="profile-section">