Commit graph

22 commits

Author SHA1 Message Date
npeter83
318fdc4812 feat(audit): admin audit log (Notification P3)
Append-only who/what/when trail for admin actions + scheduler run summaries,
generalizing QuotaEvent. Logged at ACTION / run-summary granularity — never
per-row (a scheduler run touching thousands of videos is ONE row).

Backend:
- migration 0054_audit_log + AuditLog model (actor_id FK SET NULL, action,
  target_type/id, summary, before/after JSON, created_at).
- app/audit.py: AuditAction constants (single source of truth, i18n'd on the
  client) + record() (adds to the caller's txn) + gc(retention_days).
- producers wired: role change / suspend / delete / invite approve-deny-add /
  demo add-remove-reset / discovery purge (admin.py); config set/reset — secret
  VALUES never logged, key only (config.py); scheduler interval + maintenance
  tune (scheduler routes); sync pause/resume (sync.py); and the scheduler _job()
  choke point writes ONE run-summary row per run — manual runs + errors always,
  automatic runs only when they changed something (no no-op-poll spam).
- retention: audit_gc scheduler job prunes rows older than audit_retention_days
  (sysconfig, default 180; 0 = keep forever).
- admin API routes/audit.py (/api/admin/audit): recent-window list (actor emails
  resolved, System for background) + clear (leaves one tamper-evidence row).

Frontend:
- new admin-only "Audit log" nav page (ScrollText) using the shared DataTable
  (first admin page to reuse it) — sort/filter/paginate/persist, action select
  filter, actor text filter, before→after detail, Clear-all with confirm.
- AuditLog.tsx + auditColumns.tsx + api.adminAudit/clearAudit + AuditRow type.
- trilingual audit + auditActions namespaces (HU/EN/DE) + nav + config-group +
  scheduler job labels; Audit config group (retention days).
2026-07-12 07:32:41 +02:00
npeter83
a6ffcf9577 chore(scheduler): dedup token-user query + drop pointless rss lambda
- Extract runner.token_users(db): the "all users with a stored refresh token"
  query was spelled 3× (get_service_user, run_subscription_resync,
  sync_all_playlists). get_service_user now reuses it ([0]); sync_all_playlists
  imports it from runner (no cycle — runner doesn't import playlists); the now-
  unused OAuthToken import is dropped there.
- scheduler.py: _job("rss_poll", lambda db: run_rss_poll(db)) → _job("rss_poll",
  run_rss_poll) — the lambda was dead indirection; all sibling jobs pass the
  callable directly and _job calls fn(db).

Behavior-neutral. ruff clean, localdev boots, re-review clean.
2026-07-11 19:44:53 +02:00
npeter83
bbbcf4ff5a feat(plex): Phase C — incremental + full Plex↔Siftlode watch reconcile
Completes the two-way watch-state sync with two scheduler jobs:

- plex_watch_sync (default 30m): pull recent Plex-side changes (watch history + on-deck, filtered
  to the owner account) into Siftlode under last-write-wins (_pull_apply + a _same_state ping-pong
  guard + skew tolerance), then re-push any still-unsynced local states.
- plex_watch_reconcile (default daily): full section rescan; uses synced_to_plex to settle what the
  incremental feed can't — notably propagating a Plex-side un-watch (clear a previously-mirrored row
  Plex no longer has) — while re-pushing never-synced local states and never touching hidden
  (Siftlode-only) rows. Union-preserving.

PlexClient gains accounts/watch_history/on_deck; _scan_plex_states is factored out and shared with
the one-time import. Owner accountID is resolved once and cached on the link. Both jobs are
registered in the scheduler (pause-skip, activity tracking, run-now, admin-tunable intervals) with
trilingual (HU/EN/DE) labels + descriptions. New config default plex_watch_reconcile_interval_min.

Verified live against the real Plex server: read feeds, last-write-wins, dirty re-push, the
incremental job end-to-end, and a full reconcile that cleared exactly the one un-watched item with
zero collateral across 17976 scanned.
2026-07-10 00:46:22 +02:00
npeter83
63c6e782c8 feat(plex): P1 backend — catalog sync + browse/search/show/image API
- app/plex/sync.py: full reconcile of enabled movie/show sections into plex_*
  (upsert by rating_key). Reuses Plex's own codec info to classify browser
  playability (direct/remux/transcode) — no ffprobe. Movies + episodes become
  playable leaves; shows/seasons mirrored for the drill-down.
- routes/plex.py: GET /libraries, /browse (FTS search + sort + paging, movie
  leaves or show cards), /show/{rk} (seasons+episodes, per-user state), /image
  (proxies Plex art, no duplication); admin POST /sync. All auth'd, demo-allowed.
- scheduler: plex_sync job (interval settings.plex_sync_interval_min).
- Verified against the real server: 2 libs, 3206 movies, 260 shows/961 seasons/
  14768 episodes synced in 13s; FTS search + drill-down work. Playability:
  ~1% direct, 89% remux, 6% transcode (informs P2/P3).
2026-07-05 02:20:23 +02:00
npeter83
7148335c09 feat(downloads): M3 — per-user quota + retention GC
- downloads/quota.py: per-user limits (DownloadQuota row or sysconfig defaults; unlimited
  bypass), footprint = distinct ready-asset bytes the user holds, check_enqueue (max_jobs +
  max_bytes hard at enqueue), at_concurrency_limit (worker-side max_concurrent), usage snapshot
- downloads/gc.py: run_download_gc — pre-expiry warning (once, gc_notified flag), TTL deletion
  (files + row; FK SET NULL orphans holders' jobs -> 'expired'), LRU eviction over a total-cache
  cap; notifies holders on each event
- migration 0038: media_assets.gc_notified
- config/sysconfig: download_total_max_bytes (0=unlimited) in the downloads group
- service.enqueue enforces quota; worker claim skips users at their concurrency limit
- scheduler: download_gc job registered (default 360 min, admin-tunable)

Verified on localdev: footprint accounting, max_jobs block, pre-expiry warning (notifies all
holders), TTL deletion (asset+files gone, dirs pruned, jobs orphaned, holders notified).
2026-07-03 00:26:40 +02:00
npeter83
f27f31b6db feat(search): ephemeral results, count selector, blocklist, new-first ordering (backend)
- Live-search results stay ephemeral: the discovery-cleanup job now also reclaims un-kept
  via_search videos (no watch state / not playlisted / channel not subscribed) after a grace
  period (search_grace_days), and POST /api/search/clear discards a given result set 'as if
  never added' (drops the user's search-finds + deletes the now-orphaned, un-kept videos).
  Admin POST /api/admin/purge-discovery runs it on demand (grace 0).
- Count selector: GET /api/search/youtube gains a 'limit' — the free scrape source pages
  through continuations until that many results are gathered (no manual load-more); the API
  source stays one ≤50 page (cost).
- Per-user channel blocklist (migration 0034 blocked_channels): block/unblock/list endpoints;
  blocked channels' videos are dropped from live search before ingest and hidden from the feed
  / Library / explore / channel page; explore refuses a blocked channel.
- New-first ordering: results you already have (subscribed channel, watched/in-progress/saved/
  playlisted) sink below genuinely-new discoveries, preserving relevance within each group.
2026-07-01 00:42:32 +02:00
npeter83
bc4c362423 feat(channels): channel-explore backend — about metadata, ephemeral provenance, cleanup
Adds the backend for a dedicated channel page + ephemeral browsing of un-subscribed
channels:
- migration 0033: channels.{total_view_count,published_at,banner_url,external_links,
  from_explore}, videos.via_explore, explored_channels (per-user, grace-clocked).
- enrich channels with part=brandingSettings (banner/links) + statistics.viewCount +
  snippet.publishedAt — no extra quota.
- GET /api/channels/{id}: About detail + this user's relationship; lazy-enriches the new
  About fields (published_at sentinel).
- POST /api/channels/{id}/explore (require_human, quota-reserve guarded): records the
  exploration, flags the channel ephemeral unless followed, ingests one page of uploads
  (via_explore) + enriches; returns next_page_token for load-more.
- feed: per-explorer gate — a via_explore video is visible only to users who explored or
  subscribe to its channel, so exploration never leaks into everyone's catalog.
- subscribe = keep: clears from_explore + via_explore on the channel's videos.
- scheduler explore_cleanup job + explore_grace_days config: hard-delete explored-but-
  unkept channels and their untouched ephemeral videos after a grace period.
2026-06-30 02:52:44 +02:00
npeter83
1ec89c8fbe chore: rename remaining subfeed references to siftlode
Replace the leftover 'subfeed' name across logger names + log_config,
frontend localStorage keys, Postgres user/db/volume defaults in the
compose files, .env.example, config.py, backup/restore scripts and the
README. Pure rename; no behavioural change. localStorage keys move from
subfeed.* to siftlode.* (one-time UI-state reset is acceptable).
2026-06-21 06:53:12 +02:00
npeter83
571f337fdf feat(demo): scheduled demo-account reset (5c)
A security audit of every mutating endpoint found no isolation gaps — all routes
scope by current_user, admin routes are gated, and the demo account is sandboxed
in its own user_id space (can't reach others' data, escalate, or hit admin
routes). The remaining demo concern is communal pollution of its own shared state,
so add an automatic reset: a new 'demo_reset' scheduler job (admin-tunable
interval, default 12h) reuses the manual reset logic, and no-ops without ever
creating a demo account if none exists. Verified: a triggered run wiped the demo's
video states and re-seeded its sample playlists.
2026-06-19 15:25:13 +02:00
npeter83
b23f805533 refactor(quota): canonical <entity>_<action> taxonomy for quota events
The quota-attribution action keys were inconsistent (mixed verbs, ad-hoc names,
English-only labels). Introduce a QuotaAction constants holder (one source of
truth), rename every attribution call site to it, and add migration 0020 to
rename historical quota_events.action values. The display label now resolves
from i18n (quotaActions.<key>, EN/HU/DE) instead of a hard-coded English map.
Scheduler job ids and progress phase labels are a separate namespace and are
left untouched.
2026-06-19 11:48:11 +02:00
npeter83
f69e34e3a6 fix(header): show sync activity only while a job is actually running
The header's sync indicator spun "fetching history" whenever any channel
still lacked full history — i.e. perpetually, even when the scheduler was
idle between its periodic runs. It now spins only while a channel-sync job
(backfill or RSS poll) is actually running; otherwise pending deep-history
work shows as the calm, static "N without full history" link, and recent
work queued for the next run shows a static "N queued". This makes the
header coherent with the Scheduler dashboard's live state.

Backend exposes running_job_ids() from the scheduler activity and a derived
sync_active flag on /api/sync/my-status.
2026-06-19 02:57:45 +02:00
npeter83
ed4194a8d3 feat(scheduler): admin run-now/run-all triggers + live progress + completion notices
Add per-job "Run now" buttons and a "Start all now" button to the admin Scheduler
dashboard (admin-gated endpoints). Triggers run the job in a background thread
independent of its interval, refusing a concurrent run (409). While running, the
long jobs (maintenance, enrich, backfill, shorts) report live progress through a
decoupled contextvar sink, shown as a progress bar on the job row via the existing
4s poll. A manually-triggered run posts a completion notification to the triggering
admin's inbox (scheduled runs stay silent to avoid spam); the inbox renders the
"scheduler" type trilingually from type+data. While here, give the maintenance job
its missing dashboard label/description in all three languages.
2026-06-18 04:01:10 +02:00
npeter83
c4aecf9f77 feat(maintenance): scheduled job to retire unplayable videos
Add a daily maintenance/validation job that detects videos which can't be played
anywhere and retires them safely. Two phases: re-check already-flagged videos
(recover if available again, else hard-delete once the grace period elapses,
cascading to states/playlist items), and a rolling re-validation of the
least-recently-checked currently-available videos that flags newly-unplayable
ones (hidden from the feed immediately via unavailable_since).

Detection is ~free: a video missing from the videos.list response is
deleted-or-private; an `upcoming` premiere >2 days past its scheduled start that
never went live is abandoned. A still-live broadcast is kept (legit 24/7 stream).
Enrichment now also fetches part=status to populate the status columns. Grace is
7 days for removed videos, none for abandoned. Before deleting, affected users
get one batched notification (never per-video). Interval is admin-tunable via the
Scheduler dashboard; batch size and grace are config. Quota-attributed to the
system and bounded by the same backfill reserve as the other jobs.
2026-06-18 03:20:28 +02:00
npeter83
84aebe16c7 feat(scheduler): admin-editable job intervals (DB-backed, live reschedule)
Add scheduler_settings (per-job interval override, migration 0015) and a
PATCH /api/admin/scheduler/jobs/{id} that persists the override and live-
reschedules the running APScheduler job. Intervals load from the DB (env
defaults as fallback); the snapshot reports the live trigger interval.
2026-06-16 15:58:23 +02:00
npeter83
a339a73bd6 feat(scheduler): admin dashboard endpoint + per-job activity tracking
Record each scheduler job's run activity in-process (running / last result /
error / timestamps) and expose a snapshot. New admin-only GET
/api/admin/scheduler returns the per-job activity (with APScheduler next-run
times), the DB-derived work still queued (channels/videos pending, deep ETA,
live-refresh backlog) and the shared quota picture.
2026-06-16 14:38:51 +02:00
npeter83
a661d079ee feat(playlists): YouTube playlist read-sync (backend)
Mirror each user's own YouTube playlists into local source='youtube' playlists,
one-way (YT -> local). New client methods iter_my_playlists / iter_my_playlist_video_ids
(OAuth, so private playlists work); sync/playlists.py reconciles the mirror (matching YT
order) and ingests any playlist videos not in the shared catalog yet (with stub channels).
POST /api/playlists/sync-youtube for manual sync (read-scope gated, per-user quota) plus a
scheduler job (playlist_sync_minutes, default 6h) that syncs all read-scope users. YouTube's
Watch Later / History are not API-accessible and are never synced.
2026-06-15 19:37:03 +02:00
npeter83
f255728f75 feat(stats): per-user API quota attribution + admin usage page
Track who burned how much YouTube API quota. A QuotaEvent audit log (migration
0009) records every spend with the triggering user (NULL = background/system) and
an action label, set via a request/job-scoped contextvar (quota.attribute) so no
call signatures change. User-initiated work (sync subscriptions, unsubscribe,
opt-in recent backfill, manual enrich) attributes to the user; scheduler work to
System, split by action.

- backend: QuotaEvent model + migration 0009; quota.attribute() contextvar;
  record_usage logs events; entry points wrapped (routes/sync, routes/channels,
  scheduler); GET /api/quota/my-usage + GET /api/quota/admin
- frontend: admin-only Stats page (header nav, page=stats) with daily bars +
  per-user breakdown by action and range picker; 'Your API usage' in Settings ->
  Sync for every user

Verified: attribution + endpoints compute correctly; events are per-user vs System.
2026-06-12 02:47:55 +02:00
npeter83
dc73b43b71 feat: sync status indicator, admin pause/resume, filtered video count
- Header shows a live sync status (total videos + channels still backfilling, or
  "paused"), polled every 30s
- Admins can pause/resume the background sync; a paused flag in app_state makes
  scheduled jobs skip (migration 0006)
- GET /api/feed/count returns the number of videos matching the current filters;
  shared filter builder keeps it in sync with /api/feed; shown above the feed
- /api/sync/status reports backfill progress, pending enrichment and paused state
2026-06-11 04:15:25 +02:00
npeter83
e07a37622d fix: feedback round 2 — language, subscriptions, feed scope, UX
- Language detection: classify one cleaned+concatenated blob (strip emoji,
  @mentions, #tags, numbers, punctuation); fixes caps/emoji-heavy channels
  (e.g. Nessaj -> Hungarian, no more bogus Chinese/Korean)
- Feed now joins the user's subscriptions, so unsubscribing on YouTube removes a
  channel from the feed; periodic subscription re-sync job picks up changes
- Watched/Saved/Hidden views ignore the Shorts/live default-hiding so the full
  set is visible (fixes hidden videos missing from the Hidden view)
- Persist feed filters + search across reloads (localStorage)
- 3D polish: cards lift with shadow on hover; chips/buttons get depth and a
  press effect; undo toast lasts longer
2026-06-11 03:28:45 +02:00
npeter83
8c245e986f fix: address reader-UI feedback (shorts, search, tags, login, UX)
- Shorts: confirm via youtube.com/shorts/<id> probe (SOCS cookie bypasses the
  consent redirect) instead of a 60s heuristic; concurrent probing, shorts_probed
  flag, scheduled refinement (migration 0005)
- Search: match title + channel name only (descriptions caused noisy results)
- Faceted tag filtering: AND across categories (language AND topic narrows),
  OR within a category; any/all toggle applies to topics
- Language detection: majority vote over individual titles (fixes misdetections
  like multipoleguy -> English; drops bogus Polish/Romanian)
- Login: drop forced consent so returning sign-in is quick (select_account)
- Feed cards: clickable channel name (opens channel), persistent saved badge,
  undo toast on hide, Hidden view to restore; tag chips show counts in tooltip
2026-06-11 03:07:49 +02:00
npeter83
68dad91e8a feat: M3 — automatic channel tagging (language + topic system tags)
- Tag and ChannelTag models + migration 0003 (partial unique indexes split
  system vs per-user tag names)
- Offline language detection (py3langid) constrained to a curated language set,
  with the channel's declared default language as a strong prior
- Topic tags mapped from YouTube topicDetails + dominant video category; the
  generic "Lifestyle" catch-all is intentionally dropped
- System (auto) tags are regenerated idempotently and never touch user tags;
  orphaned system tags are cleaned up
- GET /api/tags and admin POST /api/tags/recompute; scheduled autotag job
2026-06-11 01:57:19 +02:00
npeter83
cff1d23071 feat: M2 (part 2) — RSS poller, backfill, enrichment, scheduler
- Free per-channel RSS reader for quota-less fresh-video detection
- Recent-first backfill (configurable: 100 videos / 1 year) plus resumable deep
  backfill from the uploads playlist
- Enrichment via videos.list: duration, view/like counts, category, topics,
  language, Shorts heuristic and livestream/premiere classification
- Reusable sync runners + APScheduler jobs (rss / enrich / backfill), all
  quota-aware with a reserve so backfill never starves fresh enrichment
- Manual triggers: POST /api/sync/{rss,backfill,enrich}
- Exact insert counting via RETURNING with in-batch de-duplication
2026-06-11 01:36:41 +02:00