Commit graph

8 commits

Author SHA1 Message Date
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
fc69656d12 feat(downloads): granular post-processing phase labels
ffmpeg post-steps have no byte-progress, so instead of a silent 'Processing' the row now names
the current step (Merging / Extracting audio / Embedding thumbnail / Removing sponsors / Writing
metadata) with an indeterminate pulse. Byte-progress phases (video/audio) keep the % bar.
i18n en/hu/de.
2026-07-03 04:01:53 +02:00
npeter83
d55799cbc1 feat(titles): normalize video titles for display (feed, search, downloads)
Noisy YouTube titles are cleaned for display + storage, raw kept in videos.original_title:
- app/titles.normalize_title: strip emoji/symbols (keep accents), remove trailing SEO hashtag
  clusters (keep numeric #3 episode markers), context-aware de-shout (mostly-ALL-CAPS titles ->
  Title Case with an acronym whitelist + function-word lowercasing; otherwise only long all-caps
  words), collapse repeated punctuation
- applied at enrichment (sync/videos.py) and in the download worker (ad-hoc yt-dlp titles);
  catalog downloads inherit the normalized title automatically
- migration 0039: add original_title, preserve raw, rewrite title (generated search_vector
  regenerates); reversible via original_title

Backfill on localdev: 122115/273417 titles normalized in ~2 min. Verified in the feed + on
real messy samples (emoji/de-shout/hashtags), accents + acronyms (PS5/AI/USA/PC) preserved.
2026-07-03 03:00:08 +02:00
npeter83
b200f61642 feat(downloads): clear multi-phase download progress + transient-error retries
The progress bar looked broken (95% -> 5% -> recount) because yt-dlp downloads the video and
audio streams separately (each 0->100%) then merges — with no indication of which step is
running. Now:
- worker labels the phase from the stream codecs (video / audio) and via a postprocessor hook
  (merging / processing), so the user sees what's happening
- DownloadCenter shows the phase as the status and renders an indeterminate pulse (no bogus %)
  during merge/processing; phase i18n en/hu/de
- yt-dlp retries (3) + fragment_retries (5) so transient network blips self-heal instead of
  failing the job (seen once as a DNS 'No address associated with hostname' error)

Verified: phase transitions queued -> video -> processing -> done.
2026-07-03 02:42:36 +02:00
npeter83
c6ceaea899 fix(downloads): show real title + thumbnail while queued/downloading
A queued/downloading row previously showed the bare video id and no thumbnail because asset
metadata was only filled when the download completed. Now:
- service.populate_from_catalog fills title/uploader/thumbnail/date/duration at enqueue from
  our own Video/Channel catalog (0 network cost) — feed downloads look right instantly
- worker fills the same from yt-dlp's info_dict on the first progress event (covers ad-hoc
  URLs / catalog misses), best-effort, never fails a download

Verified: a paused feed download now shows its real title, channel, thumbnail and duration.
2026-07-03 02:23:21 +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
1ae4333922 feat(downloads): M2 — worker download loop + cache/dedup
- downloads/formats.py: profile spec -> format_sig (cache key) + yt-dlp opts (format
  selector, audio extract, sponsorblock, embed subs/chapters/thumbnail, jpg poster)
- downloads/storage.py: Plex tree path ({Channel}/Season {Year}/… [id].ext) + .nfo +
  poster.jpg sidecars; sanitization; asset-file deletion with empty-dir pruning
- downloads/service.py: enqueue + get-or-create shared MediaAsset (race-safe) + instant
  cache-hit path (ready asset -> job done, ref_count++)
- worker.py: N claim-loop threads (FOR UPDATE SKIP LOCKED), asset compare-and-set
  pending->downloading so identical requests never double-download, throttled progress
  writes, cooperative pause/cancel via the progress hook, crash-safe orphan recovery

Verified on localdev: real download -> Plex tree + .nfo + poster on the host bind-mount,
correct metadata/codecs/size; 2nd user = instant cache hit (shared asset, ref_count=2);
audio-only MP3 extraction path produces a distinct cached asset.
2026-07-03 00:08:18 +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