Commit Graph
1630 Commits
Author SHA1 Message Date
peter 375f6e58be merge main (0.78.1) into tonemap before release 2026-08-30 18:55:14 +02:00
peter 1a102ce81a release: 0.78.1 (bug/download-mediakind-probe)
gate / frontend (push) Failing after 1m36s
gate / backend (push) Successful in 2m22s
v0.78.1
2026-08-30 18:47:05 +02:00
peter bfd45a155e release: 0.78.1 — download media_kind from a file probe (BUG 2) 2026-08-30 18:46:33 +02:00
peter 1102ad6a28 fix(downloads): make the probe verdict authoritative over yt-dlp info by probe-ran, not per-field
Deep-review round 1 on bug/download-mediakind-probe found a real HIGH the prior
review missed:

- resolve_media_facts used a per-field `probe or info` fallback, so a probe that
  RAN and deliberately found no video track (cover-art-only audio: attached_pic
  skipped; a still image: format-guarded) had its None video overridden by
  yt-dlp's stale vcodec. An audio-mode download from a progressive source (info
  carries the source's video codec, embed-thumbnail on) was then classified VIDEO
  -> a Play button + a broken in-app <video> with no video track. Only the
  download path regressed (upload calls probe_media directly).

  Fix: gate precedence on whether ffprobe RAN, not per-field. If the probe read
  the file, its codec verdict wins outright (a None means 'no such track'); only a
  full ffprobe failure falls back to info. Dimensions still gap-fill from info.
  Refactored the parsing into _facts_from_probe so resolve_media_facts can see the
  raw probe-ran signal; probe_media keeps its 5-tuple (upload + TestProbeClassifier
  unchanged).

- tests: the cover-art-audio and still-image resurrection cases, a differing
  populated-info precedence case (the prior-flagged gap), and the ffprobe-failed
  info fallback — now stubbing run_ffprobe_json so the real parsing + precedence
  run together.

Backend gate green (ruff + full pytest).
2026-08-30 18:09:26 +02:00
peter b563155a37 docs(plex): correct the cull-yield fallback comment to name the real route 404s
Deep-review round 3: the only surviving finding — a comment-drift nit. The
fallback's cull-yield comment named the route's 'Not available yet' path, but a
culled-key segment request actually returns 'No active playback session' (the
substantive invariant — never a None that 404s as file-not-found — is unchanged).
Reworded to match the route. Concurrency + correctness came back clean.
2026-08-30 15:18:26 +02:00
peter 915319fa35 fix(plex): make the HW->SW fallback supersede-check atomic; honest cull yield
Deep-review round 2 on the round-1 fix:

- Race (medium): the round-1 identity guard did the supersede check and the
  respawn in TWO separate _lock sections. A newer same-key session installed in
  the gap was still torn down by _spawn's unconditional pop+kill (the round-1
  comment even had the direction backwards). Fold the check into _spawn via a
  new replace_only= param: the identity test and the pop+replace now share ONE
  lock, so the respawn is a true no-op when superseded/culled.

- Honest states (low): the superseded branch returned current_session(key),
  which is None when the key was cap-culled during our out-of-lock wait; the
  route turns None into a dishonest 'Media file not found on disk' 404 for a
  file that exists. Yield 'current_session(key) or s' so a culled session
  degrades to the route's retryable 'not available yet' path instead.

- Hygiene: _want_hw docstring corrected (it is a test-only shim now; production
  decides via _select_encode) and the two stale _want_hw references refreshed
  (stream.py header, config.py plex_transcode_hw).

- tests: the cap-cull no-respawn/no-None sub-case; the _opencl_present except
  OSError branch (glob raising, not just empty); tightened a tautological -vf
  assertion. Backend gate green (ruff + full pytest).
2026-08-30 15:11:37 +02:00
peter a2a8f5681d fix(plex): don't tonemap 10-bit SDR; identity-guard the HW→SW fallback
Deep-review round 1 on feature/plex-hdr-hw-tonemap:

- F1 (correctness): _select_encode gated tonemap on bit-depth alone, so a
  10-bit *SDR* (bt709) source got the HDR hable tone curve applied, visibly
  altering an SDR picture where only a 10->8-bit downconvert was needed. Split
  the ffprobe gate into a 3-way classifier (_probe_video_kind: hwsafe / hdr /
  sdr10); only true-HDR (PQ/HLG) takes the OpenCL tonemap graph, 10-bit SDR
  drops to software libx264 (an exact downconvert). Applies to both auto and on.

- F2 (race): the runtime HW->SW fallback popped+killed _sessions[key] with no
  identity check. Because wait_for blocks OUTSIDE _lock, a concurrent same-key
  _spawn (rapid seek-restart) or _enforce_cap cull could kill this session; the
  fallback then respawned software over the NEWER session and breached the
  transcode cap. Guard the respawn: only fall back if _sessions[key] is still
  this session, else yield the currently-registered one.

- tests: 3-way classifier (incl. HLG + empty-probe), real _opencl_present glob,
  real wait_for proc fast-fail branch, tonemap-variant fallback, the F1
  regressions (auto + on), the supersede/no-respawn race, and the on/opencl
  matrix cell. Backend gate green (ruff + full pytest).
2026-08-30 15:02:41 +02:00
peter 4827aebb7f feat(plex): HW HDR→SDR tonemap for 10-bit/HDR transcode (OpenCL on the iGPU)
Upgrade mode-3's 10-bit/HDR branch from "route to software libx264" (BUG 1's
behaviour) to a full-GPU pipeline: VAAPI decode → OpenCL tonemap (hable,
HDR10/HLG → bt709 SDR nv12) → h264_vaapi. Gen 9.5 (UHD 630) can't tonemap in
the VAAPI VPP (tonemap_vaapi fails), so OpenCL is the only HW route — proven on
the prod iGPU at ~4.2x realtime, ~1 CPU core.

- stream.py: `_select_encode` returns (hw, tonemap); a 10-bit/HDR HW-decodable
  source picks the tonemap graph when the OpenCL runtime is present, else drops
  to software. `_opencl_present` gates on a registered ICD. `_venc_opts` /
  `_hwaccel_in_opts` gain the tonemap variant (named vaapi+opencl devices,
  `-filter_hw_device ocl`, hwmap→tonemap_opencl→hwmap reverse). `_want_hw` kept
  as the hw-half wrapper. The runtime HW→SW fallback still backstops a session
  that emits no segment.
- Dockerfile: add the Intel NEO OpenCL runtime (intel-opencl-icd). It was removed
  from Debian trixie, so it's pulled from bookworm (22.43 still supports Gen 9.5;
  the .deb runs on the trixie base — verified live). Harmless without /dev/dri.
- route + FE: thread `tonemap` through the session response; the stats overlay
  mode row reads "transcode · GPU · HDR→SDR" for the tonemap path.
- tests: tonemap command branch, the (hw, tonemap) selection matrix (auto/on ×
  8-bit/10-bit × opencl), start_session tonemap wiring, and the fmtMode tag.
2026-08-30 06:02:45 +02:00
peter 10d69cb316 fix(plex): route 10-bit/HDR HEVC to software + runtime VAAPI→libx264 fallback
A 10-bit/HDR HEVC source (e.g. an HDR10 x265 file) died at scale_vaapi=format=nv12
on the UHD 630 (Gen 9.5 VPP: "VAProfile not supported"); ffmpeg produced 0 segments
and the item never played, with no fallback — a retry just re-picked the failing HW
path. An 8-bit HEVC and the same file on libx264 both work.

Two defenses:
- `_want_hw` auto mode now also gates on an 8-bit-SDR source via a new
  `_probe_video_hwsafe` ffprobe (bit depth from pix_fmt/bits_per_raw_sample, HDR from
  color_transfer) — a 10-bit/HDR source takes the software libx264 path, which
  downconverts to yuv420p cleanly. The Plex mirror stores no bit-depth signal, so this
  is probed at session start. "on" still trusts the admin and skips the gate.
- `start_session` adds a runtime HW→SW fallback: a HW session that emits no first
  segment respawns on libx264 and reports hw=False (the route/stats overlay honestly
  show CPU). `wait_for` gained an optional `proc` so a dead ffmpeg fails fast instead
  of burning the 25s budget before the fallback fires.

Tests: TestProbeVideoHwsafe (parsing + probe-failure-assumes-safe), the 10-bit auto
gate + "on" skip in TestWantHw, and the no-segment HW→SW respawn in
TestTranscodeStartSession. Backend lane green.
2026-08-30 03:53:31 +02:00
peter a86131e062 fix(downloads): classify media_kind from a file probe, not yt-dlp info
A downloaded video was silently demoted to a download-only "file" (no
in-app play/edit) whenever yt-dlp's info dict omitted vcodec/acodec — which
it does for Facebook and other sources. Finalize derived media_kind from
those nulls (`derive(None, None)` -> file), so a genuine H.264/AAC MP4
became non-playable in the library.

Fix: probe the finished file on disk (the ground truth) at finalize and
derive from that, falling back to the info dict only for a field the probe
couldn't read. This is the same ffprobe the upload ingest already used, now
extracted to a shared downloads/probe.py so the download worker and the
upload path share one classifier (and can't drift). Also backfills the
stored vcodec/acodec/width/height from the real file.

Tests: TestResolveMediaFacts pins probe-wins-over-info (incl. the Facebook
no-codec case and yt-dlp's literal "none"); upload's probe tests repoint to
the shared module.
2026-08-30 03:32:14 +02:00
peter a84f2db310 release: 0.78.0 (feature/e6-plex-player-polish)
gate / frontend (push) Failing after 1m35s
gate / backend (push) Successful in 2m20s
v0.78.0
2026-08-30 02:26:25 +02:00
peter 872d91f540 release: 0.78.0 — Plex transcode playback + player polish (E6) 2026-08-30 02:25:20 +02:00
peter 4eaa68ceeb feat(plex): S1a — add intel-media-va-driver for VAAPI transcode (E6)
The runtime image ships ffmpeg with h264_vaapi/libva2 but no VA driver, so
h264_vaapi can't initialise and a Plex transcode silently falls back to
libx264. Add the iHD driver (intel-media-va-driver) so the UHD 630 iGPU can
hardware-encode once /dev/dri is passed into LXC 118 (host + compose side,
tracked in siftlode-ops). Harmless where no render node is present.
2026-08-30 02:14:06 +02:00
peter 00539a4d97 fix(plex): deep-review follow-ups on E6 transcode + player polish
Eight verified findings from the sprint deep-review:

- Transcode cap: _enforce_cap now counts only transcode sessions, so a
  live remux (stream-copy) stream can no longer be LRU-evicted mid-play —
  matching the config comment + the HU/EN Config hint that promise remux
  is uncapped.
- Player Back: the browser/mouse Back interceptor (BackClose) now also
  mounts for the stats overlay and up-next card, so Back closes them first
  instead of ejecting the whole player (keyboard/Esc already did).
- Honest cold-start: loadSession clears sessionIsTranscode up front, so a
  transcode->direct/remux switch no longer flashes a stale "Preparing
  stream..." during the session request.
- Stats overlay a11y: the close (X) buttons in the stats, help and info
  overlays now read as "Close"/"Bezaras" (new plex.player.close key), not
  "Back"; dropped aria-live from the per-second stat grid so a screen
  reader is not flooded with telemetry.

Tests: route-level transcode routing (transcode item -> HLS re-encode
session, remux stays copy, direct opens none); start_session threading the
preset/crf/max_height knobs + hw/preexec into the ffmpeg argv; UpNextCard
countdown auto-advance, Play-now, Cancel and the S?-E? heading fallback.

Also brings UpNextCard.tsx / plexStats.ts / plexStats.test.ts to prettier.
2026-08-30 02:04:17 +02:00
peter dc24d353e0 feat(plex): honest transcode cold-start + encoder in stats (E6 S1d)
Surface the transcode session's runtime facts in the player. The session
response's transcode/hw flags now thread through: the stats overlay's mode row
reads "transcode · GPU" (VAAPI) or "transcode · CPU" (libx264) — the at-a-glance
confirmation of which S1 path engaged — and the initial-load overlay says
"Preparing stream…" instead of "Loading…" for a transcode session, since its
first segment is genuinely slower than a remux and shouldn't read as a hang.

fmtMode (pure, unit-tested) appends the encoder only for a transcode with a known
encoder. i18n HU/EN for the new preparing label. Browser-UAT'd on the XviD
fixture: mode row reads "transcode · CPU" live (software, no render node here).
2026-08-30 01:30:53 +02:00
peter ce96032edb feat(plex): transcode resource discipline + admin knobs (E6 S1c)
The software-fallback re-encode now runs deprioritized and admin-configurable.
The SW transcode ffmpeg child is niced (preexec_fn → os.nice, applied per-child
because the Popen lives in the long-lived API process, which must NOT itself be
niced — unlike the download worker's whole-process _deprioritize) so a CPU-bound
re-encode can't starve request handling or playback; the VAAPI path needs none of
it.

New sysconfig knobs (plex group, rendered on the admin Configuration page, i18n
HU/EN): plex_transcode_hw (auto/on/off, layered onto _want_hw), plex_transcode_
preset, plex_transcode_crf, plex_transcode_max_height (software-only downscale-to
cap; the GPU path encodes at source resolution). plex_max_transcodes stays the
concurrency cap (comment corrected — the fallback is shipped, not "later").

pytest extends the command-builder + _want_hw coverage (preset/crf/max_height
plumbing, the on/off/auto policy). Browser-UAT'd: max_height=240 drops the live
resolution to 330x240; the SW ffmpeg child runs at nice 10 (ppid=api process).
2026-08-30 01:22:30 +02:00
peter 0658f9550b feat(plex): transcode playback mode — VAAPI/libx264 re-encode (E6 S1b)
Non-h264 items (playable="transcode": HEVC/VP9/AV1/MPEG-2/XviD…) now play by
re-encoding the video to H.264 inside the existing HLS session, instead of the
old HTTP 501. VAAPI hardware encode (h264_vaapi, full GPU decode→scale→encode
pipeline) where the render node is present and the source is HW-decodable;
libx264 software fallback otherwise (yuv420p 8-bit, -threads reserve-one-core).

The re-encode mints a keyframe at every segment boundary (-force_key_frames) so
the HLS muxer still cuts independent segments and seeking works; everything else
— -copyts, the HLS tail, multi-audio renditions, the AAC audio path — is the
same machinery as remux, so hls.js/subtitles/seeking are unchanged. The session
route returns transcode/hw so the player can surface the active mode.

pytest pins both command branches (software + VAAPI shape) and the HW-selection
gate (render node present AND source HW-decodable). Browser-UAT'd on the XviD
fixture (Winning London): 501→plays, real 528×384 H.264 frames, 0 dropped, seek
lands clean; stats overlay Mode row reads "transcode".
2026-08-30 01:06:08 +02:00
peter f162788135 feat(plex): up-next end-card at episode end (E6 S4)
At episode end, when a next item exists and the pref is on, show an up-next
card (poster + title + ~10s countdown + Cancel) instead of the old instant jump,
so binge stays in the viewer's control (D6-A). Countdown or Play now advances;
Cancel/✕ dismisses and stays on the finished frame. Lives only on the
play-to-the-end path — credits-auto-skip advances earlier and never reaches the
card, so the two reconcile by construction (no double-advance). New pref
'upNext' (default on) with a Playback-tab toggle; the card closes on item change
and joins the Back/Escape cascade. i18n HU/EN.
2026-08-30 00:28:50 +02:00
peter 968674bc6a feat(plex): stats-for-nerds debug overlay (E6 S2)
Add a toggleable stats overlay to the Plex player: active mode
(direct/remux/transcode), transport, resolution, codec, bitrate, HLS level,
dropped frames, buffer-ahead, session offset and position — sampled once a
second off the live videoRef + hlsRef (no data layer). Non-modal corner panel,
aria-live polite, dismissable; toggled with 'd', a gauge control-bar button, or
its X. Number-crunching lives in a pure, unit-tested plexStats module. Doubles
as the S1 transcode-verification surface. i18n HU/EN.
2026-08-30 00:17:34 +02:00
peter 90ae9f6e88 feat(plex): on-screen ±10s skip buttons flanking play/pause (E6 S3)
Add discoverable rewind-10 / forward-10 controls to the Plex player control
bar, flanking play/pause (D5-A). New Skip10 glyph: an outline circular arrow
with a centred '10', deliberately distinct from the episode-level
SkipBack/SkipForward and from Restart's plain RotateCcw. Reuses seekTo; mirrors
the keyboard ←/→ (seekStep=10). i18n HU/EN aria-labels.
2026-08-30 00:09:36 +02:00
peter 3682e6a01f release: 0.76.0 (feature/e7-downloads-media-library)
gate / frontend (push) Failing after 1m36s
gate / backend (push) Successful in 2m26s
v0.76.0
2026-08-29 21:54:19 +02:00
peter aca355f43b chore(release): 0.76.0 — E7 Downloads & Media Library
Bundles the whole E7 epic (library metadata, crop fix, share-by-default, in-app player,
browser-playable guarantee, local upload) plus the deep-review hardening. Bump VERSION and
add the 0.76.0 release-notes entry (RELEASE_NOTES[0] must match VERSION).
2026-08-29 21:52:21 +02:00
peter f41c81eb56 fix(downloads): media-library UI review fixes (E7)
Deep-review remediation on the frontend, all findings adversarially verified:

- LibraryPlayer picks audio vs video from the canonical media_kind (single source), not a
  hardcoded container whitelist, so an uploaded audio file with an uncommon/absent container
  renders in <audio> instead of a broken video box (legacy null media_kind keeps the heuristic).
- UploadDialog aborts the in-flight upload when the dialog is closed by X/ESC/backdrop/Back
  (not only the footer Cancel); drag highlight uses an enter/leave depth counter to stop the
  flicker; the progress bar gets a stable role=progressbar aria-label with the value on aria-valuenow.
- DownloadCenter's Shared tab renders honest loading/error states instead of a blank panel, and
  the library/shared row-action gating moves to a single tested helper (lib/downloadActions).

Tests: downloadActions gating matrix, the uploadReq XHR path (abort/401/422-localize/onerror),
and the media_kind-driven audio/video branch.

i18n: downloads.upload.uploadProgress (HU + EN).
2026-08-29 21:52:13 +02:00
peter 614c5528ad fix(downloads): harden upload serving + ingest against stored-XSS and leaks (E7 review)
Deep-review remediation for the E7 upload path, all findings adversarially verified:

- Serve every library file with a Content-Type chosen from the ffprobe-verified media_kind
  + a strict container allow-list (storage.served_media_type), never guessed from the
  user-controlled file extension. Closes a stored-XSS vector where a media/HTML polyglot
  uploaded as "evil.html" was served inline as text/html on the login-free /watch surface.
  Applied to both the authenticated /file and the public watch_file endpoints.
- Classify uploaded still images (jpg/png/gif/webp/…) as media_kind='file' so they are
  force-downloaded and don't get a broken in-app video player.
- Roll back the on-disk side of an upload when the ingest transaction fails after place_file
  (unlink the placed file + its deterministic poster path), so a failed commit can't orphan
  bytes in the GC-skipped .uploads tree.
- Floor download_upload_max_bytes at min=1 so a stored 0 can't silently disable the per-file cap.
- Dedupe the ffprobe-JSON invocation into edit.run_ffprobe_json (shared by probe_codecs and
  upload._probe) and the upload extension into storage._clean_upload_ext; drop the now-unused
  parse_probe_streams.

Tests: public + authenticated force-download and the polyglot-not-served-as-HTML guard, the
real _probe classifier (image/cover-art/dims/duration), the ingest rollback/cleanup path, the
max_jobs branch, the cap-floor invariant, and run_ffprobe_json error handling.
2026-08-29 21:52:02 +02:00
peter 104bd1e3a6 feat(downloads): public generic-file watch surface (E7 S5d)
The /watch/{token} SPA gets a third branch: media_kind==='file' renders an
info card (name / TYPE · size) + a Download button, no player. The <video>/
<audio> branches now guard on media_kind!=='file' so legacy links
(media_kind null) still play. Download is gated on the link's allow_download,
mirroring the media branch; the backend serves a file force-download
(attachment + octet-stream) regardless, so the gate only reflects owner ACL.

FE-only (public_meta already emits media_kind/size_bytes/container since S5a).
i18n HU/EN/DE 'file' fallback label. Two DOM tests: file -> info card +
Download, no player; view-only file -> no Download.
2026-08-29 19:51:58 +02:00
peter 584e9ced0c feat(downloads): local-upload UI — drag-drop dialog + progress (E7 S5c)
UploadDialog (drag-drop + native picker + optional display name) opened
from a new Upload button in the queue add-row. Uploads stream through a
new XHR helper (uploadReq in api/core) so the dialog shows real 0-100%
progress with a cancel — fetch/req can't report upload progress. The
upload lands as an already-done job; the S5b file-kind row gates it to
download+share. i18n HU/EN; upload cap/empty errors localize via the
shared structured-detail path.
2026-08-29 18:53:53 +02:00
peter b4ad59274b feat(downloads): gate library row actions by media_kind (E7 S5b)
Surface the S5a media_kind discriminator in the library UI. A generic
uploaded file (media_kind='file') is download+share only — no player and
no timeline to trim, so Play/Edit/Edit-details are media-only; it shows a
neutral file glyph instead of an (always-empty) thumbnail and no duration
chip (container/size still read). The "Shared with me" tab's file row is
gated the same way. Re-download is now hidden for edit clips and uploads
too (neither has a re-fetchable source — the backend already rejects both
with 409), removing a latent click-to-409 on edit rows.

FE-only: the serializer already emits media_kind (S5a). Adds media_kind to
the DownloadAsset type and "upload" to the job_kind union; DOM tests cover
the file-row presentation (glyph, no img, no duration).
2026-08-29 18:38:21 +02:00
peter fe0b34cd00 feat(downloads): local file upload ingest — media library backend (E7 S5a)
Add the non-yt-dlp ingest path so a user-uploaded file joins the same download
library and inherits its function set. Any file type is accepted: media
(video/audio) gets the full set (metadata, share, play/edit), a generic file
(pdf/zip/docx/…) is download-only.

- MediaAsset.media_kind ("video"|"audio"|"file") — the stored discriminator that
  gates library actions. Set at every finalize (download probe, editor carries the
  source's kind, upload probe); consolidates the ad-hoc audio-detect that lived only
  in links.py into one invariant (downloads.mediakind.derive). Migration 0076 adds
  the column + backfills existing rows.
- downloads/upload.py: ingest_upload probes the file (ffprobe → codecs/dims/duration),
  places it in a per-user .uploads tree, and creates an already-ready asset + done job
  (the same cache-hit shortcut enqueue uses). Uploads never expire (irreplaceable).
- POST /api/downloads/upload: streaming multipart with the per-file cap
  (download_upload_max_bytes, default 500 MiB) enforced on actual bytes read; quota
  charges the known size up front (quota.check_upload).
- Security: a generic file is always served force-download (attachment +
  application/octet-stream) on both the in-app and public endpoints, so uploaded
  HTML/SVG can't execute inline. Edit + redownload reject non-media / upload sources.
- python-multipart added (first multipart endpoint in the app).
2026-08-29 08:02:11 +02:00
peter 9eaa01617c feat(downloads): in-app player for library items (E7 S4b)
Play a completed library item in-app: a Play button on Library rows (and
Shared rows with download access) opens a modal that streams the local file
into a native <video controls> (or <audio> for audio-only assets).

Rather than the YouTube IFrame PlayerModal or the Plex HLS PlexPlayer — both
bound to their own domain APIs and unable to point at a local file — this
reuses the shared Modal a11y shell (focus-trap, ESC, layer-stack, portal) and
the range-aware /api/downloads/{id}/file endpoint. S4a's universal-codec
guarantee (mp4/H.264/AAC + m4a/mp3) makes native playback always work, so seek,
volume, fullscreen and speed come for free with zero transcode.
2026-08-29 07:18:58 +02:00
peter fdca12968b feat(downloads): one-click Share + downloadable-by-default links (E7 S3)
D2-A: the library row's Share button becomes a single action — quickShareLink()
ensures a public link (minting on demand), copies it to the clipboard, and toasts
with an "Open in new tab" action. A copy failure still toasts (warning) with that
action so the link is never a dead end. Advanced controls (password/expiry/revoke/
user-ACL) stay in the existing ShareDialog, reached via a new "Manage sharing" row
button.

D1 (forward-only): CreateLinkIn.allow_download defaults to true, so a newly minted
link is downloadable by default. The default lives in the API, not a migration —
the download_links column default stays false, so no existing link flips. The
ShareDialog create-default is flipped to match.

Tests: TestCreateLinkDefault (default true; explicit false honored); 4 vitest cases
for quickShareLink (mint-vs-reuse, copy ok/fail, create failure). HU/EN parity.
2026-08-29 06:36:07 +02:00
peter 94fcc40bde feat(downloads): guarantee every download is browser-playable (mp4/H.264/AAC)
The Download module's purpose is download -> share -> play in-browser, so the
output must play in EVERY browser (iOS/Safari/WebKit included). The only
universally-safe combo is mp4 + H.264 + AAC/MP3.

- formats.normalize() now CLAMPS every spec to that profile (container->mp4,
  vcodec->None, audio_format opus->m4a) at the single point all paths flow
  through (picker, stored profile, raw API) — not just by hiding UI options. A
  source exposing no H.264 is still caught by the worker's browser-safe
  re-encode, so playability is guaranteed end-to-end.
- build_ydl_opts drops the now-unreachable non-compat branches (+ _VCODEC_SORT).
- _SIG_VERSION 3->4 so assets cached under an old non-compat spec — or a stale
  non-H.264 mp4 that slipped the v3 net (the HEVC Facebook dls) — re-derive.
- ProfileEditor drops the container/codec pickers and Opus; audio is M4A/MP3.
- i18n: remove the now-unused container/vcodec/any keys (HU+EN parity).
2026-08-29 06:08:21 +02:00
peter fb2c843fa9 fix(downloads): anchor crop overlay to the letterboxed picture (E7 S2)
The video preview is object-fit: contain (the browser default for <video>), so a
non-16:9 source is letter/pillarboxed inside the element box. The crop marquee was
drawn in fractions of the whole box (bars included) and those fractions were then
multiplied by the source resolution, so the emitted ffmpeg crop diverged from what
the selector showed. Anchor the overlay to the computed content rect (and set
object-contain explicitly) so a marquee fraction is a fraction of the real picture;
extract the display-px<->source-px math into cropGeometry.ts with unit tests, and
clamp the even-rounded window in-bounds so a full-frame crop can't overflow.
2026-08-29 05:43:41 +02:00
peter bb6cb6e335 style(downloads): bump library-row meta text + action buttons a step (E7 S1)
Enlarge every non-title text on the card (channel, status/format/size/date meta,
source-ref line, progress readout) one step (xs→sm) plus their inline icons, and
grow the row action buttons (padding + icon) via the actions wrapper so the shared
IconBtn stays smaller in the modals. Buttons keep the title's line — the wrapper is
shrink-0/no-wrap, so the text column shrinks instead.
2026-08-29 05:29:19 +02:00
peter bdbff89507 feat(downloads): show container/format chip in library rows (E7 S1)
The completed download rows already surfaced size + duration; add the concrete
container (MP4/M4A/WebM…) as a compact chip so the library reads as a media list
with a visible format — the audio-vs-video distinction the running-only phase pill
can't show at rest. Pure display of the already-serialized asset.container; locked
by a small DOM test (present when ready, absent before the worker fills it).
2026-08-29 05:21:54 +02:00
peter 9b87838e3a release: 0.75.0 (improvement/quickfix-batch-2)
gate / frontend (push) Failing after 1m34s
gate / backend (push) Successful in 2m16s
v0.75.0
2026-08-29 03:55:15 +02:00
peter f014416e31 chore(release): 0.75.0 — quickfix batch #2 (Plex/downloads/playlists) 2026-08-29 03:54:08 +02:00
peter 3369587412 fix(review): resolve deep-review findings across quickfix batch #2
- Button: `loading` always disables (|| not ??) — stops a double-submit
  when a call-site passes an explicit boolean `disabled` (VideoEditor).
- playlists: track un-ingestable-but-live YouTube ids in the sync baseline
  so a linked playlist containing a private/deleted video converges instead
  of re-classifying it as a pending pull forever; re-attempt ingestion each
  sync so a restored (private->public) video heals, and prune ids that
  leave YouTube.
- SettingsPanel: download-auth section renders an honest error/loading
  state (StateMessage + retry) instead of a bare empty section on a failed
  credentials fetch; HU+EN loadError string.
- tests: reorder-order, partial-failure baseline hold, un-ingestable
  converge/heal/prune, Button loading-disables, download-auth error/retry.
2026-08-29 03:52:13 +02:00
peter 9af4e8f4ff feat(playlists): sentence-style two-way sync preview (only changed sides)
The confirm dialog spelled out both sides even when one was "+0 / −0", which read
as engineer shorthand. Build the message from one sentence per side that actually
changes and add a short "stays unchanged" line for the untouched side; the zero
side is no longer shown as a bare +0/−0 to decode. Assembled in the component
from granular i18n clauses (add/remove per side, reorder, rename) with count
plurals; HU+EN. Replaces the single pushPlanMerge template.
2026-08-28 05:20:34 +02:00
peter a0b725d779 feat(playlists): two-way (3-way) merge YouTube sync + mandatory preview (S7)
"Sync to YouTube" was a one-directional destructive push (local wins, deletes
from YouTube every item not in the local list), so items added on YouTube were
lost and a dirty pull was skipped entirely. Replace it with a real 3-way merge:
each item is classified against the last-synced baseline as a local-add, a
YouTube-add, or a genuine delete, so an add or delete on either side survives.

- Persist the ordered baseline id set (new playlists.synced_video_ids, migration
  0075); the fingerprint hash alone couldn't drive a merge. NULL baseline (fresh
  export / pre-S7 link) unions losslessly on the first sync and records one.
- Pure merge_plan(baseline, local, youtube) -> add/remove per side + merged order
  (local order, YouTube-only additions appended). merge_playlist applies the
  local side directly and the YouTube side through the failure breaker; the
  baseline only advances on full success so a partial failure re-converges.
- plan_merge/push-plan now report what each side gains and loses; the confirm
  dialog shows the two-way plan before applying (pushPlanMerge, HU+EN).
- Tests: 10 new (pure classification incl. delete-vs-stale-copy; DB apply proving
  a YouTube-add and a Siftlode-add both survive on both sides, a genuine delete
  propagates without collateral loss, no-baseline never deletes, create path).
2026-08-28 04:20:29 +02:00
peter bc33b2ed16 feat(downloads): per-user, per-host yt-dlp cookie/auth support (S6)
Each user stores their own login cookies (Netscape cookies.txt) for an
auth-walled download host (Facebook, Instagram, X, TikTok, Reddit) in
Settings; a download of an auth-walled link on that host is retried with
the acting user's cookie.

Two load-bearing security controls, both in downloads.credentials as the
single source of truth so enqueue and the worker can't drift:
- cache privacy: a cookie-authed download salts its MediaAsset format_sig
  with the user id, so the authed asset is private and never cache-served
  to another user. The worker attaches a cookie ONLY when the asset it is
  downloading into IS that private, salted identity.
- host scope (SEC-12): a cookie is resolved only for a URL whose netloc
  the credential owns (netloc-scoped _host_is); a look-alike/unrelated
  host gets none.

cookies_enc is Fernet-encrypted at rest, only decrypted in-worker into a
0600 file under staging/.auth/ (survives the retry wipe + _produced_file
scan) and unlinked in finally. The blob is never returned by a route,
logged, audited, or written to a job's error/snapshot. cookiefile is set
after the adapter's extra merge so an adapter can't clobber it. Settings
UI is write-only (host + set/updated only, never echoed). New
download_credentials table + migration 0074; per-user routes; HU+EN i18n.

Tests: host-scope, cache-privacy (no cross-user serve), encrypt-at-rest,
write-only API, worker attach-gate; FE dom test for the write-only field.
2026-08-28 03:53:04 +02:00
peter 5280949667 feat(ui): migrate the canonical action-button surface onto <Button>
Sweep the rounded-lg solid/ghost/outline action-button family onto the shared primitive:
ConfirmProvider, ErrorDialog, DownloadDialog, DownloadCenter, downloadModals, downloadAdmin,
ProfileEditor, PersonalViewsModal, SettingsPanel, WatchPage, DraftSaveBar. Deliberately left
on their own styles per the agreed scope: login/onboarding rounded-xl CTAs (Welcome/Onboarding/
Setup/KeyGate), glass Plex surfaces, segmented pills/toggles/switches/chips, dropdown triggers,
data-driven Banner/tone maps, text-xs dense buttons, and tiny inline p-1/p-1.5 icon controls —
none map 1:1 to a variant+size without a visual change, and the repo has no tailwind-merge.
2026-08-28 03:12:29 +02:00
peter 4892c38466 feat(ui): shared <Button> primitive + migrate ShareDialog/VideoEditor/ChannelPage proofs
Introduce frontend/src/components/ui/Button.tsx — the one action-button primitive
(variants primary|danger|outline|ghost|glass|danger-outline; sizes xs|sm|md|lg|icon;
icon/iconOnly/label/loading/fullWidth/forwardRef). No focus-visible (already centralized
in index.css @layer base). Migrate the cleanest proof sites off btnCls, and flip the
channel Subscribe button to the outline variant so it matches Full history (the user's ask).
2026-08-28 02:59:21 +02:00
peter 9ae6c47d6c perf(downloads): de-prioritize worker transcodes + idle-back-off polls to stop LAN slowdown
The download/edit child (yt-dlp + ffmpeg) shares one LAN box with the API,
Postgres, and Plex playback. A CPU re-encode pegged every core for ~30s and
starved web requests + playback until it finished. Full-scope S4 mitigations:

- Child self-de-prioritizes at startup (inherited by every ffmpeg/yt-dlp
  descendant): CPU nice increment + best-effort idle ionice class + an
  OMP_NUM_THREADS budget, all config-gated and independently disable-able.
- Cap the browser-safe re-encode's encoder threads (`-threads`) so a transcode
  reserves a core for foreground work instead of claiming all of them.
- Throttle the retention GC's whole-tree orphan sweep (yield IO after each
  batch of files) so its rglob can't monopolize disk IO in the API process.
- Idle-back-off the Download Center (2s→15s when nothing queued/running) and
  UsageBar (2s→15s when running_jobs is 0) polls; both snap back to 2s on the
  next fetch after an enqueue (invalidateDownloads refetches immediately).

Tests: encode_thread_cap (pin/reserve-core/edge), -threads flag presence,
_deprioritize (nice/ionice/OMP branches), GC sweep throttle batching.
2026-08-28 02:37:56 +02:00
peter b7a6451dba fix(downloads): iPad audio playback + honest non-retryable failure for auth-walled hosts
5a — the public watch page rendered every asset in a <video>, but WebKit (all
iOS browsers) refuses to play an audio-only mp3 in <video>, so shared audio
links were broken on iPad while working on desktop Chromium. public_meta now
flags audio-only assets (codec present, no video stream) and WatchPage renders
a native <audio> in a padded surface card (no artwork exists for these).

5c — a login-walled Facebook post failed as a bare 'unknown', which offered a
misleading Retry. Classify closed-group / cookie-suggestion auth walls as
login_required and genuinely unsupported sites as a new unsupported_host class
(both non-retryable), and drop unknown from the modal's retryable set — an
unclassified failure is most often an unparsed auth wall. The queue row keeps
its own force-retry for real transients.
2026-08-28 02:22:28 +02:00
peter 11e32163b8 fix(ui): SortControl direction toggle no longer wraps on narrow popovers
The asc/desc buttons in the sort popover footer share one component across
Feed and Plex browse, but rendered differently: the Plex popover is narrower
(short option labels), so "Descending" wrapped icon-over-text and made the
active button taller. Add whitespace-nowrap to the direction buttons and bump
the popover min-width (min-w-52 -> min-w-56) so both call-sites render an
identical single-line toggle.
2026-08-28 02:06:06 +02:00
peter 257dc5741d fix(plex): resolve movies stuck "in progress" on the 15-min incremental sync
The incremental watch-sync (run_plex_watch_sync) learned a movie's watched
transition only from the history feed + onDeck. When a movie is finished on
Plex (commonly by scrubbing through the end credits) Plex sets viewCount>0,
clears viewOffset and DROPS it from Continue-Watching, so onDeck no longer
lists it (and onDeck can only ever ADD an in-progress row, never resolve one),
while a scrub-to-end frequently emits no history session row. The stale local
status='new', position>0 row then survived until the DAILY full reconcile —
which is why movies kept showing "in progress".

Add _reconcile_inprogress_movies: for each local in-progress MOVIE that Plex's
onDeck no longer lists, read its current per-account viewCount/viewOffset with
one metadata() call and apply it via the shared _pull_apply, flipping to
watched when Plex says so. Bounded by a probe cap (100), most-recently-touched
first; episodes are left to onDeck/history. No-signal rows are preserved (an
un-watch stays the daily reconcile's job).

Tests: watched-flip, onDeck+episode skip, and no-signal-preserved.
2026-08-28 01:34:02 +02:00
peter f52bc5898b release: 0.74.0 (improvement/quickfix-batch)
gate / frontend (push) Failing after 1m28s
gate / backend (push) Successful in 2m15s
v0.74.0
2026-08-28 00:05:21 +02:00
peter d0033617e2 chore(release): 0.74.0 — quickfix batch (Plex sync/browse + Channel Manager) 2026-08-28 00:03:13 +02:00
peter f80ed115ad fix(plex,channels): deep-review fixes — durable watch-sync + no shared-link clobber
Deep-review of the quickfix batch surfaced two data/preference-loss bugs plus
smaller items; all fixed with tests.

- watch_sync _pull_apply: don't let the Plex-authoritative pull overwrite a DIRTY
  local row (a change whose immediate push failed) — that stranded the value
  forever since _repush_dirty only retries dirty rows. Leave it dirty for re-push.
- PlexProvider / ChannelsProvider: a partial shared link opened in a fresh tab
  persisted mount-time DEFAULTS over the account's stored scope/sort/filters
  (base read before the account was pinned). Persist/keep only URL-present dims;
  re-hydrate the omitted ones from storage.
- playback resume write-back: guard against out-of-order delivery (a superseded
  older push now drops) and evict the throttle map so it can't grow unbounded.
- PlexPlayer: announce mid-playback buffering to assistive tech (role=status +
  sr-only label); new plex.player.buffering (HU+EN).
- columnSort: hoist the shared key:dir sort codec used by both URL-state modules.
- Tests: dirty-guard + flag-settle + per-key throttle scoping + superseded-push
  (backend); hold-repeat/suppress-click/unmount + partial-link no-clobber +
  refetch-doesn't-clobber-undo (frontend). Backend pytest 475, FE vitest 519.
2026-08-27 23:54:44 +02:00
peter 7fbb996297 feat(channels): shareable manager view in the URL — tab+sort+column-filters (S8)
The Channels-manager content state now round-trips through the address bar,
so a manager view is bookmarkable/shareable — the S4 pattern applied to the
last content page the audit flagged.

- New lib/channelsUrlState.ts (channelsParamUpdates / channelsParamsToState /
  hasChannelsParams) under NAMESPACED keys ctab/csort/cf — channels-unique
  like ?v=/?pl/pscope, so no other provider reads or clears them and the
  feed's URL-stripping can't delete them. The mixed scalar/array FilterMap
  (sync/priority scalars + tags multi) rides in `cf` as a JSON blob so both
  shapes round-trip exactly. Only non-default values emit.
- ChannelsProvider mirrors {view, sort, filters} via useUrlMirror, page-gated
  on channels (off-page the keys clear; localStorage still holds the state and
  it re-mirrors on return). A shared link wins over stored state: readChannels
  UrlSeed captured once, applied before paint through the slice setters (so the
  account-pin re-hydrate sees them dirty and won't reload storage over the
  shared view), then persisted to the account once pinned so it survives a
  later plain reload — mirrors PlexProvider's seed/rehydrate. The name search
  stays ephemeral (out of the shared contract).
- NavigationProvider's page-switch nulls ctab/csort/cf alongside the plex keys
  so switching modules resets to the manager root.

Tests: channelsUrlState.dom.test.ts (9 round-trip) + ChannelsProvider.dom
seed/mirror (2); the existing tab-history test's beforeEach now resets the
query string too (the mirror writes it). FE vitest 503→514. FE-only.
2026-08-27 04:37:53 +02:00