Commit Graph
518 Commits
Author SHA1 Message Date
peter 79ed9d900b docs(release): note the saved-view persistence fix in v0.22.3 2026-07-04 23:42:40 +02:00
peter 0ef8d6f423 fix(views): keep the applied saved view (or filters) across a reload
loadAccountFilters preferred the starred default view's mirror over the stored
filters, so a reload always snapped back to the default even after you'd picked
another view. Now your last-applied filters (setFilters persists them on every
change) win; the default view only seeds a fresh account that has never stored
filters. Re-apply the default from the sidebar to return to it.
2026-07-04 23:42:13 +02:00
peter 7972ec2211 feat(player): full-height glassy nav strips flanking the modal
Replace the small centred prev/next arrow buttons with faint translucent glass
strips that span the modal's full height, hugging each side of the card (hidden
on narrow screens where there's no room). Same stepping behaviour + Shift+arrow
shortcut; just a larger, easier target that reads as part of the dialog.
2026-07-04 23:42:13 +02:00
peter 14e6fc4562 feat(player): show the Shift+arrow shortcut in prev/next tooltips 2026-07-04 23:20:02 +02:00
peter 17c74d46fc merge: player feed-stepping + reachable native controls + hover-intent (v0.22.3) 2026-07-04 22:26:22 +02:00
peter e9b8bbfd9a docs(release): note player stepping + native controls in v0.22.3 2026-07-04 22:26:22 +02:00
peter 7284f89910 feat(player): step through the feed, reachable native controls, hover-intent
Three in-app player refinements:
- Prev/next stepping: the modal now takes the feed's loaded order as a queue, with
  faint arrow zones flanking the card and Shift+Left/Right shortcuts (plain arrows
  seek ±5s). The queue is frozen at open so marking the current video watched can't
  drop it and reload a different one; auto-advance-on-end stays off for the feed
  (it isn't a playlist) — playlists keep theirs.
- Reachable native YouTube controls: the interaction overlay (wheel volume / click
  pause / keyboard focus) now covers only the centre band (top-[12%] bottom-[22%]),
  leaving the top-right cluster (volume/CC/settings) and the bottom bar (seek /
  More videos / fullscreen) clickable. Verified live: settings menu + fullscreen work.
- Hover-intent description: the title popover now waits ~400ms so a quick pass no
  longer flashes it.
2026-07-04 22:25:37 +02:00
peter 01a82111cf merge: download source URL reference + adaptive watch player (v0.22.3) 2026-07-04 21:26:08 +02:00
peter 9193b73327 chore(release): v0.22.3 — source URL reference + adaptive watch player 2026-07-04 21:26:08 +02:00
peter 335ea31040 fix(watch): shrink-wrap the player to the video aspect
A vertical/short clip rendered in a fixed full-width box with large black side
bars. The player container now uses w-fit + mx-auto and the video max-h/max-w, so
a portrait clip becomes a narrow centered player and a landscape clip fills the
width — no letterboxing either way.
2026-07-04 21:25:37 +02:00
peter 8b96b673bc feat(downloads): store + show a canonical "downloaded from" source URL
Every download now records the clean source page URL and shows it on the
Downloads page (open in a new tab, or copy to clipboard). The worker stores
yt-dlp's canonical webpage_url on the asset (migration 0043 adds
media_assets.source_webpage_url); the serializer prefers it and falls back to a
URL derived from source_kind+source_ref, so YouTube, external YouTube links and
external URLs (e.g. Facebook reels) all get a correct reference, and queued/older
rows work before the worker fills it. Edit clips return null (a clip's source is
the user's own earlier download, not a web page). i18n en/hu/de.
2026-07-04 21:25:37 +02:00
peter 3327dd774a merge: route/modal code splitting (v0.22.2) 2026-07-04 19:44:19 +02:00
peter 887cda1b40 docs(release): note code-splitting in v0.22.2 2026-07-04 19:44:19 +02:00
peter 0c428abe9f perf: route- and modal-level code splitting (React.lazy)
The whole app shipped in one bundle, so the logged-out landing and every page
pulled all module code. Split into lazy chunks:
- main.tsx: lazy App + the public leaves (WatchPage, Privacy, Terms), so a
  public /watch share link never downloads the authenticated app.
- App.tsx: each module page (Feed, Channels, Playlists, Stats, Scheduler,
  Config, Users, Settings, Notifications, Messages, Downloads, ChannelPage) and
  the About/ReleaseNotes/Onboarding modals load on demand behind <Suspense>.
- Heavy modals lazy in their parents: PlayerModal (Feed, Playlists),
  DownloadDialog (DownloadButton — kept out of the feed chunk), and the
  VideoEditor/ShareDialog/ProfileEditor (DownloadCenter).
- Extracted focusAccessRequestsTab + the tab constants to lib/adminUsersTab so
  callers can pre-select the admin tab without statically importing the now
  lazy-loaded AdminUsers page (which would defeat the split).

Build now emits ~25 chunks. Landing no longer downloads any module code
(~350 KB deferred); dev landing Perf 93->95. Verified in a real browser: every
page + the video editor load with no console errors.
2026-07-04 19:43:50 +02:00
peter 3f2af0c520 merge: accessibility fixes across module pages + feed thumbnail dims 2026-07-04 19:13:35 +02:00
peter 13467837a4 docs(release): note a11y improvements in v0.22.2 2026-07-04 19:13:35 +02:00
peter 11ee22847a perf(feed): intrinsic dimensions + async decode on video thumbnails
Give the feed card thumbnail width/height (16:9) so the browser reserves the
aspect box before load (CLS), and decoding=async so image decode never blocks
the main thread. loading=lazy was already set.
2026-07-04 19:13:11 +02:00
peter 1dd74fcda9 fix(a11y): accessible names for switches, swatches, selects and inputs
Lighthouse a11y across the authenticated module pages flagged: the clickable
Siftlode logo had aria-label='Feed' (accessible name didn't include its visible
text); the shared Switch, theme swatches, sort selects and several number/range
inputs had no accessible name/label. Give Switch role='switch'+aria-checked and
an optional label (passed at every call site), aria-label the swatches, selects
(feed/playlists sort) and the settings/config/scheduler inputs, and drop the
mismatched logo aria-label so its visible text is the name. All 11 module pages
now score 100 accessibility (settings 86->100, playlists 89->100, others 92-95->100).
2026-07-04 19:13:11 +02:00
peter 36b8560355 merge: Lighthouse audit fixes — landing perf, SEO, console (v0.22.2) 2026-07-04 18:19:08 +02:00
peter 1abd5c3b51 chore(release): v0.22.2 — landing perf + SEO polish 2026-07-04 18:19:07 +02:00
peter d58dd8abad fix(auth): /api/me answers 200 when logged out (no console error)
The logged-out landing probed /api/me, which 401'd, and the browser logs every
non-2xx fetch as a console error regardless of how the app handles it. The
bootstrap probe now returns 200 with {authenticated:false} via a new
optional_current_user dependency; api.me() maps that to null. The render gate
treats no-data as 'signed out' -> the landing, and a thrown error as a real
network/5xx failure. Other protected endpoints still 401, so mid-session expiry
is still caught by the global handler. Lighthouse (dev): Best Practices 96->100.
2026-07-04 18:17:24 +02:00
peter 8dea56f1c5 feat(seo): meta description, Open Graph tags and robots.txt
The public landing had no meta description and no robots.txt (the SPA fallback
returned index.html for /robots.txt, so crawlers saw an invalid file). Add a
description + basic OG tags to index.html and a real robots.txt that allows the
landing but disallows the API/auth/watch surfaces. Lighthouse (dev): SEO 83->100.
2026-07-04 18:17:24 +02:00
peter 2acc013756 perf(landing): WebP welcome screenshots + long-cache static assets
The 3 landing screenshots were 2.3 MB of PNG (feed.png alone 1.6 MB, decoded
at 2400x1350 for a ~400px slot) — the biggest LCP/transfer cost on the public
page. Re-encoded to WebP capped at 1600px (~284 KB total, feed 91% smaller).
Also set Cache-Control: content-hashed /assets/* are immutable for a year,
other SPA-root static files (welcome images, favicon, robots) get a 7-day TTL;
index.html stays no-cache. Register image/webp+avif mimetypes so FileResponse
serves the right Content-Type. Lighthouse (dev): Perf 80->93, LCP 1.8s->1.2s.
2026-07-04 18:17:11 +02:00
peter 8f943db046 merge: iOS/Safari-compatible mp4 downloads (v0.22.1) 2026-07-04 17:30:02 +02:00
peter f872ad78c5 chore(release): v0.22.1 — iOS/Safari-compatible downloads 2026-07-04 17:30:02 +02:00
peter ddfe1854e9 fix(downloads): prefer H.264+AAC for mp4 so shared files play on iOS/Safari
mp4 downloads used yt-dlp's default best video+audio, which on YouTube is
VP9+Opus at 1080p. Chromium plays that, but iOS/Safari WebKit (which every
iOS browser is forced to use) decodes only H.264/H.265+AAC inside mp4 — so a
shared /watch link showed a broken player on iPhone/iPad while the download
still worked. Rank vcodec:h264 + acodec:aac above resolution for mp4 output
(unless a custom profile pins a vcodec), keeping VP9/AV1 as a graceful
fallback. Bump the format signature (v2) so an identical spec gets a fresh
cache identity instead of hitting a stale VP9 asset.
2026-07-04 17:29:05 +02:00
peter 6b183c72af fix(worker): wait for the DB schema before starting (no crash-loop on fresh deploy)
The worker and API start in parallel; the API applies migrations on its own startup, so on a fresh
deploy the worker hit a missing download_jobs table and crash-looped until migrations landed. The
worker now polls for the schema before recovering orphans, and (belt-and-suspenders) the compose
files gate it on the API being healthy. Fixes the observed prod restart-loop on the v0.22.0 deploy.
2026-07-04 06:43:04 +02:00
peter 516b385178 merge: Download Center editor + share + v0.22.0 release prep
Phase 2 video editor (trim/split/crop/join, cut-list, filmstrip), public share links + user
picker + watch page, shared-item edit/remove, live storage bar, UI cosmetics (favicon, dynamic
tab title, clickable logo, styled module headers), and the v0.22.0 release prep (compose worker/
sidecar + docs).
2026-07-04 06:34:01 +02:00
peter 58dd06738f chore(release): v0.22.0 — Download Center worker/sidecar in all composes + docs
Prep the Download Center epic (Phase 1 + editor + share) for prod/self-host:
- Dockerfile: create /downloads owned by appuser so a named-volume mount is writable (prod Linux).
- docker-compose.{home,selfhost,yml}: add the 'worker' (yt-dlp/ffmpeg job loop) + 'bgutil-pot'
  (PO-token) services + a downloads mount (DOWNLOAD_ROOT, WORKER_ENABLED). Media defaults to a
  named volume; DOWNLOAD_HOST_PATH points it at a host dir (e.g. a Plex-readable folder).
- README / docs/self-hosting.md / .env.example / install.{sh,ps1}: document the Download Center,
  the two extra containers, and DOWNLOAD_HOST_PATH.
- VERSION 0.22.0 + releaseNotes entry.
2026-07-04 06:31:31 +02:00
peter 01f2c50df2 feat(ui): favicon, dynamic tab title, clickable logo, styled module headers
Session-close cosmetics:
1. Favicon — an 'S' monogram (indigo→violet rounded square, path-drawn so it's font-independent)
   at public/favicon.svg, linked in index.html (tab had none before).
2. Dynamic document.title — reflects the current module ('Downloads · Siftlode'); Feed = brand
   only; an open channel page shows the channel name. Shared pageTitleKey() in lib/pageMeta.ts
   keeps the tab title and the top-bar header in lockstep.
3. The 'Siftlode' logo already navigated to Feed but read as static text — added a hover
   background + cursor affordance so it's clearly clickable.
4. Module header — new shared PageTitle component (used by every non-feed module via Header) with
   a tracked small-caps 'eyebrow' + leading accent dot (user-picked style B), styled in one place.
Verified in a real browser: favicon served (image/svg+xml), title updates per module, logo→feed,
header restyled; no console errors.
2026-07-04 06:17:40 +02:00
peter d818409c07 fix(downloads): live-refresh the storage bar so a finished edit/download shows immediately
The 'Your storage' footprint used a one-shot query, refetched only at enqueue time — when the job
is still queued (0 bytes) and the file lands asynchronously via the worker. So the footprint stayed
stale (0 B) until a manual reload. Switch the UsageBar to useLiveQuery(2s), like the library list,
so the footprint updates in lockstep the moment a clip/download completes (and after a delete).
2026-07-04 05:30:43 +02:00
peter 3c112aa855 feat(downloads): edit + remove actions on shared-with-me items (no re-share)
A shared-with-me item only had a download button. Add two actions (Share is intentionally NOT
offered — no chain re-sharing of someone else's file):
- Edit: the editor now accepts an accessible (owned OR shared) source, so editing a shared video
  produces the editor's OWN clip in their library (counts against their quota, fully theirs
  including share); the source file is only read. Route uses _accessible_job.
- Remove from my list: DELETE /api/downloads/shared/{job_id} deletes only the recipient's share
  grant — the owner's job and physical file are untouched (per-user dismissal).
i18n en/hu/de. Verified in a real browser (edit a shared 70-min video → own 2:31 clip; remove
confirm shows 'won't delete the owner's file').
2026-07-04 05:21:50 +02:00
peter f7fa8a59c0 feat(downloads): let admins set a quota for any user, not only downloaders
The admin System tab only listed users with an existing footprint, so a quota could be set only
after someone downloaded something. Add a 'Set a user's quota…' picker (over /api/admin/users,
excl. demo) beside the Per-user footprint heading that opens the quota editor for ANY user — the
quota GET/PUT endpoints already resolve defaults + create a row on demand, so no backend change.
Also show an empty-state line when no one has a footprint yet. i18n en/hu/de.
2026-07-04 05:07:23 +02:00
peter 22de99cfb5 fix(scheduler): give the download_gc job a proper name + tooltip
The download-center GC job showed its raw id 'download_gc' with a raw-key tooltip, unlike every
other job. Add its label ('Download cleanup') + description to scheduler i18n (en/hu/de), and make
the job tooltip fall back to empty for any job lacking a description (so a future/edge job never
shows a raw 'scheduler.jobDesc.*' key — also fixes demo_reset).
2026-07-04 04:58:51 +02:00
peter 0ad0ba6cfe fix(tooltip): keep hint captions inside the viewport near screen edges
The tooltip clamped its centre to a fixed 92px margin, but the caption is up to 240px wide
(120px half), so a left-edge anchor (e.g. the nav-rail counts icon at x~40) pushed the centred box
~28px off the left of the viewport. Clamp with the caption's max half-width so it can't overflow on
first paint, then a useLayoutEffect re-centres on the anchor using its actual width. Verified: the
nav-rail counts tooltip now sits fully on-screen.
2026-07-04 04:45:51 +02:00
peter b8d34ab67c fix(sync): keep the pause control inline in the nav rail, not on an orphan line
In the left-nav rail SyncStatus, the pause button rendered as its own bottom block, so with only
deep-history pending (idle) it dropped to a lonely line under 'N without full history'. Now it sits
right-aligned on the primary status row — the sync-state line when there is one, otherwise the
'N without full history' row — via flex justify-between. No change to the all-synced state.
2026-07-04 04:41:13 +02:00
peter e73ba181ee feat(downloads): share UI — user picker + public watch links + watch page
Rework the share dialog into two clear modes and add the public /watch player page:
- ShareDialog: (A) 'Share with a user' — autocomplete picker over registered users (was a blind
  email box that 404'd on non-users); (B) 'Share a link' — create/list/copy/revoke public links
  with allow-download toggle, optional expiry (1/7/30d), optional password; per-link view count.
- WatchPage: standalone login-free player at /watch/<token> (routed in main.tsx like /privacy),
  self-contained mini-i18n (en/hu/de by browser language); password gate → unlock → play; shows a
  Download button only when the link allows it.
- api: ShareLink/ShareRecipient types + link CRUD + recipients; share i18n (en/hu/de).
Verified end-to-end in a real browser: user picker, link create, public playback, stream-only vs
downloadable, password gate + unlock, no console errors.
2026-07-04 04:32:31 +02:00
peter 3e1ad6188e feat(downloads): public share-by-link + watch page backend
Share a download by a capability URL (/watch/{token}) that anyone can play on a login-free page —
distinct from the registered-user ACL share. Per-link controls: optional expiry, allow-download
toggle (stream-only vs downloadable), optional argon2 password. Revoke = delete.
- migration 0042 + DownloadLink model; app/downloads/links.py (token, HMAC grant sign/verify)
- owner endpoints (POST/GET/PATCH/DELETE links) + /recipients for the internal user picker
- public router (no auth): watch meta / password unlock→signed grant / range-aware file serve
  (inline vs attachment); unlock is rate-limited; meta verifies the file exists on disk
Verified end-to-end: 206 range, inline vs attachment, wrong-password 403, tampered-grant 403.
2026-07-04 04:19:29 +02:00
peter 83a0bce184 fix(downloads): clamp editor hover-scrub popover inside the track
The hover-scrub thumbnail was centered on the cursor with a fixed -translate-x-1/2, so near the
filmstrip's right (or left) edge it overflowed the modal and triggered a horizontal scrollbar. Now
its left edge is clamped to [0, trackW - popoverW] so it shifts inward at the edges and stays fully
within the viewport. Verified at both edges in a real browser.
2026-07-04 03:41:18 +02:00
peter 0f91e37871 feat(downloads): editor v2 — cut-list, join, aspect filmstrip + hover scrub
Rework VideoEditor around a segment cut-list: N draggable cut markers → segments, per-segment
keep/drop (eye toggle + dropped hatch), output = Separate files (one job per kept segment) or
Join into one (segments cut-list → one concatenated file). Per-segment numeric Start/End inputs.
Filmstrip fixed: aspect-correct tiles (no vertical squish) + YouTube-style hover-scrub thumbnail
from the sprite. EditSpec.segments type; editor v2 i18n (en/hu/de). Verified end-to-end in a real
browser (3-segment split, drop middle, join → 0:12 clip from a 0:19 source).
2026-07-04 03:26:33 +02:00
peter a166770b7a feat(downloads): editor concat — multi-segment join (cut-list)
Editor v2 backend: an edit_spec can carry a 'segments' cut-list that concatenates the kept
ranges into ONE file (accurate=filter_complex trim+concat re-encode; fast=concat-demuxer
stream-copy with per-segment inpoint/outpoint). normalize/edit_sig/clip_duration/needs_reencode
handle segments; worker writes the concat list + runs build_concat_plan. Single-trim path (used by
'separate files' export) unchanged.
2026-07-04 03:15:45 +02:00
peter c25f185bd7 feat(downloads): editor UI — trim/split/crop clips (phase 2)
VideoEditor modal on a finished Library download: HTML5 <video> scrubber, filmstrip timeline
(lazy server storyboard sprite) with draggable in/out handles, draggable/resizable crop overlay,
per-edit Precise (re-encode) vs Fast (stream-copy) cut toggle, split-into-N fan-out (N trim jobs),
optional clip name. Edited clips show a 'Clip' badge; 'editing' phase gets a % bar. New api
enqueueEdit/downloadStoryboard/storyboardImageUrl + EditSpec type; editor i18n (en/hu/de).
2026-07-04 01:10:42 +02:00
peter 470231516f feat(downloads): editor backend — trim/crop derivatives (phase 2)
Per-user trim/crop clips derived from a finished download via ffmpeg, riding the same
MediaAsset/DownloadJob/worker/quota/GC machinery (asset source_kind='edit', per-user cache key):
- migration 0041: download_jobs job_kind/source_job_id/source_asset_id/edit_spec
- app/downloads/edit.py: spec normalize+sig, ffmpeg trim/crop cmd (fast stream-copy vs frame-
  accurate re-encode, per-edit 'accurate' toggle), progress+cooperative-cancel runner, filmstrip
- service.enqueue_edit; worker _process_edit branch; storage.edit_rel_path (.edits/{user} tree)
- routes: POST /edit, GET /{id}/storyboard(.jpg)
2026-07-04 01:01:46 +02:00
peter c28479ba03 merge: Download Center Phase 1 (yt-dlp) into dev
yt-dlp Download Center epic Phase 1: worker+queue, cache/dedup, presets +
profile editor, per-user quota, Plex layout + .nfo/poster, sharing,
retention/GC, admin storage, title normalization, YouTube bot-detection
mitigation (PO-token sidecar + force-ipv4 + Deno). Not yet released to prod.
2026-07-04 00:30:25 +02:00
peter 8e6544a53b 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
peter 816e9f5d2b 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
peter b5a8b7d740 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
peter 89ccdbbaee improvement(downloads): no-embed builtin presets + 1080p default
Embedding (thumbnail/chapters) rewrote the whole file — a 10 GB video spent minutes making a
10 GB temp copy — while the Plex .nfo + poster sidecars already carry that metadata. So:
- builtin presets no longer embed (migration 0040 re-seeds them; formats._DEFAULTS embed off);
  only the tiny FFmpegThumbnailsConvertor (for poster.jpg) remains, so downloads go
  video -> audio -> merge -> done with no full-file rewrite
- default preset is now 1080p (Best stays available, second in the list) so a long video
  doesn't silently pull ~10 GB and blow the quota
Users who want self-contained files can enable embedding in a custom profile.
2026-07-03 04:08:53 +02:00
peter 6a9075a04e 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
peter d6e3b1c436 fix(downloads): force IPv4 (fixes container 'Errno -5' DNS failures) + retry resets errored asset
Root cause of the failing downloads: containers usually have no working IPv6 route, but
googlevideo CDN hosts advertise AAAA records, so the downloader tried IPv6 and failed with
'[Errno -5] No address associated with hostname'. Small clips happened to use IPv4; long videos
(more CDN requests / the ffmpeg path) hit IPv6 and died.
- yt-dlp source_address=0.0.0.0 forces IPv4 for every connection (== --force-ipv4). Verified: the
  full video+audio download completes cleanly.
- Also: retry (resume) now resets the shared asset from 'error' to 'pending', so it actually
  re-downloads — previously the requeued job instantly re-inherited the asset's stale error
  because the worker short-circuits a job whose asset already errored.
- Dropped the localdev DNS override (wrong hypothesis; the issue was IPv6, not the resolver).
- Kept yt-dlp retries/fragment_retries/socket_timeout for transient blips.

Verified end-to-end in the worker: two previously-failing long videos now download to done.
2026-07-03 03:47:13 +02:00