Commit graph

32 commits

Author SHA1 Message Date
npeter83
04fb3fb16e feat(plex): Phase A — one-time Plex→Siftlode watch-state import
Plex records watch state per Plex account; Siftlode per user in plex_states. A new plex_link table
maps a Siftlode user to a Plex account; for the owner (MVP) the row uses the server admin token
(uses_admin=True), so no separate Plex login. app/plex/watch_sync.py reads the owner account's
viewCount/viewOffset/lastViewedAt (already present in the catalog mirror's section listing) and
upserts them into the owner's plex_states — 'Plex is master' on this first import, but only where
Plex has a watch record (Siftlode-only states are preserved; union on the intersection). Idempotent.

Admin routes: GET/POST /api/plex/watch/link (status + enable/disable; first enable runs the import)
and POST /api/plex/watch/import (re-run). Migration 0051_plex_link. Two-way push (Phase B) and the
incremental Plex→Siftlode reconcile (Phase C) build on this in later ships.
2026-07-09 14:42:18 +02:00
npeter83
bed7bd227b chore(plex): use a generic example IP in the Plex server-URL hint (public repo) 2026-07-05 06:27:13 +02:00
npeter83
4e9b18cda9 feat(plex): P2 streaming backend — direct 206 + seek-restart HLS remux
Validated end-to-end on a real remux file (h264+ac3 mkv). The riskiest part of the
Plex epic — on-the-fly playback from the local file — now proven.

- app/plex/stream.py: seek-restart HLS session model (one ffmpeg per item, restarted
  at a seek offset via -ss). Video stream-COPY (I/O-bound, CPU-light — fine on the
  GPU-less prod host) + audio→aac only when not already aac. Maps first video+audio,
  drops subtitles (no VTT rendition clutter). Session cap + idle reaper.
- routes: POST /stream/{rk}/session?start= (direct→raw url, remux→HLS session,
  transcode→501 P3), GET /stream/{rk}/file (206 range, direct), /index.m3u8, /seg_n.ts.
- KEY FINDINGS (hard-won): (1) ffmpeg -hls_playlist_type VOD does NOT write the
  playlist mid-run (only at finalize) → use EVENT (append-only, appears immediately),
  which suits the seek-restart model; (2) naive per-segment copy + keyframe-indexed
  extraction are both unreliable (non-uniform/imprecise segments) — ffmpeg's own HLS
  muxer is the only correct segmenter; (3) dev slowness was the Windows /downloads
  bind-mount write + SMB read → PLEX_HLS_DIR env points scratch at fast container-local
  /var/tmp on dev (prod keeps download_root, fast local disk).
2026-07-05 04:16:45 +02:00
npeter83
a88254c841 feat(plex): P0 backend foundations — catalog model, config, client
Optional Plex module groundwork (design locked 2026-07-05): plays the LOCAL
physical file; Plex is metadata + optional watch-sync only.

- models + migration 0044: plex_libraries/plex_shows/plex_seasons/plex_items
  (playable leaf, ffprobe playability class, markers, weighted FTS search_vector)
  + plex_states (per-user watch state, mirrors video_states)
- sysconfig 'plex' group + config.py env defaults (server url/token[secret]/
  path-map/libraries/sync-interval/max-transcodes)
- app/plex/client.py (PlexClient httpx: server info, sections, items, metadata
  +markers, image) + app/plex/paths.py (Plex→local path map + file resolve)
- routes/plex.py admin POST /api/plex/test (verify connection + list sections)
2026-07-05 01:35:08 +02:00
npeter83
8588fa104b fix(downloads): android_vr primary + POT web fallback (fixes intermittent DRM/bot errors)
YouTube returns flaky per-client responses: the web clients intermittently mislabel normal
videos as 'DRM protected' (like tv did), and android_vr can get bot-flagged under heavy load.
Neither client alone is reliable. So:
- PRIMARY player client = android_vr: high-quality DASH (399+251), no PO token / Deno needed,
  reliable for normal use
- on a bot/DRM/format failure the worker retries with the FALLBACK set (web_safari,web,android
  + POT sidecar + Deno) — bypasses bot-detection when android_vr is flagged
- both client sets admin-tunable (DOWNLOAD_PLAYER_CLIENTS[_FALLBACK])

Verified: the previously DRM-failing video now downloads steadily via android_vr, no DRM error.
2026-07-03 23:01:09 +02:00
npeter83
21c5508889 feat(downloads): Deno JS runtime + web/android clients to complete the POT setup
The web player clients need two things beyond the PO token: (1) a JavaScript runtime for
YouTube's 'n' signature challenge — install Deno (v2.9.1) + yt-dlp[default] (bundles yt-dlp-ejs
solver scripts); auto-detected on PATH. (2) an audio source — the web clients only expose
video-only DASH, so add the 'android' client (also PO-token-backed) for audio/muxed formats.
Player clients: web_safari,web,android (dropped tv — it falsely reports normal videos as DRM).

End-to-end verified in the worker: extraction (POT + Deno n-challenge) + download completes,
no bot/DRM/format errors. Full YouTube stack now: force-ipv4 + bgutil POT sidecar + Deno.
2026-07-03 22:46:32 +02:00
npeter83
03a1865aa8 feat(downloads): automatic PO-token provider (bgutil sidecar) to beat YouTube bot-detection
YouTube increasingly demands a Proof-of-Origin token (or shows 'confirm you're not a bot').
Instead of manual cookies, run the bgutil POT provider as a sidecar that mints tokens on demand:
- compose: bgutil-pot service (brainicism/bgutil-ytdlp-pot-provider:1.3.1, port 4416, init)
- requirements: bgutil-ytdlp-pot-provider==1.3.1 plugin (version pinned to the image)
- config: DOWNLOAD_POT_BASE_URL (http://bgutil-pot:4416) + DOWNLOAD_PLAYER_CLIENTS
  (web_safari,web,tv — POT-capable; the default android_vr client never requests a token)
- formats.build_ydl_opts wires extractor_args youtube:player_client + youtubepot-bgutilhttp:base_url

Verified: the sidecar mints a valid PO token (BotGuard challenge solved) and yt-dlp fetches it
from the provider. NOTE: end-to-end download couldn't be confirmed here because the test IP got
hard-flagged by YouTube from heavy testing (~16 GB + dozens of extractions) — a hard abuse block
needs cooldown/cookies, which POT doesn't lift; POT bypasses the normal soft bot-check.
2026-07-03 04:40:10 +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
317750e491 feat(downloads): M1 — schema, config, worker container, deps
- models: MediaAsset (shared file cache, unique on source+format_sig), DownloadJob
  (per-user request), DownloadProfile (presets), DownloadShare (ACL), DownloadQuota
- migrations 0036 (5 tables) + 0037 (6 builtin presets)
- sysconfig 'downloads' group (per-user defaults, retention/GC, layout, worker concurrency)
- config: DOWNLOAD_ROOT / WORKER_ENABLED env + download defaults
- deps: yt-dlp in requirements, ffmpeg in Dockerfile runtime stage
- worker: dedicated container (python -m app.worker), thin idle entry (loop lands in M2)
- localdev compose: worker service + downloads bind-mount; gitignore downloads/
2026-07-02 23:57:40 +02:00
npeter83
0d44d3a34a chore: prepare repo for public release — scrub internal infra, rewrite README
- Remove owner-specific / legacy deploy files (home/prod/server compose, deploy/).
  The home compose stays as a local untracked file for the maintainer's own deploy.
- Genericise infra-specific code comments (egress-proxy examples) to neutral wording.
- Replace the hardcoded contact email on the legal pages with the instance operator's
  configured admin email, served via the public /auth/config and shown with a neutral
  fallback — so each self-hosted instance shows its own contact.
- Rewrite README for the current app + a copy-paste self-hosting quick start (prebuilt
  image + first-run wizard) with a build-from-source alternative; tidy .env.example.
2026-07-01 12:46:50 +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
396e09189b feat(search): zero-quota InnerTube scrape source + search_source toggle
Live YouTube search can now use YouTube's internal InnerTube endpoint instead
of search.list, materialising results through the same enrich/provenance path
at zero API quota (search.list costs 100 units/page; scrape costs nothing, only
the cheap shared videos.list enrich is charged).

- youtube/search_scrape.py: InnerTube search returning the same page shape as
  YouTubeClient.search_videos (items + continuation cursor); SOCS consent cookie,
  videoRenderer walk (channels/playlists/Shorts shelves naturally excluded),
  type:video filter, cached InnerTube key/version with constant fallbacks.
- routes/search.py: admin-selectable source (search_source, default scrape).
  Scrape path skips the 100-unit budget pre-check and logs a zero-cost search
  event so the per-user daily cap still counts it; api path unchanged. Response
  carries the active source.
- sysconfig/config: new search_source key (scrape|api).
- quota.log_action: record a zero-cost action event for per-user rate limits.
2026-06-29 22:29:54 +02:00
npeter83
9b1bdb6b42 feat(search): live YouTube search backend
Add a live YouTube search that materialises results into the shared catalog so
they render with the normal feed cards + in-app player and gain per-user state.

- YouTubeClient.search_videos(): search.list (100 units), embeddable-only, returns
  flat stubs + nextPageToken; surfaces liveBroadcastContent for live filtering.
- routes/search.py GET /api/search/youtube: require_human + per-user daily cap
  (search_daily_limit_per_user, default 70) + can_spend pre-check (429 on either);
  drops live/upcoming, upserts channel stubs (channels.list) + video stubs, enriches
  (videos.list), runs the youtube.com/shorts probe, then excludes Shorts/live and
  returns feed cards in relevance order with the YouTube pageToken as the cursor.
- Provenance: videos.via_search / channels.from_search (migration 0028) flag
  search-discovered rows; the feed hides them from the Library (scope=all) by default
  via exclude_search_discovered, leaving the Mine feed untouched.
- quota.actions_today() counts a user's per-action events today for the cap; only the
  search.list call is attributed VIDEOS_SEARCH so the counter is exactly 1 per search.
2026-06-29 02:01:31 +02:00
npeter83
d047ed7e15 feat(youtube): optional egress proxy for YouTube API calls
New youtube_api_proxy setting (env fallback + admin Config UI, env YOUTUBE_API_PROXY):
when set, the YouTubeClient routes all its httpx traffic through that HTTP(S) proxy.
Lets a dynamic-IP host send API calls through a fixed-IP host (e.g. the server over
private tunnel) so an IP-restricted API key keeps working. Empty = direct.
2026-06-26 00:32:17 +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
8c727dd99e feat(auth): account lifecycle — Google linking, passwords, suspension & deletion plumbing
- Link a Google account to a password account, and adopt the Google identity onto a matching
  email account instead of 500ing on a duplicate; set or change a password from Settings.
- Expose has_google/has_password on /api/me for the Sign-in methods UI.
- Mark Google logins email-verified (backfill existing rows, migration 0024); stop a routine
  login from clobbering an admin-assigned role (env ADMIN_EMAILS stays the bootstrap admin).
- Suspension login-gates (password + Google callback + current_user) with a rate-limited
  'suspended' notice; shared purge_user (cascade delete + access-request cleanup + Google-grant
  revoke) behind self- and admin-deletion; single app_base source for user-facing email links.
2026-06-19 19:52:02 +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
7efd4f4867 feat(auth): email+password registration with two-gate activation (5a backend)
Add email+password auth alongside Google: argon2id hashing; users gains
password_hash/email_verified/is_active and google_sub becomes nullable (migration
0022); a single-use, hashed auth_tokens table for email verification + password
reset. Registration creates a pending (inactive, unverified) account + an access
request; sign-in needs verified email AND admin approval. Anti-enumeration: uniform
register/login/reset responses (a correct-password owner still gets a specific
pending reason). allow_registration flag (DB-tunable). Admin users-list + role
endpoint (guards: not self, not demo, not the last admin); approving access (or a
manual whitelist add) now activates the matching pending account. current_user
rejects deactivated accounts. Verified end-to-end via curl + DB.
2026-06-19 14:03:11 +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
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
07f04b8f90 fix(version): read app version from the committed VERSION file
Make APP_VERSION come from the VERSION file (Dockerfile reads it for the SPA build
and ships it for the backend to read at runtime) instead of depending solely on a
deploy-time build-arg. This keeps the version correct even for a plain
`docker compose build` and removes the first-deploy bootstrap gap. git_sha/build_date
stay best-effort build-args.
2026-06-15 02:22:27 +02:00
npeter83
a93ab30fb2 feat(version): /api/version + build-time version/commit stamping
Add a VERSION file (0.1.0) and inject APP_VERSION/GIT_SHA/BUILD_DATE as Docker
build-args (both stages; Vite inlines them into the SPA). New public GET
/api/version returns app_version, git_sha, build_date and the Alembic head as the
database revision. deploy.sh and the localdev build pass the args.
2026-06-15 00:06:57 +02:00
npeter83
9ace042510 chore: rebrand Subfeed -> Siftlode
Rename all user-facing references (UI wordmark Sift+lode, titles, app name,
legal pages, onboarding wizard, emails, README/docs) and infra paths
(/srv/subfeed -> /srv/siftlode, image tag, deploy script, backup filenames).

Internal identifiers kept on purpose: Postgres user/db "subfeed", logger
namespace, localStorage keys, and the subfeed_pgdata volume (renaming would
orphan the migrated production data).
2026-06-14 04:40:22 +02:00
npeter83
4765db89de feat(auth): split base sign-in from YouTube scopes for incremental onboarding
Base login now requests only openid/email/profile (non-sensitive), so a new user
gets a clean Google consent with no "unverified app" warning and no 7-day refresh
token expiry. YouTube read (youtube.readonly) and write (youtube) are granted later
by the onboarding wizard via a parameterized /auth/upgrade?access=read|write.

Security fixes folded in from the baseline audit:
- config: refuse to boot in production (https OAUTH_REDIRECT_URL) with the
  placeholder/short SECRET_KEY or a missing TOKEN_ENCRYPTION_KEY, closing a
  session-forgery / admin-impersonation hole.
- main: mark the session cookie Secure when served over HTTPS.
- me: expose can_read; sync/subscriptions returns a friendly 403 (not a 500)
  until YouTube read access is granted.
2026-06-13 23:56:34 +02:00
npeter83
2add173760 feat(m5c): onboarding — DB invites, request-access, admin approval, email
Move the access whitelist from the ALLOWED_EMAILS env var into a DB Invite table
(env kept as bootstrap fallback), and add a self-service request + admin approval
flow with fail-soft email.

- models: Invite(email, status pending|approved|denied, requested_at, decided_*)
- migration 0008: invites table; seed env ALLOWED_EMAILS u ADMIN_EMAILS as approved
- auth: is_allowed() (DB-first, env fallback); a denied Google login records a pending
  request and bounces to /?access=requested instead of a raw 403; public POST
  /auth/request-access; upsert is idempotent so repeats don't re-spam admins
- routes/admin.py (admin-only): list/approve/deny invites + manual add
- email.py: smtplib + Gmail App Password, fail-soft (skips if SMTP unset)
- /api/me exposes pending_invites; config + .env.example gain SMTP_*
- UI: Login 'Request access' form + access=requested/denied handling; Settings ->
  Access requests (approve/deny + add); admin nudge toast on pending requests

Verified locally: request-access creates a pending invite and emails the admin;
seed approved npeter83; guinea-pig yt.trash2023 denied until approved.
2026-06-12 01:43:07 +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
npeter83
24b6e0026d feat: M2 (part 1) — subscription import, YouTube client, quota guard
- Channel/Subscription/Video/ApiQuotaUsage models + migration 0002
- Synchronous YouTube Data API client with OAuth token refresh and per-call
  quota accounting (API key preferred for public reads when configured)
- Subscription import: upsert channels + subscription links, prune unsubscribed,
  fetch channel details (uploads playlist, topics, stats, handle, country)
- Central quota guard tracking units per US-Pacific day against a daily budget
- POST /api/sync/subscriptions and GET /api/sync/status endpoints
2026-06-11 01:22:07 +02:00
npeter83
3a5209e96c feat: M1 foundation — compose stack, FastAPI, Google OAuth, encrypted tokens
- docker-compose with Postgres 16 + slim Python API image
- FastAPI app with session middleware, health endpoint, static login page
- Google OAuth (Authlib) with email invite-list whitelist; admin role support
- User + OAuthToken models; refresh tokens encrypted at rest (Fernet)
- Alembic migrations, run automatically on container startup
- Postgres backup/restore scripts for portability between machines
2026-06-11 01:01:37 +02:00