3 Commits

Author SHA1 Message Date
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 134 additions and 32 deletions

1
.build_number Normal file
View File

@@ -0,0 +1 @@
251260501

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',
created_at TEXT NOT NULL,
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'")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN jellyfin_password_hash TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN last_jellyfin_auth_at TEXT")
except sqlite3.OperationalError:
pass
_backfill_auth_providers()
ensure_admin_user()
@@ -277,7 +287,8 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
with _connect() as conn:
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
WHERE username = ?
""",
@@ -294,6 +305,8 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
"created_at": row[5],
"last_login_at": row[6],
"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:
with _connect() as conn:
rows = conn.execute(

View File

@@ -1,3 +1,5 @@
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, HTTPException, status, Depends
from fastapi.security import OAuth2PasswordRequestForm
@@ -7,6 +9,7 @@ from ..db import (
set_last_login,
get_user_by_username,
set_user_password,
set_jellyfin_auth_cache,
get_user_activity,
get_user_activity_summary,
get_user_request_stats,
@@ -16,7 +19,7 @@ from ..db import (
from ..runtime import get_runtime_settings
from ..clients.jellyfin import JellyfinClient
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
router = APIRouter(prefix="/auth", tags=["auth"])
@@ -26,6 +29,31 @@ def _normalize_username(value: str) -> str:
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")
async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
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)
if not client.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:
response = await client.authenticate_by_name(form_data.username, form_data.password)
response = await client.authenticate_by_name(username, password)
except Exception as 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"):
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")
user = get_user_by_username(form_data.username)
create_user_if_missing(username, "jellyfin-user", role="user", auth_provider="jellyfin")
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")
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")
except Exception:
pass
token = create_access_token(form_data.username, "user")
set_last_login(form_data.username)
return {"access_token": token, "token_type": "bearer", "user": {"username": form_data.username, "role": "user"}}
set_jellyfin_auth_cache(username, password)
token = create_access_token(username, "user")
set_last_login(username)
return {"access_token": token, "token_type": "bearer", "user": {"username": username, "role": "user"}}
@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 ""
stats = get_user_request_stats(username_norm)
global_total = get_global_request_total()
leader = get_global_request_leader()
share = (stats.get("total", 0) / global_total) if global_total else 0
activity_summary = get_user_activity_summary(username) if username else {}
activity_recent = get_user_activity(username, limit=5) if username else []
stats_payload = {
**stats,
"share": share,
"global_total": global_total,
}
if current_user.get("role") == "admin":
stats_payload["most_active_user"] = get_global_request_leader()
return {
"user": current_user,
"stats": {
**stats,
"share": share,
"global_total": global_total,
"most_active_user": leader,
},
"stats": stats_payload,
"activity": {
**activity_summary,
"recent": activity_recent,

View File

@@ -11,6 +11,9 @@ router = APIRouter(prefix="/branding", tags=["branding"])
_BRANDING_DIR = os.path.join(os.getcwd(), "data", "branding")
_LOGO_PATH = os.path.join(_BRANDING_DIR, "logo.png")
_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")
def _ensure_branding_dir() -> None:
@@ -41,6 +44,18 @@ def _ensure_default_branding() -> None:
if os.path.exists(_LOGO_PATH) and os.path.exists(_FAVICON_PATH):
return
_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):
image = Image.new("RGBA", (300, 300), (12, 18, 28, 255))
draw = ImageDraw.Draw(image)

View File

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

View File

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

View File

@@ -188,14 +188,16 @@ export default function ProfilePage() {
: '0%'}
</div>
</div>
<div className="stat-card">
<div className="stat-label">Most active user</div>
<div className="stat-value stat-value--small">
{stats?.most_active_user
? `${stats.most_active_user.username} (${stats.most_active_user.total})`
: 'N/A'}
{profile?.role === 'admin' ? (
<div className="stat-card">
<div className="stat-label">Most active user</div>
<div className="stat-value stat-value--small">
{stats?.most_active_user
? `${stats.most_active_user.username} (${stats.most_active_user.total})`
: 'N/A'}
</div>
</div>
</div>
) : null}
</div>
</section>
<section className="profile-section">