Merge: promote dev to prod
This commit is contained in:
commit
5268c9fdcb
48 changed files with 952 additions and 58 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.40.0
|
0.41.0
|
||||||
51
backend/alembic/versions/0054_audit_log.py
Normal file
51
backend/alembic/versions/0054_audit_log.py
Normal file
|
|
@ -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")
|
||||||
23
backend/alembic/versions/0055_channel_keywords.py
Normal file
23
backend/alembic/versions/0055_channel_keywords.py
Normal file
|
|
@ -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")
|
||||||
85
backend/app/audit.py
Normal file
85
backend/app/audit.py
Normal file
|
|
@ -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 `<entity>_<action>` audit keys — the single source of truth. User-facing labels
|
||||||
|
are resolved on the client via i18n `auditActions.<key>` (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
|
||||||
|
|
@ -185,6 +185,9 @@ class Settings(BaseSettings):
|
||||||
download_retention_days: int = 30
|
download_retention_days: int = 30
|
||||||
download_gc_grace_days: int = 2
|
download_gc_grace_days: int = 2
|
||||||
download_gc_minutes: int = 360
|
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".
|
# On-disk layout: "plex" (Channel/Season YYYY/… [id].ext + .nfo/poster) or "flat".
|
||||||
download_layout: str = "plex"
|
download_layout: str = "plex"
|
||||||
# Default SponsorBlock (skip sponsor segments) for new custom profiles.
|
# Default SponsorBlock (skip sponsor segments) for new custom profiles.
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ from app.config import settings
|
||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
from app.routes import (
|
from app.routes import (
|
||||||
admin,
|
admin,
|
||||||
|
audit as audit_routes,
|
||||||
channels,
|
channels,
|
||||||
config as config_routes,
|
config as config_routes,
|
||||||
downloads,
|
downloads,
|
||||||
|
|
@ -146,6 +147,7 @@ app.include_router(public_routes.router)
|
||||||
app.include_router(admin.router)
|
app.include_router(admin.router)
|
||||||
app.include_router(config_routes.router)
|
app.include_router(config_routes.router)
|
||||||
app.include_router(scheduler_routes.router)
|
app.include_router(scheduler_routes.router)
|
||||||
|
app.include_router(audit_routes.router)
|
||||||
app.include_router(quota.router)
|
app.include_router(quota.router)
|
||||||
app.include_router(version.router)
|
app.include_router(version.router)
|
||||||
app.include_router(setup_routes.router)
|
app.include_router(setup_routes.router)
|
||||||
|
|
|
||||||
|
|
@ -180,6 +180,9 @@ class Channel(Base, TimestampMixin):
|
||||||
topic_categories: Mapped[list | None] = mapped_column(JSON)
|
topic_categories: Mapped[list | None] = mapped_column(JSON)
|
||||||
default_language: Mapped[str | None] = mapped_column(String(16))
|
default_language: Mapped[str | None] = mapped_column(String(16))
|
||||||
country: Mapped[str | None] = mapped_column(String(8))
|
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.
|
# Sync bookkeeping.
|
||||||
details_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
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):
|
class AppState(Base):
|
||||||
"""Single-row global app state (admin-controlled)."""
|
"""Single-row global app state (admin-controlled)."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,9 @@ from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||||
from sqlalchemy import delete, func, select
|
from sqlalchemy import delete, func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import audit
|
||||||
from app import email as email_mod
|
from app import email as email_mod
|
||||||
|
from app.audit import AuditAction
|
||||||
from app.auth import (
|
from app.auth import (
|
||||||
admin_user,
|
admin_user,
|
||||||
count_admins,
|
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.status = "approved" if approved else "denied"
|
||||||
inv.decided_at = datetime.now(timezone.utc)
|
inv.decided_at = datetime.now(timezone.utc)
|
||||||
inv.decided_by = admin.email
|
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()
|
db.commit()
|
||||||
return inv
|
return inv
|
||||||
|
|
||||||
|
|
@ -117,6 +127,7 @@ def add_invite(
|
||||||
inv.status = "approved"
|
inv.status = "approved"
|
||||||
inv.decided_at = datetime.now(timezone.utc)
|
inv.decided_at = datetime.now(timezone.utc)
|
||||||
inv.decided_by = admin.email
|
inv.decided_by = admin.email
|
||||||
|
audit.record(db, admin.id, AuditAction.INVITE_ADD, target_type="invite", target_id=inv.id, summary=email)
|
||||||
db.commit()
|
db.commit()
|
||||||
_activate_user_by_email(db, email)
|
_activate_user_by_email(db, email)
|
||||||
return _serialize(inv)
|
return _serialize(inv)
|
||||||
|
|
@ -174,6 +185,12 @@ def set_user_role(
|
||||||
):
|
):
|
||||||
raise HTTPException(status_code=400, detail="Can't remove the last admin.")
|
raise HTTPException(status_code=400, detail="Can't remove the last admin.")
|
||||||
changed = target.role != role
|
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
|
target.role = role
|
||||||
db.commit()
|
db.commit()
|
||||||
if changed:
|
if changed:
|
||||||
|
|
@ -210,6 +227,12 @@ def set_user_suspended(
|
||||||
):
|
):
|
||||||
raise HTTPException(status_code=400, detail="Can't suspend the last active admin.")
|
raise HTTPException(status_code=400, detail="Can't suspend the last active admin.")
|
||||||
was_suspended = target.is_suspended
|
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
|
target.is_suspended = suspended
|
||||||
db.commit()
|
db.commit()
|
||||||
if was_suspended and not suspended:
|
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:
|
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.")
|
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)
|
purge_user(db, target, background)
|
||||||
return {"deleted": user_id}
|
return {"deleted": user_id}
|
||||||
|
|
||||||
|
|
@ -289,6 +317,7 @@ def add_demo_whitelist(
|
||||||
added_by=admin.email,
|
added_by=admin.email,
|
||||||
)
|
)
|
||||||
db.add(row)
|
db.add(row)
|
||||||
|
audit.record(db, admin.id, AuditAction.DEMO_ADD, target_type="demo", target_id=email, summary=email)
|
||||||
db.commit()
|
db.commit()
|
||||||
return _serialize_demo(row)
|
return _serialize_demo(row)
|
||||||
|
|
||||||
|
|
@ -296,11 +325,12 @@ def add_demo_whitelist(
|
||||||
@router.delete("/demo/whitelist/{entry_id}")
|
@router.delete("/demo/whitelist/{entry_id}")
|
||||||
def remove_demo_whitelist(
|
def remove_demo_whitelist(
|
||||||
entry_id: int,
|
entry_id: int,
|
||||||
_: User = Depends(admin_user),
|
admin: User = Depends(admin_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
row = db.get(DemoWhitelist, entry_id)
|
row = db.get(DemoWhitelist, entry_id)
|
||||||
if row is not None:
|
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.delete(row)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"deleted": entry_id}
|
return {"deleted": entry_id}
|
||||||
|
|
@ -375,21 +405,30 @@ def reset_demo_state(db: Session, demo: User) -> int:
|
||||||
|
|
||||||
@router.post("/demo/reset")
|
@router.post("/demo/reset")
|
||||||
def reset_demo(
|
def reset_demo(
|
||||||
_: User = Depends(admin_user), db: Session = Depends(get_db)
|
admin: User = Depends(admin_user), db: Session = Depends(get_db)
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Manually clean up the shared demo sandbox back to a baseline. A scheduled job does the
|
"""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."""
|
same automatically (admin-tunable interval); this is the admin's on-demand button."""
|
||||||
demo = get_or_create_demo_user(db)
|
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")
|
@router.post("/purge-discovery")
|
||||||
def 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:
|
) -> dict:
|
||||||
"""Reclaim all un-kept discovery content NOW (ignoring the grace period): live-search result
|
"""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
|
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."""
|
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
|
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
|
||||||
|
|
|
||||||
71
backend/app/routes/audit.py
Normal file
71
backend/app/routes/audit.py
Normal file
|
|
@ -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}
|
||||||
|
|
@ -233,6 +233,9 @@ def _channel_detail_dict(
|
||||||
"published_at": channel.published_at.isoformat() if channel.published_at else None,
|
"published_at": channel.published_at.isoformat() if channel.published_at else None,
|
||||||
"external_links": channel.external_links or [],
|
"external_links": channel.external_links or [],
|
||||||
"country": channel.country,
|
"country": channel.country,
|
||||||
|
"default_language": channel.default_language,
|
||||||
|
"topic_categories": channel.topic_categories or [],
|
||||||
|
"keywords": channel.keywords,
|
||||||
"subscribed": subscribed,
|
"subscribed": subscribed,
|
||||||
"explored": explored,
|
"explored": explored,
|
||||||
"blocked": blocked,
|
"blocked": blocked,
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,29 @@
|
||||||
"""Admin Configuration page API: read/edit the DB-overridable operational settings defined in
|
"""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."""
|
app.sysconfig. Secret values (e.g. SMTP password) are write-only — never returned."""
|
||||||
|
import re
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app import audit
|
||||||
from app import email as email_mod
|
from app import email as email_mod
|
||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
|
from app.audit import AuditAction
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import User
|
from app.models import User
|
||||||
from app.routes.admin import admin_user
|
from app.routes.admin import admin_user
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin/config", tags=["admin"])
|
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("")
|
@router.get("")
|
||||||
def get_config(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
|
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(
|
def set_config(
|
||||||
key: str,
|
key: str,
|
||||||
payload: dict,
|
payload: dict,
|
||||||
_: User = Depends(admin_user),
|
admin: User = Depends(admin_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
s = sysconfig.spec(key)
|
s = sysconfig.spec(key)
|
||||||
|
|
@ -34,22 +47,44 @@ def set_config(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail="Set TOKEN_ENCRYPTION_KEY to store secrets in the database.",
|
detail="Set TOKEN_ENCRYPTION_KEY to store secrets in the database.",
|
||||||
)
|
)
|
||||||
|
old = None if s.secret else sysconfig.get(db, key)
|
||||||
try:
|
try:
|
||||||
sysconfig.set_value(db, key, payload["value"])
|
sysconfig.set_value(db, key, payload["value"])
|
||||||
except (TypeError, ValueError) as exc:
|
except (TypeError, ValueError) as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc) or "Invalid value")
|
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)
|
return sysconfig.describe(db)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{key}")
|
@router.delete("/{key}")
|
||||||
def reset_config(
|
def reset_config(
|
||||||
key: str,
|
key: str,
|
||||||
_: User = Depends(admin_user),
|
admin: User = Depends(admin_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
if sysconfig.spec(key) is None:
|
s = sysconfig.spec(key)
|
||||||
|
if s is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown config key")
|
raise HTTPException(status_code=404, detail="Unknown config key")
|
||||||
|
old = None if s.secret else sysconfig.get(db, key)
|
||||||
sysconfig.reset(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)
|
return sysconfig.describe(db)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy import and_, func, or_, select
|
from sqlalchemy import and_, func, or_, select
|
||||||
from sqlalchemy.orm import Session
|
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.config import settings
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import Channel, Subscription, User, Video
|
from app.models import Channel, Subscription, User, Video
|
||||||
|
|
@ -28,7 +29,7 @@ router = APIRouter(prefix="/api/admin/scheduler", tags=["admin"])
|
||||||
def set_job_interval(
|
def set_job_interval(
|
||||||
job_id: str,
|
job_id: str,
|
||||||
payload: dict,
|
payload: dict,
|
||||||
_: User = Depends(admin_user),
|
admin: User = Depends(admin_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Change how often a scheduler job runs (minutes). Persisted as an override and applied
|
"""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",
|
detail=f"interval must be between {MIN_INTERVAL} and {MAX_INTERVAL} minutes",
|
||||||
)
|
)
|
||||||
applied = apply_interval(db, job_id, 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}
|
return {"id": job_id, "interval_minutes": applied}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -74,7 +80,7 @@ def run_all_now(user: User = Depends(admin_user)) -> dict:
|
||||||
@router.patch("/maintenance")
|
@router.patch("/maintenance")
|
||||||
def set_maintenance_settings(
|
def set_maintenance_settings(
|
||||||
payload: dict,
|
payload: dict,
|
||||||
_: User = Depends(admin_user),
|
admin: User = Depends(admin_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Admin-tune the maintenance job's rolling re-validation batch (videos re-checked per
|
"""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}"
|
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("")
|
@router.get("")
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy import and_, case, func, select, update
|
from sqlalchemy import and_, case, func, select, update
|
||||||
from sqlalchemy.orm import Session
|
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.auth import admin_user, current_user, has_read_scope, require_human
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import Channel, Subscription, User, Video
|
from app.models import Channel, Subscription, User, Video
|
||||||
|
|
@ -188,12 +189,16 @@ def deep_all(
|
||||||
|
|
||||||
|
|
||||||
@router.post("/pause")
|
@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)
|
state.set_sync_paused(db, True)
|
||||||
|
audit.record(db, admin.id, AuditAction.SYNC_PAUSE, summary="Background sync paused")
|
||||||
|
db.commit()
|
||||||
return {"paused": True}
|
return {"paused": True}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/resume")
|
@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)
|
state.set_sync_paused(db, False)
|
||||||
|
audit.record(db, admin.id, AuditAction.SYNC_RESUME, summary="Background sync resumed")
|
||||||
|
db.commit()
|
||||||
return {"paused": False}
|
return {"paused": False}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,8 @@ from typing import Callable
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
from sqlalchemy import select
|
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.config import settings
|
||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
from app.downloads.gc import run_download_gc
|
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_sync": settings.plex_sync_interval_min,
|
||||||
"plex_watch_sync": settings.plex_watch_sync_interval_min,
|
"plex_watch_sync": settings.plex_watch_sync_interval_min,
|
||||||
"plex_watch_reconcile": settings.plex_watch_reconcile_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).
|
# 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)
|
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:
|
def _job(name: str, fn) -> None:
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
actor_id = _manual_actor.get() # set only for a manual "run now" trigger
|
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)
|
last_result="paused", progress=None)
|
||||||
if actor_id is not None:
|
if actor_id is not None:
|
||||||
_notify_done(db, actor_id, name, "skipped", "sync paused")
|
_notify_done(db, actor_id, name, "skipped", "sync paused")
|
||||||
|
_audit_run(db, actor_id, name, "skipped", "sync paused", None)
|
||||||
return
|
return
|
||||||
with progress.bind(sink):
|
with progress.bind(sink):
|
||||||
result = fn(db)
|
result = fn(db)
|
||||||
|
|
@ -151,6 +186,7 @@ def _job(name: str, fn) -> None:
|
||||||
last_result=summary, progress=None)
|
last_result=summary, progress=None)
|
||||||
if actor_id is not None:
|
if actor_id is not None:
|
||||||
_notify_done(db, actor_id, name, "ok", summary)
|
_notify_done(db, actor_id, name, "ok", summary)
|
||||||
|
_audit_run(db, actor_id, name, "ok", summary, result)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
logger.exception("job %s failed", name)
|
logger.exception("job %s failed", name)
|
||||||
|
|
@ -159,6 +195,7 @@ def _job(name: str, fn) -> None:
|
||||||
last_error=err, progress=None)
|
last_error=err, progress=None)
|
||||||
if actor_id is not None:
|
if actor_id is not None:
|
||||||
_notify_done(db, actor_id, name, "error", err)
|
_notify_done(db, actor_id, name, "error", err)
|
||||||
|
_audit_run(db, actor_id, name, "error", err, None)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
@ -307,6 +344,16 @@ def _plex_watch_reconcile_job() -> None:
|
||||||
_job("plex_watch_reconcile", run_plex_watch_reconcile)
|
_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,
|
# 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").
|
# shared by start_scheduler (recurring registration) and trigger_job (manual "run now").
|
||||||
JOB_FUNCS: dict[str, Callable[[], None]] = {
|
JOB_FUNCS: dict[str, Callable[[], None]] = {
|
||||||
|
|
@ -324,6 +371,7 @@ JOB_FUNCS: dict[str, Callable[[], None]] = {
|
||||||
"plex_sync": _plex_sync_job,
|
"plex_sync": _plex_sync_job,
|
||||||
"plex_watch_sync": _plex_watch_sync_job,
|
"plex_watch_sync": _plex_watch_sync_job,
|
||||||
"plex_watch_reconcile": _plex_watch_reconcile_job,
|
"plex_watch_reconcile": _plex_watch_reconcile_job,
|
||||||
|
"audit_gc": _audit_gc_job,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ def apply_channel_details(db: Session, items: list[dict]) -> None:
|
||||||
channel.banner_url = branding.get("image", {}).get("bannerExternalUrl")
|
channel.banner_url = branding.get("image", {}).get("bannerExternalUrl")
|
||||||
channel.external_links = _external_links(branding)
|
channel.external_links = _external_links(branding)
|
||||||
channel.topic_categories = topics.get("topicCategories")
|
channel.topic_categories = topics.get("topicCategories")
|
||||||
|
channel.keywords = (branding.get("channel") or {}).get("keywords")
|
||||||
channel.details_synced_at = now
|
channel.details_synced_at = now
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,9 @@ SPECS: tuple[ConfigSpec, ...] = (
|
||||||
ConfigSpec("download_layout", "str", "downloads", "download_layout"),
|
ConfigSpec("download_layout", "str", "downloads", "download_layout"),
|
||||||
ConfigSpec("download_worker_concurrency", "int", "downloads", "download_worker_concurrency", min=1, max=16),
|
ConfigSpec("download_worker_concurrency", "int", "downloads", "download_worker_concurrency", min=1, max=16),
|
||||||
ConfigSpec("sponsorblock_default", "bool", "downloads", "sponsorblock_default"),
|
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) ---
|
# --- Plex integration (optional; local-file playback + Plex metadata) ---
|
||||||
ConfigSpec("plex_enabled", "bool", "plex", "plex_enabled"),
|
ConfigSpec("plex_enabled", "bool", "plex", "plex_enabled"),
|
||||||
ConfigSpec("plex_server_url", "str", "plex", "plex_server_url"),
|
ConfigSpec("plex_server_url", "str", "plex", "plex_server_url"),
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ const Stats = lazy(() => import("./components/Stats"));
|
||||||
const Scheduler = lazy(() => import("./components/Scheduler"));
|
const Scheduler = lazy(() => import("./components/Scheduler"));
|
||||||
const ConfigPanel = lazy(() => import("./components/ConfigPanel"));
|
const ConfigPanel = lazy(() => import("./components/ConfigPanel"));
|
||||||
const AdminUsers = lazy(() => import("./components/AdminUsers"));
|
const AdminUsers = lazy(() => import("./components/AdminUsers"));
|
||||||
|
const AuditLog = lazy(() => import("./components/AuditLog"));
|
||||||
const SettingsPanel = lazy(() => import("./components/SettingsPanel"));
|
const SettingsPanel = lazy(() => import("./components/SettingsPanel"));
|
||||||
const NotificationsPanel = lazy(() => import("./components/NotificationsPanel"));
|
const NotificationsPanel = lazy(() => import("./components/NotificationsPanel"));
|
||||||
const Messages = lazy(() => import("./components/Messages"));
|
const Messages = lazy(() => import("./components/Messages"));
|
||||||
|
|
@ -828,6 +829,8 @@ export default function App() {
|
||||||
<ConfigPanel />
|
<ConfigPanel />
|
||||||
) : page === "users" && meQuery.data!.role === "admin" ? (
|
) : page === "users" && meQuery.data!.role === "admin" ? (
|
||||||
<AdminUsers me={meQuery.data!} />
|
<AdminUsers me={meQuery.data!} />
|
||||||
|
) : page === "audit" && meQuery.data!.role === "admin" ? (
|
||||||
|
<AuditLog />
|
||||||
) : page === "playlists" ? (
|
) : page === "playlists" ? (
|
||||||
<Playlists canWrite={meQuery.data!.can_write} />
|
<Playlists canWrite={meQuery.data!.can_write} />
|
||||||
) : page === "notifications" ? (
|
) : page === "notifications" ? (
|
||||||
|
|
|
||||||
90
frontend/src/components/AuditLog.tsx
Normal file
90
frontend/src/components/AuditLog.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="p-4 max-w-5xl w-full mx-auto">
|
||||||
|
<Section card title={t("audit.title")}>
|
||||||
|
<p className="text-xs text-muted leading-relaxed mb-3">{t("audit.intro")}</p>
|
||||||
|
{q.isLoading ? (
|
||||||
|
<div className="text-muted py-8">{t("common.loading")}</div>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted">{t("audit.empty")}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<DataTable
|
||||||
|
rows={rows}
|
||||||
|
columns={columns}
|
||||||
|
rowKey={(r) => String(r.id)}
|
||||||
|
persistKey={LS.auditTable}
|
||||||
|
controlsPosition="top"
|
||||||
|
controlsLeading={
|
||||||
|
<button
|
||||||
|
onClick={onClear}
|
||||||
|
disabled={clear.isPending}
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg border border-border text-muted hover:text-red-400 disabled:opacity-50 transition"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
{t("audit.clear")}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
emptyText={t("audit.empty")}
|
||||||
|
/>
|
||||||
|
{truncated && (
|
||||||
|
<p className="text-[11px] text-muted mt-2">
|
||||||
|
{t("audit.truncated", { returned: data!.returned, total: data!.total })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -18,9 +18,11 @@ import { useConfirm } from "./ConfirmProvider";
|
||||||
export default function ChannelDiscovery({
|
export default function ChannelDiscovery({
|
||||||
canWrite,
|
canWrite,
|
||||||
onOpenWizard,
|
onOpenWizard,
|
||||||
|
onViewChannel,
|
||||||
}: {
|
}: {
|
||||||
canWrite: boolean;
|
canWrite: boolean;
|
||||||
onOpenWizard: () => void;
|
onOpenWizard: () => void;
|
||||||
|
onViewChannel: (id: string, name: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
@ -76,7 +78,13 @@ export default function ChannelDiscovery({
|
||||||
filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` },
|
filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` },
|
||||||
cardPrimary: true,
|
cardPrimary: true,
|
||||||
render: (c) => (
|
render: (c) => (
|
||||||
<ChannelLink id={c.id} title={c.title} handle={c.handle} thumbnailUrl={c.thumbnail_url} />
|
<ChannelLink
|
||||||
|
id={c.id}
|
||||||
|
title={c.title}
|
||||||
|
handle={c.handle}
|
||||||
|
thumbnailUrl={c.thumbnail_url}
|
||||||
|
onView={() => onViewChannel(c.id, c.title ?? c.id)}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
subsColumn<DiscoveredChannel>(t),
|
subsColumn<DiscoveredChannel>(t),
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,40 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||||
import { api, type FeedFilters, type Me } from "../lib/api";
|
import { api, type FeedFilters, type Me } from "../lib/api";
|
||||||
import { channelYouTubeUrl, formatViews } from "../lib/format";
|
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<string>();
|
||||||
|
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
|
// 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
|
// 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,
|
// 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 qc = useQueryClient();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
const [tab, setTab] = useState<"videos" | "about">("videos");
|
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({
|
const detail = useQuery({
|
||||||
queryKey: ["channel", channelId],
|
queryKey: ["channel", channelId],
|
||||||
queryFn: () => api.channelDetail(channelId),
|
queryFn: () => api.channelDetail(channelId),
|
||||||
});
|
});
|
||||||
const ch = detail.data;
|
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.
|
// Background auto-ingest ("explore") of an un-subscribed channel's uploads.
|
||||||
const [nextToken, setNextToken] = useState<string | null | undefined>(undefined);
|
const [nextToken, setNextToken] = useState<string | null | undefined>(undefined);
|
||||||
|
|
@ -147,16 +189,23 @@ export default function ChannelPage({
|
||||||
ch?.total_view_count != null ? t("channel.totalViews", { formatted: formatViews(ch.total_view_count) }) : null,
|
ch?.total_view_count != null ? t("channel.totalViews", { formatted: formatViews(ch.total_view_count) }) : null,
|
||||||
joined ? t("channel.joined", { date: joined }) : null,
|
joined ? t("channel.joined", { date: joined }) : null,
|
||||||
].filter(Boolean) as string[];
|
].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) =>
|
const tabClass = (active: boolean) =>
|
||||||
active
|
active
|
||||||
? "pb-2 text-sm font-medium border-b-2 border-fg text-fg"
|
? "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";
|
: "pb-2 text-sm text-muted hover:text-fg border-b-2 border-transparent";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex-1 min-w-0 overflow-y-auto">
|
// 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.
|
||||||
|
<main className="flex-1 min-w-0 overflow-y-auto [scrollbar-gutter:stable]">
|
||||||
{/* Banner + back — OPTION C: inset, rounded card (not full-bleed) */}
|
{/* Banner + back — OPTION C: inset, rounded card (not full-bleed) */}
|
||||||
<div className="relative px-4 sm:px-6 pt-3">
|
<div className="relative px-4 sm:px-6 pt-3">
|
||||||
{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
|
// 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
|
// low default size, so request a crisp wide version (=w1707) and object-cover the
|
||||||
// desktop "safe area" — the centre 2560×423 (~6:1) band.
|
// desktop "safe area" — the centre 2560×423 (~6:1) band.
|
||||||
|
|
@ -165,13 +214,27 @@ export default function ChannelPage({
|
||||||
style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }}
|
style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={`${ch.banner_url}=w1707`}
|
// key forces a fresh <img> 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=""
|
alt=""
|
||||||
className="w-full h-full object-cover object-center"
|
className="w-full h-full object-cover object-center"
|
||||||
|
onError={() => {
|
||||||
|
if (bannerAttempt <= MAX_BANNER_RETRIES) {
|
||||||
|
window.setTimeout(() => setBannerAttempt((a) => a + 1), 500 * (bannerAttempt + 1));
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-14 w-full rounded-2xl bg-surface" />
|
// 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).
|
||||||
|
<div
|
||||||
|
className="w-full rounded-2xl bg-surface"
|
||||||
|
style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={onBack}
|
onClick={onBack}
|
||||||
|
|
@ -341,6 +404,48 @@ export default function ChannelPage({
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{hasAboutDetails && (
|
||||||
|
<div className="mt-5 space-y-2.5 text-sm border-t border-border pt-4">
|
||||||
|
{ch?.country && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="text-muted w-24 shrink-0">{t("channel.country")}</span>
|
||||||
|
<span>
|
||||||
|
{countryFlag(ch.country)} {displayName(ch.country, i18n.language, "region")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ch?.default_language && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="text-muted w-24 shrink-0">{t("channel.language")}</span>
|
||||||
|
<span>{displayName(ch.default_language, i18n.language, "language")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{topics.length > 0 && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="text-muted w-24 shrink-0 pt-0.5">{t("channel.topics")}</span>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{topics.map((tp) => (
|
||||||
|
<span key={tp} className="glass-card px-2 py-0.5 rounded-full text-xs">
|
||||||
|
{tp}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{keywords.length > 0 && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="text-muted w-24 shrink-0 pt-0.5">{t("channel.keywords")}</span>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{keywords.map((kw, i) => (
|
||||||
|
<span key={i} className="glass-card px-2 py-0.5 rounded-full text-xs">
|
||||||
|
{kw}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -399,7 +399,7 @@ export default function Channels({
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{tabs}
|
{tabs}
|
||||||
<ChannelDiscovery canWrite={canWrite} onOpenWizard={onOpenWizard} />
|
<ChannelDiscovery canWrite={canWrite} onOpenWizard={onOpenWizard} onViewChannel={onViewChannel} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import { LS } from "../lib/storage";
|
||||||
// applied together via one Save/Discard bar (consistent with the Settings page); nothing hits
|
// 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.
|
// the server until Save. Group order is fixed where known so logically related settings (e.g.
|
||||||
// Email/SMTP) stay together.
|
// 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() {
|
export default function ConfigPanel() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
|
||||||
|
|
@ -552,25 +552,33 @@ export default function Feed({
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
{/* The Source filter (organic / include-search / search-only) is a global-catalog concept —
|
||||||
<label className="inline-flex items-center gap-1.5 text-xs text-muted">
|
it selects how videos ENTERED the library. On a channel-scoped view we already show all
|
||||||
<span>{t("feed.source.label")}</span>
|
of this one channel's videos (librarySource is pinned to "all"), so the selector would
|
||||||
<select
|
only offer a near-empty, confusing slice — hide it here. */}
|
||||||
value={filters.librarySource ?? "organic"}
|
{!channelScoped && (
|
||||||
onChange={(e) =>
|
<>
|
||||||
setFilters({
|
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
||||||
...filters,
|
<label className="inline-flex items-center gap-1.5 text-xs text-muted">
|
||||||
librarySource: e.target.value as FeedFilters["librarySource"],
|
<span>{t("feed.source.label")}</span>
|
||||||
})
|
<select
|
||||||
}
|
value={filters.librarySource ?? "organic"}
|
||||||
title={t("feed.source.tip")}
|
onChange={(e) =>
|
||||||
className="bg-card border border-border rounded-lg px-2 py-1 text-xs outline-none focus:border-accent"
|
setFilters({
|
||||||
>
|
...filters,
|
||||||
<option value="organic">{t("feed.source.organic")}</option>
|
librarySource: e.target.value as FeedFilters["librarySource"],
|
||||||
<option value="all">{t("feed.source.all")}</option>
|
})
|
||||||
<option value="search">{t("feed.source.only")}</option>
|
}
|
||||||
</select>
|
title={t("feed.source.tip")}
|
||||||
</label>
|
className="bg-card border border-border rounded-lg px-2 py-1 text-xs outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
<option value="organic">{t("feed.source.organic")}</option>
|
||||||
|
<option value="all">{t("feed.source.all")}</option>
|
||||||
|
<option value="search">{t("feed.source.only")}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
||||||
<span className="text-xs text-muted whitespace-nowrap">
|
<span className="text-xs text-muted whitespace-nowrap">
|
||||||
{countQuery.data
|
{countQuery.data
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
ListVideo,
|
ListVideo,
|
||||||
LogOut,
|
LogOut,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
|
ScrollText,
|
||||||
Settings,
|
Settings,
|
||||||
Shield,
|
Shield,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
|
|
@ -193,6 +194,7 @@ export default function NavSidebar({
|
||||||
{ page: "scheduler", icon: Activity, label: t("header.account.scheduler") },
|
{ page: "scheduler", icon: Activity, label: t("header.account.scheduler") },
|
||||||
{ page: "config", icon: SlidersHorizontal, label: t("header.account.config") },
|
{ page: "config", icon: SlidersHorizontal, label: t("header.account.config") },
|
||||||
{ page: "users", icon: Users, label: t("header.account.users") },
|
{ page: "users", icon: Users, label: t("header.account.users") },
|
||||||
|
{ page: "audit", icon: ScrollText, label: t("header.account.audit") },
|
||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
|
|
||||||
91
frontend/src/components/auditColumns.tsx
Normal file
91
frontend/src/components/auditColumns.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
import type { TFunction } from "i18next";
|
||||||
|
import { relativeTime, formatDate } from "../lib/format";
|
||||||
|
import i18n from "../i18n";
|
||||||
|
import type { AuditRow } from "../lib/api";
|
||||||
|
import type { Column } from "./DataTable";
|
||||||
|
|
||||||
|
function actionLabel(t: TFunction, action: string): string {
|
||||||
|
// Canonical key → localized label; falls back to the raw key for any unmapped action.
|
||||||
|
return t(`auditActions.${action}`, action);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(v: unknown): string {
|
||||||
|
return v == null || v === "" ? "—" : String(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A compact before→after (or after-only) rendering of the JSON snapshots, e.g. "role: user → admin". */
|
||||||
|
function changeText(row: AuditRow): string | null {
|
||||||
|
const { before, after } = row;
|
||||||
|
if (before && after) {
|
||||||
|
return Object.keys(after)
|
||||||
|
.map((k) => `${k}: ${fmt(before[k])} → ${fmt(after[k])}`)
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
if (after) {
|
||||||
|
return Object.entries(after)
|
||||||
|
.map(([k, v]) => `${k}: ${fmt(v)}`)
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Columns for the admin audit-log DataTable. `actionOptions` are the distinct actions present in
|
||||||
|
* the data (built by the page) so the Action column's select filter only offers real values. */
|
||||||
|
export function auditColumns(
|
||||||
|
t: TFunction,
|
||||||
|
actionOptions: { value: string; label: string }[],
|
||||||
|
): Column<AuditRow>[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "when",
|
||||||
|
header: t("audit.cols.when"),
|
||||||
|
nowrap: true,
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (r) => r.created_at ?? "",
|
||||||
|
cardPrimary: true,
|
||||||
|
render: (r) => (
|
||||||
|
<span
|
||||||
|
className="text-muted tabular-nums"
|
||||||
|
title={r.created_at ? formatDate(r.created_at, i18n.language) : ""}
|
||||||
|
>
|
||||||
|
{r.created_at ? relativeTime(r.created_at) : "—"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actor",
|
||||||
|
header: t("audit.cols.actor"),
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (r) => r.actor ?? "",
|
||||||
|
filter: { kind: "text", get: (r) => r.actor ?? t("audit.system") },
|
||||||
|
render: (r) =>
|
||||||
|
r.actor ? (
|
||||||
|
<span className="truncate">{r.actor}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted italic">{t("audit.system")}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "action",
|
||||||
|
header: t("audit.cols.action"),
|
||||||
|
nowrap: true,
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (r) => actionLabel(t, r.action),
|
||||||
|
filter: { kind: "select", options: actionOptions, test: (r, v) => r.action === v },
|
||||||
|
render: (r) => <span className="font-medium">{actionLabel(t, r.action)}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "details",
|
||||||
|
header: t("audit.cols.details"),
|
||||||
|
render: (r) => {
|
||||||
|
const change = changeText(r);
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
{r.summary && <div className="truncate">{r.summary}</div>}
|
||||||
|
{change && <div className="text-[11px] text-muted truncate">{change}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
18
frontend/src/i18n/locales/de/audit.json
Normal file
18
frontend/src/i18n/locales/de/audit.json
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"title": "Audit-Protokoll",
|
||||||
|
"intro": "Ein reines Anhänge-Protokoll der Admin-Aktionen und Scheduler-Laufzusammenfassungen — wer was wann getan hat. Auf Aktionsebene protokolliert, nie pro Element.",
|
||||||
|
"empty": "Noch keine Protokolleinträge.",
|
||||||
|
"system": "System",
|
||||||
|
"clear": "Protokoll leeren",
|
||||||
|
"clearTitle": "Audit-Protokoll leeren?",
|
||||||
|
"clearConfirm": "Alle Protokolleinträge dauerhaft löschen? Das kann nicht rückgängig gemacht werden. (Ein Eintrag, der festhält, dass du es geleert hast, bleibt erhalten.)",
|
||||||
|
"cleared": "{{count}} Protokolleinträge gelöscht",
|
||||||
|
"clearFailed": "Das Protokoll konnte nicht geleert werden.",
|
||||||
|
"truncated": "Die {{returned}} neuesten von {{total}} Einträgen werden angezeigt.",
|
||||||
|
"cols": {
|
||||||
|
"when": "Wann",
|
||||||
|
"actor": "Wer",
|
||||||
|
"action": "Aktion",
|
||||||
|
"details": "Details"
|
||||||
|
}
|
||||||
|
}
|
||||||
21
frontend/src/i18n/locales/de/auditActions.json
Normal file
21
frontend/src/i18n/locales/de/auditActions.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"user_role_change": "Rolle geändert",
|
||||||
|
"user_suspend": "Benutzer gesperrt",
|
||||||
|
"user_unsuspend": "Benutzer reaktiviert",
|
||||||
|
"user_delete": "Benutzer gelöscht",
|
||||||
|
"invite_approve": "Zugriffsanfrage genehmigt",
|
||||||
|
"invite_deny": "Zugriffsanfrage abgelehnt",
|
||||||
|
"invite_add": "E-Mail auf Whitelist",
|
||||||
|
"demo_add": "Demo-E-Mail hinzugefügt",
|
||||||
|
"demo_remove": "Demo-E-Mail entfernt",
|
||||||
|
"demo_reset": "Demo zurückgesetzt",
|
||||||
|
"discovery_purge": "Entdeckung bereinigt",
|
||||||
|
"config_set": "Einstellung geändert",
|
||||||
|
"config_reset": "Einstellung zurückgesetzt",
|
||||||
|
"scheduler_interval": "Job-Intervall geändert",
|
||||||
|
"maintenance_tune": "Wartung angepasst",
|
||||||
|
"sync_pause": "Sync pausiert",
|
||||||
|
"sync_resume": "Sync fortgesetzt",
|
||||||
|
"job_run": "Scheduler-Job ausgeführt",
|
||||||
|
"audit_clear": "Audit-Protokoll geleert"
|
||||||
|
}
|
||||||
|
|
@ -19,5 +19,9 @@
|
||||||
"noDescription": "Dieser Kanal hat keine Beschreibung.",
|
"noDescription": "Dieser Kanal hat keine Beschreibung.",
|
||||||
"block": "Kanal blockieren",
|
"block": "Kanal blockieren",
|
||||||
"unblock": "Blockierung aufheben",
|
"unblock": "Blockierung aufheben",
|
||||||
"blockedBadge": "Blockiert"
|
"blockedBadge": "Blockiert",
|
||||||
|
"country": "Land",
|
||||||
|
"language": "Sprache",
|
||||||
|
"topics": "Themen",
|
||||||
|
"keywords": "Schlagwörter"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@
|
||||||
"batch": "Stapelgrößen",
|
"batch": "Stapelgrößen",
|
||||||
"explore": "Kanal erkunden",
|
"explore": "Kanal erkunden",
|
||||||
"downloads": "Downloads",
|
"downloads": "Downloads",
|
||||||
"plex": "Plex"
|
"plex": "Plex",
|
||||||
|
"audit": "Audit-Protokoll"
|
||||||
},
|
},
|
||||||
"fields": {
|
"fields": {
|
||||||
"smtp_host": {
|
"smtp_host": {
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,8 @@
|
||||||
"about": "Über",
|
"about": "Über",
|
||||||
"signOut": "Abmelden",
|
"signOut": "Abmelden",
|
||||||
"addAccount": "Weiteres Konto hinzufügen",
|
"addAccount": "Weiteres Konto hinzufügen",
|
||||||
"switchTo": "Zu {{name}} wechseln"
|
"switchTo": "Zu {{name}} wechseln",
|
||||||
|
"audit": "Audit-Protokoll"
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
"yours": "deine",
|
"yours": "deine",
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,8 @@
|
||||||
"download_gc": "Löscht heruntergeladene Dateien nach Ablauf der Aufbewahrungsfrist, gibt nicht mehr belegten Speicher frei und warnt vor dem Ablauf einer geteilten Datei. Fällt er aus, häufen sich alte Downloads und Speicherplatz wird nicht freigegeben.",
|
"download_gc": "Löscht heruntergeladene Dateien nach Ablauf der Aufbewahrungsfrist, gibt nicht mehr belegten Speicher frei und warnt vor dem Ablauf einer geteilten Datei. Fällt er aus, häufen sich alte Downloads und Speicherplatz wird nicht freigegeben.",
|
||||||
"plex_sync": "Spiegelt die aktivierten Plex-Bibliotheken in den App-Katalog — die Metadaten hinter Plex-Stöbern, Suche, Filtern und Info-Seiten (Genres, Besetzung, Wertungen, Artwork). Fällt er aus, erscheinen neu hinzugefügte oder geänderte Plex-Titel erst beim nächsten Sync.",
|
"plex_sync": "Spiegelt die aktivierten Plex-Bibliotheken in den App-Katalog — die Metadaten hinter Plex-Stöbern, Suche, Filtern und Info-Seiten (Genres, Besetzung, Wertungen, Artwork). Fällt er aus, erscheinen neu hinzugefügte oder geänderte Plex-Titel erst beim nächsten Sync.",
|
||||||
"plex_watch_sync": "Holt kürzliche Plex-Wiedergabeänderungen (beendete Episoden, Fortsetzungspositionen) nach Siftlode und schiebt lokale Wiedergabeänderungen nach, die Plex nicht erreicht haben. Der günstige Zwei-Wege-Abgleich zwischen den vollen Reconciles. Fällt er aus, erscheinen in der Plex-App gesetzte Gesehen-/Fortsetzungsstände hier später.",
|
"plex_watch_sync": "Holt kürzliche Plex-Wiedergabeänderungen (beendete Episoden, Fortsetzungspositionen) nach Siftlode und schiebt lokale Wiedergabeänderungen nach, die Plex nicht erreicht haben. Der günstige Zwei-Wege-Abgleich zwischen den vollen Reconciles. Fällt er aus, erscheinen in der Plex-App gesetzte Gesehen-/Fortsetzungsstände hier später.",
|
||||||
"plex_watch_reconcile": "Ein vollständiger Wiedergabe-Abgleich, der jede Bibliothek neu scannt — erfasst, was der inkrementelle Lauf nicht kann, vor allem eine auf Plex-Seite zurückgenommene Gesehen-Markierung. Schwerer, läuft daher etwa täglich. Fällt er aus, werden in Plex vorgenommene Un-Watches nicht zurückgespiegelt."
|
"plex_watch_reconcile": "Ein vollständiger Wiedergabe-Abgleich, der jede Bibliothek neu scannt — erfasst, was der inkrementelle Lauf nicht kann, vor allem eine auf Plex-Seite zurückgenommene Gesehen-Markierung. Schwerer, läuft daher etwa täglich. Fällt er aus, werden in Plex vorgenommene Un-Watches nicht zurückgespiegelt.",
|
||||||
|
"audit_gc": "Entfernt Audit-Protokolleinträge, die älter als das eingestellte Aufbewahrungsfenster sind (Konfiguration → Audit-Protokoll). Wenn er stoppt, wächst das Protokoll unbegrenzt — harmlos, aber unbeschränkt."
|
||||||
},
|
},
|
||||||
"jobs": {
|
"jobs": {
|
||||||
"rss_poll": "RSS-Abfrage (neue Uploads)",
|
"rss_poll": "RSS-Abfrage (neue Uploads)",
|
||||||
|
|
@ -87,7 +88,8 @@
|
||||||
"download_gc": "Download-Bereinigung",
|
"download_gc": "Download-Bereinigung",
|
||||||
"plex_sync": "Plex-Bibliothek-Sync",
|
"plex_sync": "Plex-Bibliothek-Sync",
|
||||||
"plex_watch_sync": "Plex-Wiedergabe-Sync (inkrementell)",
|
"plex_watch_sync": "Plex-Wiedergabe-Sync (inkrementell)",
|
||||||
"plex_watch_reconcile": "Plex-Wiedergabe-Abgleich (vollständig)"
|
"plex_watch_reconcile": "Plex-Wiedergabe-Abgleich (vollständig)",
|
||||||
|
"audit_gc": "Audit-Protokoll-Bereinigung"
|
||||||
},
|
},
|
||||||
"queue": {
|
"queue": {
|
||||||
"recentPending": "Zu synchronisierende Kanäle",
|
"recentPending": "Zu synchronisierende Kanäle",
|
||||||
|
|
|
||||||
18
frontend/src/i18n/locales/en/audit.json
Normal file
18
frontend/src/i18n/locales/en/audit.json
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"title": "Audit log",
|
||||||
|
"intro": "An append-only record of admin actions and scheduler run summaries — who did what, and when. Logged at action granularity, never per item.",
|
||||||
|
"empty": "No audit entries yet.",
|
||||||
|
"system": "System",
|
||||||
|
"clear": "Clear log",
|
||||||
|
"clearTitle": "Clear the audit log?",
|
||||||
|
"clearConfirm": "Permanently delete all audit entries? This can't be undone. (One entry recording that you cleared it is kept.)",
|
||||||
|
"cleared": "Cleared {{count}} audit entries",
|
||||||
|
"clearFailed": "Couldn't clear the audit log.",
|
||||||
|
"truncated": "Showing the {{returned}} most recent of {{total}} entries.",
|
||||||
|
"cols": {
|
||||||
|
"when": "When",
|
||||||
|
"actor": "Who",
|
||||||
|
"action": "Action",
|
||||||
|
"details": "Details"
|
||||||
|
}
|
||||||
|
}
|
||||||
21
frontend/src/i18n/locales/en/auditActions.json
Normal file
21
frontend/src/i18n/locales/en/auditActions.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"user_role_change": "Role changed",
|
||||||
|
"user_suspend": "User suspended",
|
||||||
|
"user_unsuspend": "User reinstated",
|
||||||
|
"user_delete": "User deleted",
|
||||||
|
"invite_approve": "Access request approved",
|
||||||
|
"invite_deny": "Access request denied",
|
||||||
|
"invite_add": "Email whitelisted",
|
||||||
|
"demo_add": "Demo email added",
|
||||||
|
"demo_remove": "Demo email removed",
|
||||||
|
"demo_reset": "Demo reset",
|
||||||
|
"discovery_purge": "Discovery purged",
|
||||||
|
"config_set": "Setting changed",
|
||||||
|
"config_reset": "Setting reset",
|
||||||
|
"scheduler_interval": "Job interval changed",
|
||||||
|
"maintenance_tune": "Maintenance tuned",
|
||||||
|
"sync_pause": "Sync paused",
|
||||||
|
"sync_resume": "Sync resumed",
|
||||||
|
"job_run": "Scheduler job run",
|
||||||
|
"audit_clear": "Audit log cleared"
|
||||||
|
}
|
||||||
|
|
@ -19,5 +19,9 @@
|
||||||
"noDescription": "This channel has no description.",
|
"noDescription": "This channel has no description.",
|
||||||
"block": "Block channel",
|
"block": "Block channel",
|
||||||
"unblock": "Unblock channel",
|
"unblock": "Unblock channel",
|
||||||
"blockedBadge": "Blocked"
|
"blockedBadge": "Blocked",
|
||||||
|
"country": "Country",
|
||||||
|
"language": "Language",
|
||||||
|
"topics": "Topics",
|
||||||
|
"keywords": "Keywords"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@
|
||||||
"batch": "Batch sizes",
|
"batch": "Batch sizes",
|
||||||
"explore": "Channel explore",
|
"explore": "Channel explore",
|
||||||
"downloads": "Downloads",
|
"downloads": "Downloads",
|
||||||
"plex": "Plex"
|
"plex": "Plex",
|
||||||
|
"audit": "Audit log"
|
||||||
},
|
},
|
||||||
"fields": {
|
"fields": {
|
||||||
"smtp_host": {
|
"smtp_host": {
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,8 @@
|
||||||
"about": "About",
|
"about": "About",
|
||||||
"signOut": "Sign out",
|
"signOut": "Sign out",
|
||||||
"addAccount": "Add another account",
|
"addAccount": "Add another account",
|
||||||
"switchTo": "Switch to {{name}}"
|
"switchTo": "Switch to {{name}}",
|
||||||
|
"audit": "Audit log"
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
"yours": "yours",
|
"yours": "yours",
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,8 @@
|
||||||
"download_gc": "Deletes downloaded files past their retention window, reclaims space no one is holding anymore, and warns owners before a shared file expires. If it stops, old downloads pile up and disk space isn't freed.",
|
"download_gc": "Deletes downloaded files past their retention window, reclaims space no one is holding anymore, and warns owners before a shared file expires. If it stops, old downloads pile up and disk space isn't freed.",
|
||||||
"plex_sync": "Mirrors the enabled Plex libraries into the app's catalogue — the metadata behind Plex browsing, search, filters and the info pages (genres, cast, ratings, artwork). If it stops, newly added or changed Plex titles don't appear until the next sync.",
|
"plex_sync": "Mirrors the enabled Plex libraries into the app's catalogue — the metadata behind Plex browsing, search, filters and the info pages (genres, cast, ratings, artwork). If it stops, newly added or changed Plex titles don't appear until the next sync.",
|
||||||
"plex_watch_sync": "Pulls recent Plex watch changes (finished episodes, resume positions) into Siftlode and re-pushes any local watch changes that didn't reach Plex. The cheap two-way keep-in-sync between full reconciles. If it stops, watched/resume made in the Plex app takes longer to show here.",
|
"plex_watch_sync": "Pulls recent Plex watch changes (finished episodes, resume positions) into Siftlode and re-pushes any local watch changes that didn't reach Plex. The cheap two-way keep-in-sync between full reconciles. If it stops, watched/resume made in the Plex app takes longer to show here.",
|
||||||
"plex_watch_reconcile": "A full watch-state reconcile that rescans every library — catches what the incremental pass can't, notably an episode un-marked as watched on the Plex side. Heavier, so it runs about once a day. If it stops, un-watches done in Plex aren't mirrored back."
|
"plex_watch_reconcile": "A full watch-state reconcile that rescans every library — catches what the incremental pass can't, notably an episode un-marked as watched on the Plex side. Heavier, so it runs about once a day. If it stops, un-watches done in Plex aren't mirrored back.",
|
||||||
|
"audit_gc": "Prunes admin audit-log entries older than the configured retention window (Configuration → Audit log). If it stops, the audit log just keeps growing — harmless but unbounded."
|
||||||
},
|
},
|
||||||
"jobs": {
|
"jobs": {
|
||||||
"rss_poll": "RSS poll (new uploads)",
|
"rss_poll": "RSS poll (new uploads)",
|
||||||
|
|
@ -87,7 +88,8 @@
|
||||||
"download_gc": "Download cleanup",
|
"download_gc": "Download cleanup",
|
||||||
"plex_sync": "Plex library sync",
|
"plex_sync": "Plex library sync",
|
||||||
"plex_watch_sync": "Plex watch sync (incremental)",
|
"plex_watch_sync": "Plex watch sync (incremental)",
|
||||||
"plex_watch_reconcile": "Plex watch reconcile (full)"
|
"plex_watch_reconcile": "Plex watch reconcile (full)",
|
||||||
|
"audit_gc": "Audit-log cleanup"
|
||||||
},
|
},
|
||||||
"queue": {
|
"queue": {
|
||||||
"recentPending": "Channels to sync",
|
"recentPending": "Channels to sync",
|
||||||
|
|
|
||||||
18
frontend/src/i18n/locales/hu/audit.json
Normal file
18
frontend/src/i18n/locales/hu/audit.json
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"title": "Naplózás",
|
||||||
|
"intro": "Az admin-műveletek és az ütemező futás-összegzéseinek csak-hozzáfűző naplója — ki, mit, mikor. Művelet-szinten naplózva, soha nem elemenként.",
|
||||||
|
"empty": "Még nincs naplóbejegyzés.",
|
||||||
|
"system": "Rendszer",
|
||||||
|
"clear": "Napló ürítése",
|
||||||
|
"clearTitle": "Üríted a naplót?",
|
||||||
|
"clearConfirm": "Véglegesen törlöd az összes naplóbejegyzést? Ez nem vonható vissza. (Egy bejegyzés arról, hogy te ürítetted, megmarad.)",
|
||||||
|
"cleared": "{{count}} naplóbejegyzés törölve",
|
||||||
|
"clearFailed": "A naplót nem sikerült üríteni.",
|
||||||
|
"truncated": "A legutóbbi {{returned}} bejegyzés látszik a(z) {{total}}-ból.",
|
||||||
|
"cols": {
|
||||||
|
"when": "Mikor",
|
||||||
|
"actor": "Ki",
|
||||||
|
"action": "Művelet",
|
||||||
|
"details": "Részletek"
|
||||||
|
}
|
||||||
|
}
|
||||||
21
frontend/src/i18n/locales/hu/auditActions.json
Normal file
21
frontend/src/i18n/locales/hu/auditActions.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"user_role_change": "Szerepkör módosítva",
|
||||||
|
"user_suspend": "Felhasználó felfüggesztve",
|
||||||
|
"user_unsuspend": "Felhasználó visszaállítva",
|
||||||
|
"user_delete": "Felhasználó törölve",
|
||||||
|
"invite_approve": "Hozzáférési kérelem jóváhagyva",
|
||||||
|
"invite_deny": "Hozzáférési kérelem elutasítva",
|
||||||
|
"invite_add": "Email fehérlistázva",
|
||||||
|
"demo_add": "Demo email hozzáadva",
|
||||||
|
"demo_remove": "Demo email eltávolítva",
|
||||||
|
"demo_reset": "Demo visszaállítva",
|
||||||
|
"discovery_purge": "Felfedezés ürítve",
|
||||||
|
"config_set": "Beállítás módosítva",
|
||||||
|
"config_reset": "Beállítás visszaállítva",
|
||||||
|
"scheduler_interval": "Job-intervallum módosítva",
|
||||||
|
"maintenance_tune": "Karbantartás hangolva",
|
||||||
|
"sync_pause": "Szinkron szüneteltetve",
|
||||||
|
"sync_resume": "Szinkron folytatva",
|
||||||
|
"job_run": "Ütemező job lefutott",
|
||||||
|
"audit_clear": "Napló ürítve"
|
||||||
|
}
|
||||||
|
|
@ -19,5 +19,9 @@
|
||||||
"noDescription": "Ennek a csatornának nincs leírása.",
|
"noDescription": "Ennek a csatornának nincs leírása.",
|
||||||
"block": "Csatorna tiltása",
|
"block": "Csatorna tiltása",
|
||||||
"unblock": "Tiltás feloldása",
|
"unblock": "Tiltás feloldása",
|
||||||
"blockedBadge": "Tiltva"
|
"blockedBadge": "Tiltva",
|
||||||
|
"country": "Ország",
|
||||||
|
"language": "Nyelv",
|
||||||
|
"topics": "Témák",
|
||||||
|
"keywords": "Kulcsszavak"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@
|
||||||
"batch": "Kötegméretek",
|
"batch": "Kötegméretek",
|
||||||
"explore": "Csatorna-felfedezés",
|
"explore": "Csatorna-felfedezés",
|
||||||
"downloads": "Letöltések",
|
"downloads": "Letöltések",
|
||||||
"plex": "Plex"
|
"plex": "Plex",
|
||||||
|
"audit": "Naplózás"
|
||||||
},
|
},
|
||||||
"fields": {
|
"fields": {
|
||||||
"smtp_host": {
|
"smtp_host": {
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,8 @@
|
||||||
"about": "Névjegy",
|
"about": "Névjegy",
|
||||||
"signOut": "Kijelentkezés",
|
"signOut": "Kijelentkezés",
|
||||||
"addAccount": "Másik fiók hozzáadása",
|
"addAccount": "Másik fiók hozzáadása",
|
||||||
"switchTo": "Váltás erre: {{name}}"
|
"switchTo": "Váltás erre: {{name}}",
|
||||||
|
"audit": "Naplózás"
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
"yours": "tiéd",
|
"yours": "tiéd",
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,8 @@
|
||||||
"download_gc": "Törli a megőrzési időn túli letöltéseket, felszabadítja a már senki által nem használt helyet, és a lejárat előtt figyelmezteti a tulajdonost. Ha leáll, a régi letöltések felhalmozódnak, és a tárhely nem szabadul fel.",
|
"download_gc": "Törli a megőrzési időn túli letöltéseket, felszabadítja a már senki által nem használt helyet, és a lejárat előtt figyelmezteti a tulajdonost. Ha leáll, a régi letöltések felhalmozódnak, és a tárhely nem szabadul fel.",
|
||||||
"plex_sync": "Tükrözi az engedélyezett Plex-könyvtárakat az app katalógusába — ez adja a Plex böngészés, keresés, szűrők és infó-oldalak metaadatait (műfaj, szereplők, pontszám, borítók). Ha leáll, az újonnan hozzáadott vagy módosított Plex-címek csak a következő sync után jelennek meg.",
|
"plex_sync": "Tükrözi az engedélyezett Plex-könyvtárakat az app katalógusába — ez adja a Plex böngészés, keresés, szűrők és infó-oldalak metaadatait (műfaj, szereplők, pontszám, borítók). Ha leáll, az újonnan hozzáadott vagy módosított Plex-címek csak a következő sync után jelennek meg.",
|
||||||
"plex_watch_sync": "Áthozza a Plex-oldali friss megtekintés-változásokat (befejezett epizódok, folytatás-pozíciók) a Siftlode-ba, és felnyomja a Plexet el nem érő helyi változásokat. Ez az olcsó kétirányú szinkron a teljes reconcile-ok között. Ha leáll, a Plex-appban jelölt megtekintés/folytatás lassabban jelenik meg itt.",
|
"plex_watch_sync": "Áthozza a Plex-oldali friss megtekintés-változásokat (befejezett epizódok, folytatás-pozíciók) a Siftlode-ba, és felnyomja a Plexet el nem érő helyi változásokat. Ez az olcsó kétirányú szinkron a teljes reconcile-ok között. Ha leáll, a Plex-appban jelölt megtekintés/folytatás lassabban jelenik meg itt.",
|
||||||
"plex_watch_reconcile": "Teljes megtekintés-állapot reconcile, ami minden könyvtárat újraszkennel — elkapja, amit az inkrementális menet nem, főleg ha egy epizódról a Plex oldalán levették a megtekintve jelet. Nehezebb, ezért kb. naponta fut. Ha leáll, a Plexben végzett un-watch nem tükröződik vissza."
|
"plex_watch_reconcile": "Teljes megtekintés-állapot reconcile, ami minden könyvtárat újraszkennel — elkapja, amit az inkrementális menet nem, főleg ha egy epizódról a Plex oldalán levették a megtekintve jelet. Nehezebb, ezért kb. naponta fut. Ha leáll, a Plexben végzett un-watch nem tükröződik vissza.",
|
||||||
|
"audit_gc": "Törli a beállított megőrzési időnél (Beállítások → Naplózás) régebbi admin-naplóbejegyzéseket. Ha leáll, a napló csak nő — ártalmatlan, de korlátlanul."
|
||||||
},
|
},
|
||||||
"jobs": {
|
"jobs": {
|
||||||
"rss_poll": "RSS-lekérdezés (új feltöltések)",
|
"rss_poll": "RSS-lekérdezés (új feltöltések)",
|
||||||
|
|
@ -87,7 +88,8 @@
|
||||||
"download_gc": "Letöltések takarítása",
|
"download_gc": "Letöltések takarítása",
|
||||||
"plex_sync": "Plex-könyvtár szinkron",
|
"plex_sync": "Plex-könyvtár szinkron",
|
||||||
"plex_watch_sync": "Plex megtekintés-szinkron (inkrementális)",
|
"plex_watch_sync": "Plex megtekintés-szinkron (inkrementális)",
|
||||||
"plex_watch_reconcile": "Plex megtekintés-reconcile (teljes)"
|
"plex_watch_reconcile": "Plex megtekintés-reconcile (teljes)",
|
||||||
|
"audit_gc": "Napló-karbantartás"
|
||||||
},
|
},
|
||||||
"queue": {
|
"queue": {
|
||||||
"recentPending": "Szinkronra váró csatorna",
|
"recentPending": "Szinkronra váró csatorna",
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,9 @@ export interface ChannelDetail {
|
||||||
published_at: string | null;
|
published_at: string | null;
|
||||||
external_links: ChannelLink[];
|
external_links: ChannelLink[];
|
||||||
country: string | null;
|
country: string | null;
|
||||||
|
default_language: string | null;
|
||||||
|
topic_categories: string[];
|
||||||
|
keywords: string | null;
|
||||||
subscribed: boolean;
|
subscribed: boolean;
|
||||||
explored: boolean;
|
explored: boolean;
|
||||||
blocked: boolean;
|
blocked: boolean;
|
||||||
|
|
@ -926,6 +929,23 @@ export interface AdminUserRow {
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AuditRow {
|
||||||
|
id: number;
|
||||||
|
created_at: string | null;
|
||||||
|
actor: string | null; // email of the acting admin; null = System (scheduler/background)
|
||||||
|
action: string; // canonical key, localized on the client via auditActions.<action>
|
||||||
|
target_type: string | null;
|
||||||
|
target_id: string | null;
|
||||||
|
summary: string | null;
|
||||||
|
before: Record<string, unknown> | null;
|
||||||
|
after: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
export interface AuditListResult {
|
||||||
|
items: AuditRow[];
|
||||||
|
total: number; // total rows in the log (may exceed `items` if capped)
|
||||||
|
returned: number;
|
||||||
|
}
|
||||||
|
|
||||||
// --- download center ---
|
// --- download center ---
|
||||||
export interface DownloadSpec {
|
export interface DownloadSpec {
|
||||||
mode: "av" | "v" | "a"; // audio+video | video-only | audio-only
|
mode: "av" | "v" | "a"; // audio+video | video-only | audio-only
|
||||||
|
|
@ -1386,6 +1406,8 @@ export const api = {
|
||||||
plexImageUrl: (id: string, variant?: "thumb" | "art"): string =>
|
plexImageUrl: (id: string, variant?: "thumb" | "art"): string =>
|
||||||
`/api/plex/image/${encodeURIComponent(id)}${variant === "art" ? "?variant=art" : ""}`,
|
`/api/plex/image/${encodeURIComponent(id)}${variant === "art" ? "?variant=art" : ""}`,
|
||||||
// --- admin: users & roles ---
|
// --- admin: users & roles ---
|
||||||
|
adminAudit: (limit = 500): Promise<AuditListResult> => req(`/api/admin/audit?limit=${limit}`),
|
||||||
|
clearAudit: (): Promise<{ cleared: number }> => req("/api/admin/audit", { method: "DELETE" }),
|
||||||
adminUsers: (): Promise<AdminUserRow[]> => req("/api/admin/users"),
|
adminUsers: (): Promise<AdminUserRow[]> => req("/api/admin/users"),
|
||||||
setUserRole: (id: number, role: "user" | "admin"): Promise<AdminUserRow> =>
|
setUserRole: (id: number, role: "user" | "admin"): Promise<AdminUserRow> =>
|
||||||
req(`/api/admin/users/${id}/role`, { method: "PATCH", body: JSON.stringify({ role }) }),
|
req(`/api/admin/users/${id}/role`, { method: "PATCH", body: JSON.stringify({ role }) }),
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ export function pageTitleKey(page: Page): string {
|
||||||
return "header.configuration";
|
return "header.configuration";
|
||||||
case "users":
|
case "users":
|
||||||
return "header.users";
|
return "header.users";
|
||||||
|
case "audit":
|
||||||
|
return "audit.title";
|
||||||
case "settings":
|
case "settings":
|
||||||
return "settings.title";
|
return "settings.title";
|
||||||
default:
|
default:
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,21 @@ export interface ReleaseEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.41.0",
|
||||||
|
date: "2026-07-12",
|
||||||
|
summary: "Richer channel pages, a new admin audit log, and channel-page fixes.",
|
||||||
|
features: [
|
||||||
|
"Channel pages now show Country, Topics and Keywords in the About tab.",
|
||||||
|
"From the channel Discovery tab you can open a channel's page to look it over — its About and videos — before subscribing.",
|
||||||
|
"New admin Audit log: an append-only record of admin actions and scheduler activity (who did what, and when), with filtering and a configurable retention window.",
|
||||||
|
],
|
||||||
|
fixes: [
|
||||||
|
"Channel banners that briefly failed to load now recover on their own instead of staying broken.",
|
||||||
|
"The channel header no longer shifts a few pixels when switching between the Videos and About tabs, and channels without a banner lay out correctly.",
|
||||||
|
"The Source (search-results) filter is hidden on a single channel's page, where it didn't apply.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.40.0",
|
version: "0.40.0",
|
||||||
date: "2026-07-12",
|
date: "2026-07-12",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ export const LS = {
|
||||||
channelDiscoveryTable: "siftlode.channelDiscoveryTable",
|
channelDiscoveryTable: "siftlode.channelDiscoveryTable",
|
||||||
settingsTab: "siftlode.settingsTab",
|
settingsTab: "siftlode.settingsTab",
|
||||||
configTab: "siftlode.configTab",
|
configTab: "siftlode.configTab",
|
||||||
|
auditTable: "siftlode.auditTable",
|
||||||
statsTab: "siftlode.statsTab",
|
statsTab: "siftlode.statsTab",
|
||||||
adminUsersTab: "siftlode.adminUsersTab",
|
adminUsersTab: "siftlode.adminUsersTab",
|
||||||
navCollapsed: "siftlode.navCollapsed",
|
navCollapsed: "siftlode.navCollapsed",
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,7 @@ const PAGES = [
|
||||||
"scheduler",
|
"scheduler",
|
||||||
"config",
|
"config",
|
||||||
"users",
|
"users",
|
||||||
|
"audit",
|
||||||
"notifications",
|
"notifications",
|
||||||
"messages",
|
"messages",
|
||||||
"downloads",
|
"downloads",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue