diff --git a/VERSION b/VERSION index 23b64a4..ca59bb0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.40.0 \ No newline at end of file +0.41.0 \ No newline at end of file diff --git a/backend/alembic/versions/0054_audit_log.py b/backend/alembic/versions/0054_audit_log.py new file mode 100644 index 0000000..5f83938 --- /dev/null +++ b/backend/alembic/versions/0054_audit_log.py @@ -0,0 +1,51 @@ +"""audit_log: append-only admin/scheduler audit trail (who/what/when/before->after) + +Revision ID: 0054_audit_log +Revises: 0053_user_session_epoch +Create Date: 2026-07-12 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "0054_audit_log" +down_revision: Union[str, None] = "0053_user_session_epoch" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "audit_log", + sa.Column("id", sa.Integer(), primary_key=True), + # SET NULL on user delete: keep the trail (it just becomes system-attributed). + sa.Column( + "actor_id", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("action", sa.String(length=48), nullable=False), + sa.Column("target_type", sa.String(length=32), nullable=True), + sa.Column("target_id", sa.String(length=128), nullable=True), + sa.Column("summary", sa.String(length=255), nullable=True), + sa.Column("before", sa.JSON(), nullable=True), + sa.Column("after", sa.JSON(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + ) + op.create_index("ix_audit_log_actor_id", "audit_log", ["actor_id"]) + op.create_index("ix_audit_log_action", "audit_log", ["action"]) + op.create_index("ix_audit_log_created_at", "audit_log", ["created_at"]) + + +def downgrade() -> None: + op.drop_index("ix_audit_log_created_at", table_name="audit_log") + op.drop_index("ix_audit_log_action", table_name="audit_log") + op.drop_index("ix_audit_log_actor_id", table_name="audit_log") + op.drop_table("audit_log") diff --git a/backend/alembic/versions/0055_channel_keywords.py b/backend/alembic/versions/0055_channel_keywords.py new file mode 100644 index 0000000..0a2877c --- /dev/null +++ b/backend/alembic/versions/0055_channel_keywords.py @@ -0,0 +1,23 @@ +"""channels.keywords: creator keyword tags from brandingSettings (About tab) + +Revision ID: 0055_channel_keywords +Revises: 0054_audit_log +Create Date: 2026-07-12 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "0055_channel_keywords" +down_revision: Union[str, None] = "0054_audit_log" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("channels", sa.Column("keywords", sa.Text(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("channels", "keywords") diff --git a/backend/app/audit.py b/backend/app/audit.py new file mode 100644 index 0000000..9c564c1 --- /dev/null +++ b/backend/app/audit.py @@ -0,0 +1,85 @@ +"""Admin/scheduler audit trail: an append-only record of who changed what and when. + +Design (locked): log at ACTION / job-run granularity, NEVER per-row — a scheduler run that +touches thousands of videos is ONE audit row summarising the outcome. Automatic job runs are +only logged when they actually changed something (or errored); all admin actions and all manual +runs are always logged. Secrets are never stored (config changes log the key name only). + +`record()` only db.add()s the row — the caller commits it in the SAME transaction as the +mutation it describes, so the trail can't drift from reality (and a rolled-back mutation drops +its audit row too). +""" +from datetime import datetime, timedelta, timezone + +from sqlalchemy import delete +from sqlalchemy.orm import Session + +from app.models import AuditLog + + +class AuditAction: + """Canonical `_` audit keys — the single source of truth. User-facing labels + are resolved on the client via i18n `auditActions.` (mirrors quota.QuotaAction).""" + + # User / roles / access + USER_ROLE_CHANGE = "user_role_change" + USER_SUSPEND = "user_suspend" + USER_UNSUSPEND = "user_unsuspend" + USER_DELETE = "user_delete" + INVITE_APPROVE = "invite_approve" + INVITE_DENY = "invite_deny" + INVITE_ADD = "invite_add" + DEMO_ADD = "demo_add" + DEMO_REMOVE = "demo_remove" + DEMO_RESET = "demo_reset" + DISCOVERY_PURGE = "discovery_purge" + # Config + CONFIG_SET = "config_set" + CONFIG_RESET = "config_reset" + # Scheduler / sync + SCHEDULER_INTERVAL = "scheduler_interval" + MAINTENANCE_TUNE = "maintenance_tune" + SYNC_PAUSE = "sync_pause" + SYNC_RESUME = "sync_resume" + # Scheduler job outcome / run-summary (actor_id set = a manual "run now"; NULL = automatic) + JOB_RUN = "job_run" + # The log itself was cleared (a single row survives the clear as tamper-evidence) + AUDIT_CLEAR = "audit_clear" + + +def record( + db: Session, + actor_id: int | None, + action: str, + *, + target_type: str | None = None, + target_id: str | int | None = None, + summary: str | None = None, + before: dict | None = None, + after: dict | None = None, +) -> None: + """Append an audit row (NOT committed — the caller commits it with the mutation).""" + db.add( + AuditLog( + actor_id=actor_id, + action=action, + target_type=target_type, + # Truncate to the column widths so an over-long target/summary can never abort the + # mutation this row is committed alongside (an audit row must not break what it observes). + target_id=None if target_id is None else str(target_id)[:128], + summary=(summary or None) and summary[:255], + before=before, + after=after, + ) + ) + + +def gc(db: Session, retention_days: int) -> int: + """Delete audit rows older than `retention_days`. Returns the number removed. A + retention_days <= 0 disables pruning (keep forever).""" + if retention_days <= 0: + return 0 + cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days) + result = db.execute(delete(AuditLog).where(AuditLog.created_at < cutoff)) + db.commit() + return result.rowcount or 0 diff --git a/backend/app/config.py b/backend/app/config.py index a4d6c10..78bb9a6 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -185,6 +185,9 @@ class Settings(BaseSettings): download_retention_days: int = 30 download_gc_grace_days: int = 2 download_gc_minutes: int = 360 + # Admin audit log: how long to keep rows (days; 0 = keep forever) + how often the GC runs. + audit_retention_days: int = 180 + audit_gc_minutes: int = 1440 # On-disk layout: "plex" (Channel/Season YYYY/… [id].ext + .nfo/poster) or "flat". download_layout: str = "plex" # Default SponsorBlock (skip sponsor segments) for new custom profiles. diff --git a/backend/app/main.py b/backend/app/main.py index 772888a..41f8a19 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -29,6 +29,7 @@ from app.config import settings from app.db import SessionLocal from app.routes import ( admin, + audit as audit_routes, channels, config as config_routes, downloads, @@ -146,6 +147,7 @@ app.include_router(public_routes.router) app.include_router(admin.router) app.include_router(config_routes.router) app.include_router(scheduler_routes.router) +app.include_router(audit_routes.router) app.include_router(quota.router) app.include_router(version.router) app.include_router(setup_routes.router) diff --git a/backend/app/models.py b/backend/app/models.py index 9b31e67..d31a9f8 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -180,6 +180,9 @@ class Channel(Base, TimestampMixin): topic_categories: Mapped[list | None] = mapped_column(JSON) default_language: Mapped[str | None] = mapped_column(String(16)) country: Mapped[str | None] = mapped_column(String(8)) + # Creator keyword tags (brandingSettings.channel.keywords) — a single space-separated string, + # multi-word tags quoted; the client parses it into chips for the About tab. + keywords: Mapped[str | None] = mapped_column(Text) # Sync bookkeeping. details_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) @@ -495,6 +498,30 @@ class QuotaEvent(Base): ) +class AuditLog(Base): + """Append-only admin/scheduler audit trail: who did what, when, and (where it applies) + the before->after values. `actor_id` is the acting admin; NULL means the scheduler / + background did it (SET NULL on user delete keeps the trail). Logged at ACTION / job-run + granularity — never per-row — so a scheduler run that touches thousands of videos is ONE + row summarising the outcome. `before`/`after` are small JSON snapshots (secrets excluded).""" + + __tablename__ = "audit_log" + + id: Mapped[int] = mapped_column(primary_key=True) + actor_id: Mapped[int | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL"), index=True + ) + action: Mapped[str] = mapped_column(String(48), index=True) + target_type: Mapped[str | None] = mapped_column(String(32)) + target_id: Mapped[str | None] = mapped_column(String(128)) + summary: Mapped[str | None] = mapped_column(String(255)) + before: Mapped[dict | None] = mapped_column(JSON) + after: Mapped[dict | None] = mapped_column(JSON) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), index=True + ) + + class AppState(Base): """Single-row global app state (admin-controlled).""" diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py index 3edd262..c507c6c 100644 --- a/backend/app/routes/admin.py +++ b/backend/app/routes/admin.py @@ -6,7 +6,9 @@ from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from sqlalchemy import delete, func, select from sqlalchemy.orm import Session +from app import audit from app import email as email_mod +from app.audit import AuditAction from app.auth import ( admin_user, count_admins, @@ -74,6 +76,14 @@ def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite: inv.status = "approved" if approved else "denied" inv.decided_at = datetime.now(timezone.utc) inv.decided_by = admin.email + audit.record( + db, + admin.id, + AuditAction.INVITE_APPROVE if approved else AuditAction.INVITE_DENY, + target_type="invite", + target_id=inv.id, + summary=inv.email, + ) db.commit() return inv @@ -117,6 +127,7 @@ def add_invite( inv.status = "approved" inv.decided_at = datetime.now(timezone.utc) inv.decided_by = admin.email + audit.record(db, admin.id, AuditAction.INVITE_ADD, target_type="invite", target_id=inv.id, summary=email) db.commit() _activate_user_by_email(db, email) return _serialize(inv) @@ -174,6 +185,12 @@ def set_user_role( ): raise HTTPException(status_code=400, detail="Can't remove the last admin.") changed = target.role != role + if changed: + audit.record( + db, admin.id, AuditAction.USER_ROLE_CHANGE, + target_type="user", target_id=target.id, summary=target.email, + before={"role": target.role}, after={"role": role}, + ) target.role = role db.commit() if changed: @@ -210,6 +227,12 @@ def set_user_suspended( ): raise HTTPException(status_code=400, detail="Can't suspend the last active admin.") was_suspended = target.is_suspended + if was_suspended != suspended: + audit.record( + db, admin.id, + AuditAction.USER_SUSPEND if suspended else AuditAction.USER_UNSUSPEND, + target_type="user", target_id=target.id, summary=target.email, + ) target.is_suspended = suspended db.commit() if was_suspended and not suspended: @@ -240,6 +263,11 @@ def admin_delete_user( ) if target.role == "admin" and not target.is_suspended and count_admins(db, active_only=True) <= 1: raise HTTPException(status_code=400, detail="Can't delete the last admin.") + # Record BEFORE the purge — the target row (and its email) is gone afterwards. + audit.record( + db, admin.id, AuditAction.USER_DELETE, + target_type="user", target_id=target.id, summary=target.email, + ) purge_user(db, target, background) return {"deleted": user_id} @@ -289,6 +317,7 @@ def add_demo_whitelist( added_by=admin.email, ) db.add(row) + audit.record(db, admin.id, AuditAction.DEMO_ADD, target_type="demo", target_id=email, summary=email) db.commit() return _serialize_demo(row) @@ -296,11 +325,12 @@ def add_demo_whitelist( @router.delete("/demo/whitelist/{entry_id}") def remove_demo_whitelist( entry_id: int, - _: User = Depends(admin_user), + admin: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: row = db.get(DemoWhitelist, entry_id) if row is not None: + audit.record(db, admin.id, AuditAction.DEMO_REMOVE, target_type="demo", target_id=row.email, summary=row.email) db.delete(row) db.commit() return {"deleted": entry_id} @@ -375,21 +405,30 @@ def reset_demo_state(db: Session, demo: User) -> int: @router.post("/demo/reset") def reset_demo( - _: User = Depends(admin_user), db: Session = Depends(get_db) + admin: User = Depends(admin_user), db: Session = Depends(get_db) ) -> dict: """Manually clean up the shared demo sandbox back to a baseline. A scheduled job does the same automatically (admin-tunable interval); this is the admin's on-demand button.""" demo = get_or_create_demo_user(db) - return {"reset": True, "playlists_seeded": reset_demo_state(db, demo)} + seeded = reset_demo_state(db, demo) + audit.record(db, admin.id, AuditAction.DEMO_RESET, summary=f"{seeded} sample playlists re-seeded") + db.commit() + return {"reset": True, "playlists_seeded": seeded} @router.post("/purge-discovery") def purge_discovery( - _: User = Depends(admin_user), db: Session = Depends(get_db) + admin: User = Depends(admin_user), db: Session = Depends(get_db) ) -> dict: """Reclaim all un-kept discovery content NOW (ignoring the grace period): live-search result videos nobody watched/saved/subscribed to, plus explored-but-unsubscribed channels. The scheduled discovery-cleanup job does the same on its interval; this is the on-demand button.""" from app.sync.explore import purge_unkept_explored, purge_unkept_search - return {**purge_unkept_search(db, grace_days=0), **purge_unkept_explored(db)} + result = {**purge_unkept_search(db, grace_days=0), **purge_unkept_explored(db)} + audit.record( + db, admin.id, AuditAction.DISCOVERY_PURGE, + summary=", ".join(f"{k}: {v}" for k, v in result.items()) or None, after=result, + ) + db.commit() + return result diff --git a/backend/app/routes/audit.py b/backend/app/routes/audit.py new file mode 100644 index 0000000..206a32c --- /dev/null +++ b/backend/app/routes/audit.py @@ -0,0 +1,71 @@ +"""Admin audit log API: read the append-only who/what/when trail, and clear it. + +Admin-only. The client renders these rows in a DataTable (client-side sort/filter/paginate), so +this returns the most recent window of rows (capped) plus the true total, rather than paging +server-side — the log stays small in practice (state-changing runs + admin actions only).""" +from fastapi import APIRouter, Depends +from sqlalchemy import delete, func, select +from sqlalchemy.orm import Session + +from app import audit +from app.audit import AuditAction +from app.db import get_db +from app.models import AuditLog, User +from app.routes.admin import admin_user + +router = APIRouter(prefix="/api/admin/audit", tags=["admin"]) + +# Cap the client-side window. The audit log is small by design; if it ever outgrows this we'd +# switch the DataTable to server-side paging. +MAX_ROWS = 2000 + + +def _serialize(row: AuditLog, actors: dict[int, str]) -> dict: + return { + "id": row.id, + "created_at": row.created_at.isoformat() if row.created_at else None, + # actor_id NULL = the scheduler/background did it (client shows "System"); a set id that + # can't be resolved would mean a deleted user, but SET NULL makes that NULL — so present. + "actor": actors.get(row.actor_id) if row.actor_id is not None else None, + "action": row.action, + "target_type": row.target_type, + "target_id": row.target_id, + "summary": row.summary, + "before": row.before, + "after": row.after, + } + + +@router.get("") +def list_audit( + limit: int = 500, + _: User = Depends(admin_user), + db: Session = Depends(get_db), +) -> dict: + limit = max(1, min(limit, MAX_ROWS)) + total = db.scalar(select(func.count()).select_from(AuditLog)) or 0 + rows = ( + db.execute(select(AuditLog).order_by(AuditLog.created_at.desc()).limit(limit)) + .scalars() + .all() + ) + actor_ids = {r.actor_id for r in rows if r.actor_id is not None} + actors: dict[int, str] = {} + if actor_ids: + actors = dict( + db.execute(select(User.id, User.email).where(User.id.in_(actor_ids))).all() + ) + return { + "items": [_serialize(r, actors) for r in rows], + "total": total, + "returned": len(rows), + } + + +@router.delete("") +def clear_audit(admin: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict: + """Clear the audit log. Leaves ONE row recording who cleared it + how many (tamper-evidence).""" + n = db.execute(delete(AuditLog)).rowcount or 0 + audit.record(db, admin.id, AuditAction.AUDIT_CLEAR, summary=f"Cleared {n} audit entries") + db.commit() + return {"cleared": n} diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py index 38b5dff..e74589f 100644 --- a/backend/app/routes/channels.py +++ b/backend/app/routes/channels.py @@ -233,6 +233,9 @@ def _channel_detail_dict( "published_at": channel.published_at.isoformat() if channel.published_at else None, "external_links": channel.external_links or [], "country": channel.country, + "default_language": channel.default_language, + "topic_categories": channel.topic_categories or [], + "keywords": channel.keywords, "subscribed": subscribed, "explored": explored, "blocked": blocked, diff --git a/backend/app/routes/config.py b/backend/app/routes/config.py index 3d05345..9e90a10 100644 --- a/backend/app/routes/config.py +++ b/backend/app/routes/config.py @@ -1,16 +1,29 @@ """Admin Configuration page API: read/edit the DB-overridable operational settings defined in app.sysconfig. Secret values (e.g. SMTP password) are write-only — never returned.""" +import re + from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session +from app import audit from app import email as email_mod from app import sysconfig +from app.audit import AuditAction from app.db import get_db from app.models import User from app.routes.admin import admin_user router = APIRouter(prefix="/api/admin/config", tags=["admin"]) +# Strip URL userinfo (user:pass@) so a non-secret setting that happens to embed credentials — +# e.g. an authenticated egress proxy http://user:pass@host:port — never lands plaintext in the +# audit trail. Non-secret values are shown live in the UI, but the log shouldn't retain creds. +_URL_USERINFO = re.compile(r"(://)[^/@\s]+@") + + +def _redact(v): + return _URL_USERINFO.sub(r"\1***@", v) if isinstance(v, str) else v + @router.get("") def get_config(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict: @@ -21,7 +34,7 @@ def get_config(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> def set_config( key: str, payload: dict, - _: User = Depends(admin_user), + admin: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: s = sysconfig.spec(key) @@ -34,22 +47,44 @@ def set_config( status_code=400, detail="Set TOKEN_ENCRYPTION_KEY to store secrets in the database.", ) + old = None if s.secret else sysconfig.get(db, key) try: sysconfig.set_value(db, key, payload["value"]) except (TypeError, ValueError) as exc: raise HTTPException(status_code=400, detail=str(exc) or "Invalid value") + # Never log a secret's value — only that it was changed, by whom. Non-secret values are logged + # but with URL credentials redacted (a proxy/URL setting can embed user:pass). Skip the row when + # a non-secret value didn't actually change (a no-op re-save shouldn't add trail noise); secrets + # are write-only so we can't compare — always log. + new = None if s.secret else sysconfig.get(db, key) + if s.secret or new != old: + audit.record( + db, admin.id, AuditAction.CONFIG_SET, target_type="config", target_id=key, + summary=f"{key} = (secret updated)" if s.secret else f"{key} = {_redact(new)}", + before=None if s.secret else {"value": _redact(old)}, + after=None if s.secret else {"value": _redact(new)}, + ) + db.commit() return sysconfig.describe(db) @router.delete("/{key}") def reset_config( key: str, - _: User = Depends(admin_user), + admin: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: - if sysconfig.spec(key) is None: + s = sysconfig.spec(key) + if s is None: raise HTTPException(status_code=404, detail="Unknown config key") + old = None if s.secret else sysconfig.get(db, key) sysconfig.reset(db, key) + audit.record( + db, admin.id, AuditAction.CONFIG_RESET, target_type="config", target_id=key, + summary=f"{key} → default", + before=None if s.secret else {"value": _redact(old)}, + ) + db.commit() return sysconfig.describe(db) diff --git a/backend/app/routes/scheduler.py b/backend/app/routes/scheduler.py index e88b1db..eedbcd9 100644 --- a/backend/app/routes/scheduler.py +++ b/backend/app/routes/scheduler.py @@ -5,7 +5,8 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import and_, func, or_, select from sqlalchemy.orm import Session -from app import quota, state, sysconfig +from app import audit, quota, state, sysconfig +from app.audit import AuditAction from app.config import settings from app.db import get_db from app.models import Channel, Subscription, User, Video @@ -28,7 +29,7 @@ router = APIRouter(prefix="/api/admin/scheduler", tags=["admin"]) def set_job_interval( job_id: str, payload: dict, - _: User = Depends(admin_user), + admin: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: """Change how often a scheduler job runs (minutes). Persisted as an override and applied @@ -45,6 +46,11 @@ def set_job_interval( detail=f"interval must be between {MIN_INTERVAL} and {MAX_INTERVAL} minutes", ) applied = apply_interval(db, job_id, minutes) + audit.record( + db, admin.id, AuditAction.SCHEDULER_INTERVAL, target_type="job", target_id=job_id, + summary=f"{job_id} → {applied} min/run", after={"interval_minutes": applied}, + ) + db.commit() return {"id": job_id, "interval_minutes": applied} @@ -74,7 +80,7 @@ def run_all_now(user: User = Depends(admin_user)) -> dict: @router.patch("/maintenance") def set_maintenance_settings( payload: dict, - _: User = Depends(admin_user), + admin: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: """Admin-tune the maintenance job's rolling re-validation batch (videos re-checked per @@ -91,7 +97,10 @@ def set_maintenance_settings( f"{state.MAINTENANCE_BATCH_MAX}" ), ) - return {"revalidate_batch": state.set_maintenance_batch(db, value)} + new = state.set_maintenance_batch(db, value) + audit.record(db, admin.id, AuditAction.MAINTENANCE_TUNE, summary=f"revalidate batch = {new}", after={"revalidate_batch": new}) + db.commit() + return {"revalidate_batch": new} @router.get("") diff --git a/backend/app/routes/sync.py b/backend/app/routes/sync.py index 0ee9f4e..9cfa522 100644 --- a/backend/app/routes/sync.py +++ b/backend/app/routes/sync.py @@ -2,7 +2,8 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import and_, case, func, select, update from sqlalchemy.orm import Session -from app import quota, state +from app import audit, quota, state +from app.audit import AuditAction from app.auth import admin_user, current_user, has_read_scope, require_human from app.db import get_db from app.models import Channel, Subscription, User, Video @@ -188,12 +189,16 @@ def deep_all( @router.post("/pause") -def pause_sync(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict: +def pause_sync(admin: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict: state.set_sync_paused(db, True) + audit.record(db, admin.id, AuditAction.SYNC_PAUSE, summary="Background sync paused") + db.commit() return {"paused": True} @router.post("/resume") -def resume_sync(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict: +def resume_sync(admin: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict: state.set_sync_paused(db, False) + audit.record(db, admin.id, AuditAction.SYNC_RESUME, summary="Background sync resumed") + db.commit() return {"paused": False} diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py index e5e97b2..c08e985 100644 --- a/backend/app/scheduler.py +++ b/backend/app/scheduler.py @@ -9,7 +9,8 @@ from typing import Callable from apscheduler.schedulers.background import BackgroundScheduler from sqlalchemy import select -from app import progress, quota +from app import audit, progress, quota +from app.audit import AuditAction from app.config import settings from app.db import SessionLocal from app.downloads.gc import run_download_gc @@ -66,6 +67,7 @@ JOB_INTERVALS: dict[str, int] = { "plex_sync": settings.plex_sync_interval_min, "plex_watch_sync": settings.plex_watch_sync_interval_min, "plex_watch_reconcile": settings.plex_watch_reconcile_interval_min, + "audit_gc": settings.audit_gc_minutes, } # Sane bounds for an admin-set interval (minutes). @@ -127,6 +129,38 @@ def _notify_done(db, actor_id: int, job_id: str, status: str, summary: str | Non logger.exception("failed to write completion notification for job %s", job_id) +def _run_changed(result) -> bool: + """Did an automatic job run actually do something worth an audit row? (No-op polls don't.) + A "change" = a truthy NUMERIC count; status-string dicts like {"skipped": "disabled"} (Plex + off — the default) or {"skipped": "no demo account"} are no-ops, not changes, so they don't + flood the trail every interval.""" + if result is None: + return False + if isinstance(result, dict): + return any(isinstance(v, (int, float)) and v for v in result.values()) + if isinstance(result, (int, float)): + return result != 0 + return bool(result) + + +def _audit_run(db, actor_id, job_id, status, summary, result) -> None: + """Write ONE run-summary audit row per job run (never per-item). Manual runs (an admin + clicked "run now" → actor_id set) and errors are always logged; automatic runs only when + they changed something, so routine no-op polls don't spam the trail. Best-effort.""" + if actor_id is None and status == "ok" and not _run_changed(result): + return + try: + audit.record( + db, actor_id, AuditAction.JOB_RUN, target_type="job", target_id=job_id, + summary=f"{job_id}: {status}" + (f" — {summary}" if summary else ""), + after={"status": status, "summary": summary}, + ) + db.commit() + except Exception: + db.rollback() + logger.exception("failed to write audit row for job %s", job_id) + + def _job(name: str, fn) -> None: db = SessionLocal() actor_id = _manual_actor.get() # set only for a manual "run now" trigger @@ -142,6 +176,7 @@ def _job(name: str, fn) -> None: last_result="paused", progress=None) if actor_id is not None: _notify_done(db, actor_id, name, "skipped", "sync paused") + _audit_run(db, actor_id, name, "skipped", "sync paused", None) return with progress.bind(sink): result = fn(db) @@ -151,6 +186,7 @@ def _job(name: str, fn) -> None: last_result=summary, progress=None) if actor_id is not None: _notify_done(db, actor_id, name, "ok", summary) + _audit_run(db, actor_id, name, "ok", summary, result) except Exception as exc: db.rollback() logger.exception("job %s failed", name) @@ -159,6 +195,7 @@ def _job(name: str, fn) -> None: last_error=err, progress=None) if actor_id is not None: _notify_done(db, actor_id, name, "error", err) + _audit_run(db, actor_id, name, "error", err, None) finally: db.close() @@ -307,6 +344,16 @@ def _plex_watch_reconcile_job() -> None: _job("plex_watch_reconcile", run_plex_watch_reconcile) +def _audit_gc_job() -> None: + # Prune audit-log rows older than the admin-set retention (audit_retention_days; 0 = keep + # forever). Pure DB, no quota. + def work(db): + from app import sysconfig + return {"reaped": audit.gc(db, sysconfig.get_int(db, "audit_retention_days"))} + + _job("audit_gc", work) + + # job_id -> wrapper. The single source of truth for which jobs exist and how to run one, # shared by start_scheduler (recurring registration) and trigger_job (manual "run now"). JOB_FUNCS: dict[str, Callable[[], None]] = { @@ -324,6 +371,7 @@ JOB_FUNCS: dict[str, Callable[[], None]] = { "plex_sync": _plex_sync_job, "plex_watch_sync": _plex_watch_sync_job, "plex_watch_reconcile": _plex_watch_reconcile_job, + "audit_gc": _audit_gc_job, } diff --git a/backend/app/sync/subscriptions.py b/backend/app/sync/subscriptions.py index a007296..e8b98cc 100644 --- a/backend/app/sync/subscriptions.py +++ b/backend/app/sync/subscriptions.py @@ -69,6 +69,7 @@ def apply_channel_details(db: Session, items: list[dict]) -> None: channel.banner_url = branding.get("image", {}).get("bannerExternalUrl") channel.external_links = _external_links(branding) channel.topic_categories = topics.get("topicCategories") + channel.keywords = (branding.get("channel") or {}).get("keywords") channel.details_synced_at = now diff --git a/backend/app/sysconfig.py b/backend/app/sysconfig.py index 8264fb5..0c8d66c 100644 --- a/backend/app/sysconfig.py +++ b/backend/app/sysconfig.py @@ -77,6 +77,9 @@ SPECS: tuple[ConfigSpec, ...] = ( ConfigSpec("download_layout", "str", "downloads", "download_layout"), ConfigSpec("download_worker_concurrency", "int", "downloads", "download_worker_concurrency", min=1, max=16), ConfigSpec("sponsorblock_default", "bool", "downloads", "sponsorblock_default"), + # --- Audit log --- + # Days to keep admin audit-log rows before the audit_gc job prunes them (0 = keep forever). + ConfigSpec("audit_retention_days", "int", "audit", "audit_retention_days", min=0, max=3650), # --- Plex integration (optional; local-file playback + Plex metadata) --- ConfigSpec("plex_enabled", "bool", "plex", "plex_enabled"), ConfigSpec("plex_server_url", "str", "plex", "plex_server_url"), diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a498575..4f0892a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -73,6 +73,7 @@ const Stats = lazy(() => import("./components/Stats")); const Scheduler = lazy(() => import("./components/Scheduler")); const ConfigPanel = lazy(() => import("./components/ConfigPanel")); const AdminUsers = lazy(() => import("./components/AdminUsers")); +const AuditLog = lazy(() => import("./components/AuditLog")); const SettingsPanel = lazy(() => import("./components/SettingsPanel")); const NotificationsPanel = lazy(() => import("./components/NotificationsPanel")); const Messages = lazy(() => import("./components/Messages")); @@ -828,6 +829,8 @@ export default function App() { ) : page === "users" && meQuery.data!.role === "admin" ? ( + ) : page === "audit" && meQuery.data!.role === "admin" ? ( + ) : page === "playlists" ? ( ) : page === "notifications" ? ( diff --git a/frontend/src/components/AuditLog.tsx b/frontend/src/components/AuditLog.tsx new file mode 100644 index 0000000..12fb281 --- /dev/null +++ b/frontend/src/components/AuditLog.tsx @@ -0,0 +1,90 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Trash2 } from "lucide-react"; +import { api } from "../lib/api"; +import { notify } from "../lib/notifications"; +import { LS } from "../lib/storage"; +import { Section } from "./ui/form"; +import { useConfirm } from "./ConfirmProvider"; +import DataTable from "./DataTable"; +import { auditColumns } from "./auditColumns"; + +// Admin-only audit trail: an append-only who/what/when log of admin actions + scheduler run +// summaries (never per-row). Read-only apart from Clear. Client-side sort/filter/paginate via +// DataTable; the API returns the most recent window (capped) — the log stays small by design. +export default function AuditLog() { + const { t } = useTranslation(); + const qc = useQueryClient(); + const confirm = useConfirm(); + const q = useQuery({ queryKey: ["admin-audit"], queryFn: () => api.adminAudit() }); + const data = q.data; + const rows = useMemo(() => data?.items ?? [], [data]); + + const actionOptions = useMemo(() => { + const present = Array.from(new Set(rows.map((r) => r.action))).sort(); + return present.map((a) => ({ value: a, label: t(`auditActions.${a}`, a) })); + }, [rows, t]); + const columns = useMemo(() => auditColumns(t, actionOptions), [t, actionOptions]); + + const clear = useMutation({ + mutationFn: () => api.clearAudit(), + onSuccess: (r) => { + qc.invalidateQueries({ queryKey: ["admin-audit"] }); + notify({ level: "success", message: t("audit.cleared", { count: r.cleared }) }); + }, + onError: () => notify({ level: "error", message: t("audit.clearFailed") }), + }); + const onClear = async () => { + if ( + await confirm({ + title: t("audit.clearTitle"), + message: t("audit.clearConfirm"), + confirmLabel: t("audit.clear"), + danger: true, + }) + ) + clear.mutate(); + }; + + const truncated = !!data && data.total > data.returned; + + return ( +
+
+

{t("audit.intro")}

+ {q.isLoading ? ( +
{t("common.loading")}
+ ) : rows.length === 0 ? ( +

{t("audit.empty")}

+ ) : ( + <> + String(r.id)} + persistKey={LS.auditTable} + controlsPosition="top" + controlsLeading={ + + } + emptyText={t("audit.empty")} + /> + {truncated && ( +

+ {t("audit.truncated", { returned: data!.returned, total: data!.total })} +

+ )} + + )} +
+
+ ); +} diff --git a/frontend/src/components/ChannelDiscovery.tsx b/frontend/src/components/ChannelDiscovery.tsx index 17e98d1..0353e5a 100644 --- a/frontend/src/components/ChannelDiscovery.tsx +++ b/frontend/src/components/ChannelDiscovery.tsx @@ -18,9 +18,11 @@ import { useConfirm } from "./ConfirmProvider"; export default function ChannelDiscovery({ canWrite, onOpenWizard, + onViewChannel, }: { canWrite: boolean; onOpenWizard: () => void; + onViewChannel: (id: string, name: string) => void; }) { const { t } = useTranslation(); const qc = useQueryClient(); @@ -76,7 +78,13 @@ export default function ChannelDiscovery({ filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` }, cardPrimary: true, render: (c) => ( - + onViewChannel(c.id, c.title ?? c.id)} + /> ), }, subsColumn(t), diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index 08e259b..703c287 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -10,6 +10,40 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { api, type FeedFilters, type Me } from "../lib/api"; import { channelYouTubeUrl, formatViews } from "../lib/format"; +// --- About-tab metadata helpers --- +// ISO 3166-1 alpha-2 code → 🇺🇸 regional-indicator flag emoji. +function countryFlag(code: string): string { + if (!/^[A-Za-z]{2}$/.test(code)) return ""; + return String.fromCodePoint(...[...code.toUpperCase()].map((c) => 0x1f1e6 + c.charCodeAt(0) - 65)); +} +function displayName(code: string, lang: string, type: "region" | "language"): string { + try { + return new Intl.DisplayNames([lang], { type }).of(type === "region" ? code.toUpperCase() : code) ?? code; + } catch { + return code; + } +} +// YouTube keywords are ONE space-separated string with multi-word tags quoted: gaming "let's play". +function parseKeywords(s: string): string[] { + return (s.match(/"[^"]+"|\S+/g) ?? []).map((k) => k.replace(/^"|"$/g, "")).filter(Boolean); +} +// topicCategories are Wikipedia URLs (…/wiki/Music) → a readable label, de-duplicated. +function topicLabels(urls: string[]): string[] { + const seen = new Set(); + for (const url of urls) { + const seg = url.split("/wiki/")[1] ?? url.split("/").pop() ?? url; + let label = seg; + try { + label = decodeURIComponent(seg); + } catch { + /* keep raw */ + } + label = label.replace(/_/g, " ").trim(); + if (label) seen.add(label); + } + return [...seen]; +} + // A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the // channel's videos (the catalog filtered to this channel). For an un-subscribed channel it // auto-ingests recent uploads in the background ("explore") so they're browsable immediately, @@ -34,12 +68,20 @@ export default function ChannelPage({ const qc = useQueryClient(); const confirm = useConfirm(); const [tab, setTab] = useState<"videos" | "about">("videos"); + // The banner URL is valid, but googleusercontent intermittently throttles it when the channel + // page fires the banner + avatar + ~16 thumbnails in one burst — the request is dropped and the + // browser then NEGATIVE-CACHES the miss, so the banner stays broken even though a retry would + // succeed. So on error we retry a few times with a cache-buster (forcing a fresh request), and + // only fall back to the blank bar if it keeps failing (a genuinely dead URL — rare). + const MAX_BANNER_RETRIES = 3; + const [bannerAttempt, setBannerAttempt] = useState(0); const detail = useQuery({ queryKey: ["channel", channelId], queryFn: () => api.channelDetail(channelId), }); const ch = detail.data; + useEffect(() => setBannerAttempt(0), [ch?.banner_url]); // fresh channel/banner → try again // Background auto-ingest ("explore") of an un-subscribed channel's uploads. const [nextToken, setNextToken] = useState(undefined); @@ -147,16 +189,23 @@ export default function ChannelPage({ ch?.total_view_count != null ? t("channel.totalViews", { formatted: formatViews(ch.total_view_count) }) : null, joined ? t("channel.joined", { date: joined }) : null, ].filter(Boolean) as string[]; + const topics = ch?.topic_categories?.length ? topicLabels(ch.topic_categories) : []; + const keywords = ch?.keywords ? parseKeywords(ch.keywords) : []; + const hasAboutDetails = + !!ch?.country || !!ch?.default_language || topics.length > 0 || keywords.length > 0; const tabClass = (active: boolean) => active ? "pb-2 text-sm font-medium border-b-2 border-fg text-fg" : "pb-2 text-sm text-muted hover:text-fg border-b-2 border-transparent"; return ( -
+ // scrollbar-gutter:stable reserves the scrollbar track on BOTH tabs, so switching between the + // tall Videos tab (scrollbar) and the short About tab (no scrollbar) no longer shifts the + // header/logo/buttons horizontally as the scrollbar appears/disappears. +
{/* Banner + back — OPTION C: inset, rounded card (not full-bleed) */}
- {ch?.banner_url ? ( + {ch?.banner_url && bannerAttempt <= MAX_BANNER_RETRIES ? ( // Match YouTube's banner: the stored bannerExternalUrl is the full 16:9 template at a // low default size, so request a crisp wide version (=w1707) and object-cover the // desktop "safe area" — the centre 2560×423 (~6:1) band. @@ -165,13 +214,27 @@ export default function ChannelPage({ style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }} > per attempt; the ?r= cache-buster on a retry bypasses the + // browser's negative cache (googleusercontent accepts an extra query param). + key={bannerAttempt} + src={`${ch.banner_url}=w1707${bannerAttempt ? `?r=${bannerAttempt}` : ""}`} alt="" className="w-full h-full object-cover object-center" + onError={() => { + if (bannerAttempt <= MAX_BANNER_RETRIES) { + window.setTimeout(() => setBannerAttempt((a) => a + 1), 500 * (bannerAttempt + 1)); + } + }} />
) : ( -
+ // No banner (or a dead one): a neutral bar the SAME height as a real banner, so the + // overlapping avatar + the Back button have the same room and don't collide (a short bar + // let the -mt-8 avatar ride up into the Back button). +
)}
)}
diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index b4e2043..d41af77 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -399,7 +399,7 @@ export default function Channels({ return ( <> {tabs} - + ); } diff --git a/frontend/src/components/ConfigPanel.tsx b/frontend/src/components/ConfigPanel.tsx index 730dd0d..6f80394 100644 --- a/frontend/src/components/ConfigPanel.tsx +++ b/frontend/src/components/ConfigPanel.tsx @@ -15,7 +15,7 @@ import { LS } from "../lib/storage"; // applied together via one Save/Discard bar (consistent with the Settings page); nothing hits // the server until Save. Group order is fixed where known so logically related settings (e.g. // Email/SMTP) stay together. -const GROUP_ORDER = ["access", "email", "youtube", "google", "quota", "backfill", "shorts", "batch", "downloads", "plex"]; +const GROUP_ORDER = ["access", "email", "youtube", "google", "quota", "backfill", "shorts", "batch", "downloads", "audit", "plex"]; export default function ConfigPanel() { const { t } = useTranslation(); diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 37e0423..a2a76e5 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -552,25 +552,33 @@ export default function Feed({ ); })} -