Commit Graph
1324 Commits
Author SHA1 Message Date
peter 7928d81116 release: 0.63.1 (improvement/source-adapters) 2026-08-13 01:21:50 +02:00
peter ee8aa72abe release: 0.63.1 — SourceAdapter refactor + sidecar/nfo fixes 2026-08-13 01:21:22 +02:00
peter 2cd02eb1af fix(downloads): netloc-scoped adapter host match (review round 2)
resolve_adapter matched sources by loose substring over the netloc, so a
look-alike host ('myyoutube.com', 'youtube.com.evil') misresolved. Match the
host exactly or as a subdomain via a _host_is() helper, and identify from the
netloc of BOTH source_ref and yt-dlp's webpage_url — the latter by host only,
never a substring over its path. This also restores a host signal to the
early-fill path (resolve_adapter(None, None, info)), which has no source_ref,
so a Facebook asset's uploader_url no longer depends on extractor_key being
present in the progress-hook info_dict.

Tests: look-alike hosts stay generic while real subdomains resolve; a
webpage_url netloc identifies the source when extractor_key is absent.
2026-08-13 01:18:25 +02:00
peter 883e8515b9 fix(downloads): tighten SourceAdapter resolution + neutral nfo default (review)
- resolve_adapter: identify from the yt-dlp extractor_key only, not the full
  webpage_url (a sibling source's name in a URL path no longer misresolves);
  add youtube-nocookie.com to the YouTube host match so nocookie embeds keep
  the POT/player-client extraction strategy.
- _download: re-resolve the adapter with the post-extraction info dict so the
  .nfo uniqueid type and uploader_url agree on one source (dedups the inline
  resolve too).
- MediaMeta.uniqueid_type defaults to source-neutral "generic" instead of
  "youtube", so an un-updated caller can never falsely label a non-YouTube
  download as youtube.
- Tests: lock extractor-key-only resolution + nocookie host; default nfo type.
2026-08-13 01:09:38 +02:00
peter d4dca46d9f fix(downloads): keep sidecar basenames intact when the title contains a dot
write_sidecars / delete_asset_files computed `base = media.with_suffix("")` and then
`base.with_suffix(".nfo")`. Stripping the real `.mp4` first leaves a stem whose own last dot may
be inside the title (e.g. "Sonka Házilag."), which `with_suffix` then treats as the extension and
clobbers — writing the .nfo/.jpg under a TRUNCATED basename that no longer matches the media, so
Plex/Kodi never associate them (and delete_asset_files targets the wrong name, leaking orphans).
Swap the extension on the FULL media path via a shared `_sidecar` helper instead.

Pre-existing bug (predates the source-adapters work); found during its E2E and folded in here.
Verified end-to-end: a dotted-title Facebook reel now writes a single shared basename.
2026-08-13 00:47:51 +02:00
peter 8e4dee1bb6 refactor(downloads): introduce SourceAdapter — a thin per-source Strategy over yt-dlp
Consolidates source-specific policy (youtube / facebook / reddit / generic) that was scattered
across worker.py, backfill.py, service.py, formats.py and storage.py with no shared source
identity. app/downloads/adapters.py holds the base (= generic yt-dlp behaviour) + per-source
overrides; resolve_adapter(source_kind, source_ref, info) picks one by source_kind, the yt-dlp
extractor_key, or the URL host, falling back to generic. yt-dlp stays the engine — the adapter is
policy only.

Moved into adapters (behaviour-equivalent):
- uploader_url derivation (YouTube channel_url / Facebook uploader_id / reddit None) — removes the
  worker/backfill duplication (build_uploader_url + _should_refetch gone).
- the player-client fallback sets + retriable markers + YouTube extractor_args/POT (build_ydl_opts
  now takes source-agnostic opts plus an `extra` dict from the adapter).
- source_url reconstruction (service.source_url delegates to the adapter).

Two intentional (approved) behaviour changes:
- the player-client fallback is now YouTube-only; non-YouTube sources make a single extraction
  attempt instead of a pointless double attempt with youtube-only client args.
- the .nfo <uniqueid type=...> follows the source (adapter.nfo_uniqueid_type) instead of the
  hardcoded "youtube" that mislabelled every url-source download.

Deliberately left generic (documented non-moves): title cleaning (normalize_title is harmless
across sources), the classify marker table, thumbnail_to_jpg, the format selectors, and og.py's
YouTube-hqdefault og:image preference (a self-contained thumbnail heuristic on the share path).

Verified end-to-end on localdev across all four adapters: downloads succeed and uploader_url + the
.nfo uniqueid type are correct per source.
2026-08-13 00:34:42 +02:00
peter 44efdf328d release: 0.63.0 (improvement/download-subprocess-watchdog) 2026-08-12 23:43:28 +02:00
peter 099d6d1301 chore: bump VERSION 0.63.0 + release notes (supervised download-subprocess watchdog) 2026-08-12 23:43:15 +02:00
peter cce862cde7 fix(downloads): supervisor edge-cases from review round 3
- worker.py: keep an absolute wall-clock backstop for ANY still-alive child. A child that
  reached a terminal state (done/error) but wedges in teardown is now killed to free the worker
  slot ("abandon"), with its DB status left intact; only a still-'running' over-cap job is failed.
  Previously gating the wall-clock on status=='running' let a wedged finisher loop forever (#1).
- worker.py: reconcile the claimed asset on ANY clean child exit (not only non-zero), so a child
  that exits without finalizing it — e.g. an early return on a job DELETEd mid-flight — no longer
  leaves the asset stuck 'downloading' until boot recovery (#2).
- worker.py: extract _stop_and_free + _fail_after_kill; the exception / stall / timeout / cancel /
  shutdown / abandon paths now share one kill+free (+fail) tail instead of three copies (#3).
- tests: a wedged-finished child is killed by the wall-clock without a status change; clean exits
  now assert the reconciliation no-op (#1, #4).
2026-08-12 23:38:37 +02:00
peter b42b340594 fix(downloads): don't let the supervisor kill a finishing child (review round 2)
- worker.py: the watchdog loop now treats only 'paused'/'cancelled' (or a vanished row) as a
  stop; a terminal status (done/error, or a re-queued contended asset) means the child already
  wrote its outcome and is exiting, so keep polling instead of killing it. Previously a
  proc.wait timeout landing between the child's terminal commit and its exit was misread as a
  route-cancel and SIGKILLed a successful download (dropping its completion notification). The
  stall/wall-clock/shutdown checks are now gated on status=='running' (#1, #5 test).
- worker.py: guard the crash-path _job_liveness probe so a DB blip there can't propagate (the
  child is already dead; boot recovery is the backstop) (#2).
- worker.py: drop _stop_child's redundant poll guard — escalate_kill no-ops on an exited proc
  and getpgid yields None for a reaped pid (#3).
- worker.py: note the extraction heartbeat is per-attempt (a single over-timeout attempt is
  still killed, bounded by socket_timeout) (#4).
2026-08-12 23:15:41 +02:00
peter 657224c1ad refactor(downloads): address /code-review on the download supervisor
- worker.py: wrap the watchdog loop so a failure in it (e.g. a transient DB error in
  _job_liveness) still hard-kills the child + fails the job, instead of leaving the child
  running unsupervised — the exact wedge the feature prevents (#1).
- worker.py: _touch_job heartbeat bumps updated_at at each extraction attempt, so a slow /
  multi-client-set fallback extraction (which writes no progress, and re-writes identical
  values so ORM onupdate does not fire) is not false-killed as "stalled" (#2).
- edit.py: extract escalate_kill (terminate->wait->kill escalation); _stop and worker's
  _stop_child (process-group variant) now share it (#3).
- worker.py: document the edit path's second, tighter run_ffmpeg watchdog and that the
  supervisor is POSIX/Linux-container-only (#4, #6).
- tests: a long download stays alive while progress is fresh, and a watchdog-loop error
  still stops the child (#1, #5).
2026-08-12 23:04:48 +02:00
peter 0ed82fe6b6 feat(downloads): run each download/edit in a supervised child process with a hard-kill watchdog
Defense-in-depth after the ReDoS incident. The heavy work (yt-dlp download / ffmpeg edit)
now runs in a child process (app.downloads.runner) supervised by the worker thread, not in
the thread itself. A thread can't be killed and a C-level spin can't even be cooperatively
cancelled, so one wedged download used to pin a core, hold the GIL and freeze the whole
queue; a child process can be SIGTERM/SIGKILLed.

The supervisor kills the child (and its process group, so ffmpeg grandchildren die too)
when: a route pauses/cancels the job, it makes no progress for download_stall_timeout_seconds
(150s), it runs past download_wall_clock_max_seconds (2h), the worker is shutting down, or
the child crashes without setting state. On a kill it frees the asset and fails the job with
a retryable network error (stalls are not auto-requeued). A clean exit is left alone — the
child already wrote the terminal state, including a cooperative pause via the progress hook.

- config.py: stall / wall-clock / supervisor-poll / kill-grace settings.
- worker.py: _run_supervised + _spawn_download_child / _job_liveness / _stop_child /
  _cleanup_staging; _process_job claims the asset, then hands the heavy work to the child.
- app/downloads/runner.py: the child entrypoint.
- docker-compose.localdev.yml: init:true on the worker so tini (PID 1) reaps orphaned
  ffmpeg grandchildren left by a hard-kill (the worker reaps its own direct children).
- tests: watchdog decision logic + SIGTERM->SIGKILL escalation (fake proc, no real subprocess).
2026-08-12 22:39:17 +02:00
peter e395fb816d release: 0.62.2 (bug/download-worker-redos-hotfix) 2026-08-12 18:50:02 +02:00
peter 4af919ac4d chore: bump VERSION 0.62.2 + release notes (download ReDoS + Reddit thumbnail hotfix) 2026-08-12 18:49:42 +02:00
peter fea6f55d4f refactor(downloads): address /code-review on the download hotfix
- edit.py: extract _run_ffmpeg_best_effort (one run+timeout+swallow policy shared by
  thumbnail_to_jpg / ensure_poster / ensure_storyboard) + a build_thumbnail_jpg_cmd
  builder, matching the module's other ffmpeg commands.
- thumbnail_to_jpg: copy an already-JPEG thumbnail (Facebook, and Reddit's jpeg-under-
  a-.png-name) instead of re-encoding it; only webp/png-proper are decoded via ffmpeg.
- errors.py: drop the redundant "account authentication" classify marker
  ("authentication is required" already covers the known Reddit message).
- formats.py: document that the opt-in EmbedThumbnail is unaffected by dropping the
  convertor (it converts unsupported thumbs to PNG, not the crashing mjpeg2jpeg path).
- test_titles.py: guard the ReDoS regression with a thread + timeout (deterministic
  "must not hang") instead of a wall-clock assertion.
2026-08-12 18:47:01 +02:00
peter 37cca723fc fix(downloads): stop a ReDoS title and a fatal thumbnail convert from wedging/killing downloads
Three prod-observed download bugs, root-caused from a frozen prod queue with the
worker pinned at 100% CPU:

- ReDoS in normalize_title: _TRAILING_HASHTAGS had two overlapping unbounded
  quantifiers around the required letter, so a Facebook title with a long hashtag
  cluster that is NOT at the end ("... #a #b #c | Page") backtracked catastrophically.
  It runs inside the yt-dlp progress hook holding the GIL, so one such download froze
  the whole worker (and could stall feed enrichment too - sync/videos.py calls it).
  Rewrite with a zero-width "has a letter" lookahead + a possessive tag body: one
  unambiguous split, nothing to backtrack. 40-tag stress case: ~0.1ms.

- Reddit download killed by a cosmetic thumbnail convert: yt-dlp's
  FFmpegThumbnailsConvertor runs "-bsf:v mjpeg2jpeg" on a JPEG-served-under-a-.png-name
  (Reddit) and fails; being a postprocessor, it failed the WHOLE download even though
  the media was fine. Drop it and produce the <base>.jpg poster/og:image ourselves via
  editmod.thumbnail_to_jpg (plain ffmpeg decode->mjpeg, best-effort -> ensure_poster
  cuts a frame on failure). The FB Messenger / MS Teams share preview keeps working
  (posters verified as real JPEGs).

- classify: map "authentication is required" (Reddit's auth wall) to login_required
  instead of unknown, so the UI explains it rather than offering a pointless retry.

Tests: ReDoS-timing + numeric-marker regression, the Reddit classify case, and
thumbnail_to_jpg (real JPEG out / None on bad input, never raises).
2026-08-12 18:27:35 +02:00
peter 6883fedc1b release: 0.62.1 (r12-plex-layering) 2026-08-12 02:01:04 +02:00
peter d9a12144dc chore: bump VERSION 0.62.1 + release notes (R12 S4a PlexPlayer refactor) 2026-08-12 01:58:48 +02:00
peter 4c3ae00492 refactor(plex): address /code-review on the S4a decomposition
Two behaviour-equivalent cleanups from the review:
- Drop the redundant per-render `audioRef.current = audioOrd` mirror in
  PlexPlayer: the only two setAudioOrd callers (usePlexTracks.changeAudio
  + the detail effect) already set audioRef synchronously, so the mirror
  never carried a value they hadn't written.
- Narrow the time-tracking effect deps from [id, patchPrefs] to [id];
  patchPrefs was a vestigial, unused dependency (eslint confirms [id] is
  the complete set).

The third finding (usePlayerVolume duplicating playerVolume.ts) is the
tracked R12 volume-consolidation sprint, not a review fix.
2026-08-12 01:52:33 +02:00
peter f86313eaa9 refactor(plex): extract PlexPlayer track selection into usePlexTracks
S4a-4a of the PlexPlayer decomposition. Move audio/subtitle track
selection + subtitle rendering into usePlexTracks: the selected-ordinal
state, change/cycle audio+subtitle, the debounced sub-offset, the audio
A/V-sync offset, and the two subtitle effects (force-showing the mounted
track + positioning cues at the user's subPos). The shared media refs
(video/hls/audio/aoff/abs/multiAudio) stay component-owned and are passed
in, since loadSession + the detail effect write audioRef too; absRef is
hoisted above the hook call for that. No behaviour change; verified by
self-E2E (subtitle on/off via 'S' mounts/removes the <track>, mode
'showing', VTT 804 cues all positioned to line=subPos, subLang persists;
playback uninterrupted). Audio switch is single-track here so cycleAudio
correctly no-ops.
2026-08-12 01:21:20 +02:00
peter 521d2aeaa8 refactor(plex): extract PlexPlayer auto-skip into useAutoSkip
S4a-3b of the PlexPlayer decomposition. Move the intro/credits auto-skip
machinery (activeMarker detection, the skipProgress countdown, doSkip,
cancelAutoSkip + the running-interval and reset-on-id effects) into
useAutoSkip. The hook returns skipProgressRef / cancelAutoSkipRef — the
forward-declaration bridge the earlier keyboard/back handlers read to
cancel a running countdown; those two stable refs are now listed in the
backIntent/keyboard dep arrays (they were provably-stable local refs
before, params now). No behaviour change; verified by self-E2E (credits
marker detected -> countdown fill renders + advances while playing ->
auto-skip fires -> item marked Watched + player closes).
2026-08-12 00:46:54 +02:00
peter 68f4834695 refactor(plex): extract PlexPlayer progress + volume hooks
S4a-3a of the PlexPlayer decomposition. Move two byte-equivalent
behaviours into lib/plexPlayerHooks.ts: usePlexProgress (the 10s
interval flush + pause/pagehide beacon + reopen cache-patch checkpoint;
absRef stays in the component since seek/skip/ended mutate it too) and
usePlayerVolume (HTML5 <video> 0..1 volume + independent mute, persisted
+ re-applied per session). usePlayerVolume is the seam the later R12
volume-consolidation sprint will rework to share playerVolume.ts's
tested pure core with the YouTube player. No behaviour change; verified
by self-E2E (volume-bar sets + persists video.volume, 'm' toggles mute
keeping the level, pause fires plexProgress 200).
2026-08-12 00:32:44 +02:00
peter c7a69596ad refactor(plex): extract PlexPlayer leaf hooks (clock/fullscreen/chrome)
S4a-2 of the PlexPlayer decomposition. Move three self-contained,
byte-equivalent behaviours into lib/plexPlayerHooks.ts: useWallClock
(15s top-bar clock), useFullscreen (wrapper fs toggle + reactive flag),
and useControlsVisibility (auto-hiding control bar + wake). The ref
mirrors the hooks read (barAutoHide/menuOpen/video-paused) stay in the
component and are passed in, so the timer sees fresh state. No behaviour
change; verified by self-E2E (real HLS playback: clock renders, controls
auto-hide after 3s while playing, wake restores + stays while paused).
2026-08-12 00:15:41 +02:00
peter a0e9912886 refactor(plex): extract PlexPlayer pure helpers + control primitives
S4a-1 of the PlexPlayer decomposition. Move the module-level pure
helpers (prefs types/defaults, track-language resolution, subtitle
shadow CSS, wall-clock formatting, HLS_SUB_LAG, fmt) to
lib/plexPlayerPrefs.ts and the presentational building blocks
(Ctrl/Slider/Toggle/PanelHead) to plexPlayerControls.tsx. No hooks or
behaviour touched; PlexPlayer.tsx 1889->1661.
2026-08-12 00:06:21 +02:00
peter dd2118a547 refactor: address /code-review — rename uploader_url() to build_uploader_url()
The R12 S3 underscore-publish left worker.uploader_url() sharing a name with the
`uploader_url` model attribute and the yt-dlp `"uploader_url"` info-dict key. The
function is now build_uploader_url() — the attribute, dict key, log line and
docstrings keep the plain `uploader_url` name. Rename touches only the def, its
two call sites, and the backfill import; no behaviour change (ruff clean,
pytest 193).
2026-08-11 23:50:49 +02:00
peter c74f69babc refactor(plex): dedupe show/season heroes into DetailHero + WatchedToggle
R12 S4b. PlexShowView and PlexSeasonView were copy-paste siblings — the same
glass hero frame, play/resume poster button, customize-menu slot, and the
mark-watched action button, duplicated verbatim. Two shared components now own
that structure:

- DetailHero: the hero shell (frame + poster button + customize slot + info
  column via children); the season page passes a smaller poster size.
- WatchedToggle: the mark-whole-show/season watched action (icon + label flip);
  the differing labels are props.

Single source of truth for the hero — a future hero change touches one place
instead of two. Gate green (tsc / eslint 0 errors / prettier / knip / vitest 79)
+ self-E2E: the show detail and season detail pages both render their hero,
metadata, actions and watched-toggle identically to before (screenshot-verified;
the stale [hmr] PlexBrowse errors in the console buffer are from an intermediate
edit — the fresh Vite server logs clean).
2026-08-11 23:38:57 +02:00
peter 69d34f7fa3 refactor(channels): move cells + column defs into channelColumns.tsx
R12 S4c. Channels.tsx shed its ~150-line column block and 3 cell components
into the co-located channelColumns.tsx:

- SyncBadge / SyncCell / ActionsCell move verbatim (prop-driven, self-contained).
- the column array becomes a buildChannelColumns(deps) factory next to
  subsColumn — the manager owns the state/mutations and passes them in; the
  inline patch.mutate wrappers become onHide/onDeep callbacks so the factory
  doesn't couple to the mutation type.

Channels.tsx 905 -> 628 lines; no behaviour change. Gate green (tsc / eslint 0
errors / prettier / knip / vitest 79) + self-E2E: the manager renders every
column and cell (priority stepper, actions, sync badges, tags) with no console
errors.
2026-08-11 23:27:16 +02:00
peter 51463b226e refactor: R12 S3 — invert scheduler->routes, publish cross-module privates
Layering cleanup, no behaviour change (ruff clean backend+scripts, pytest 193):

- Invert the scheduler->routes dependency: reset_demo_state (+ its demo-seed
  helpers) move from routes/admin.py to a new service module app/demo.py.
  Both routes/admin.py and scheduler.py now import it from app.demo, so the
  scheduler no longer reaches into the routes layer — its lazy import (the
  routes<->scheduler cycle workaround) is gone, hoisted to a top-level import.

- Publish the private symbols that were imported across module boundaries (the
  underscore convention was lying): feed._serialize -> serialize,
  feed._to_tsquery_str -> to_tsquery_str, videos._insert_stubs -> insert_stubs,
  videos._stub_from_playlist_item -> stub_from_playlist_item,
  worker._uploader_url -> uploader_url, plex._shared._item_or_404 ->
  item_or_404. Scoped renames — the 5 other route modules keep their own truly
  private _serialize helpers.

- _release_asset moves from routes/downloads.py to app/downloads/service.py as
  the public release_asset (asset bookkeeping is a service concern); the routes
  call it via service.release_asset.
2026-08-11 22:58:10 +02:00
peter ac6383e92c refactor(auth): address /code-review on the S2 split
Cleanup only, no behaviour change (ruff clean + pytest 193):
- keep the whole OAuth scope vocabulary together in app/auth.py: the bundles
  (BASE_SCOPES/READ_SCOPES/WRITE_SCOPES/_YOUTUBE_SCOPES) move back next to the
  atoms (READ_SCOPE/WRITE_SCOPE) as core; routes/auth.py imports the bundles it
  needs instead of rebuilding them from the atoms.
- compact the const groups the split generator spread out (the six rate
  limiters, the TTLs, the demo/scope constants) back into blocks.
2026-08-11 22:43:33 +02:00
peter 1b07d54663 refactor(auth): split app/auth.py — endpoints to app/routes/auth.py
R12 S2. app/auth.py (1154 lines) mixed the core auth surface with 18 HTTP
endpoints on one /auth router. Split by dependency graph:

- app/auth.py (223 lines) keeps ONLY the core surface other modules import
  (current_user, resolved_user_id, admin_user, require_*, has_*_scope,
  purge_user, count_admins, get_or_create_demo_user, google_enabled,
  is_allowed, operator_contact, optional_current_user) plus everything it
  transitively depends on.
- app/routes/auth.py (883 lines, new) gets the router + all 18 endpoints +
  their endpoint-only helpers, importing the core surface from app.auth.
- main.py includes auth_routes.router (was app.auth.router).
- test_oauth_redirect imports _oauth_redirect_uri from its new home.

The STAY/MOVE partition is driven by an AST scan of the COMPLETE external
surface (every `from app.auth import` incl. multiline + `from app import
auth; auth.X`), so no still-referenced symbol was moved. Behaviour-equivalent:
every symbol relocated verbatim (get_source_segment equality), route set
proven identical, no undefined names (symtable sweep).

Gate: ruff clean (backend + scripts), pytest 193 passed.
2026-08-11 22:01:14 +02:00
peter f484de1008 refactor(plex): address /code-review on the R12 S1 split
Cleanup only, no behaviour change (ruff clean + pytest 193):
- imports hoisted above `router =` and grouped/sorted (isort) across the split
  files; ruff-format pass restores the 2-blank-line PEP8 spacing the generator
  collapsed to 1.
- the shared on-disk cache root moves to a neutral app/plex/cache.py, so
  subtitles.py no longer imports it from imgcache.py (drops the cross-import).
- the former plex.py module docstring's access-model note (read endpoints open
  to any authed user; admin endpoints test/sync) is restored in the package
  __init__.py.
2026-08-11 21:46:43 +02:00
peter 99befa6e38 refactor(plex): split routes/plex.py into an admin/browse/playback package
R12 S1 step B. The 2011-line routes/plex.py becomes an app/routes/plex/
package:

- admin.py     — connection test, catalog sync, owner watch-link config (5)
- browse.py    — libraries, facets, collections, playlists, show/item, people (23)
- playback.py  — image/subtitle proxies, HLS streaming, watch-state writes (10)
- _shared.py   — the two genuinely cross-module helpers (_item_or_404, log)
- __init__.py  — assembles `router` from the three sub-routers; the app wiring
                 (plex_routes.router in main.py) is unchanged.

Mechanically generated and proven behaviour-equivalent: every function/class
source relocated verbatim (get_source_segment equality) and the (method, path)
route set is identical to before. Endpoints keep the /api/plex prefix on each
sub-router. No SQL change (db.query untouched; 2.0 select() is the next step).

Gate: ruff clean, pytest 193 passed (pure-logic + DB lane).
2026-08-11 21:30:01 +02:00
peter 4106bceb08 refactor(plex): extract img/subtitle/rollup helpers into app/plex services
R12 S1 step A. Move the pure, leaf-level helpers out of the 2227-line
routes/plex.py into the app/plex service package with public names:

- app/plex/imgcache.py — on-disk poster/art cache + proxy (was _img_cache_*,
  _image_key, _img_response, _IMG_CACHE/_IMG_SIZES). Owns the shared cache dir.
- app/plex/subtitles.py — WebVTT convert/time-shift + subtitle cache (was
  _srt_to_vtt, _shift_vtt, _ffmpeg_to_vtt, _sub_cache_*, _BITMAP_SUBS).
- app/plex/rollup.py — show watch-state aggregation (was _rollup,
  _show_status, _show_agg_subq, _show_state_map).

Behaviour-equivalent move: bodies verbatim, only the public API renamed and
call sites re-qualified. No endpoint or SQL change (db.query kept as-is; the
2.0 select() conversion is a later step). plex.py 2227 -> 2011 lines.

Gate: ruff clean, pytest 193 passed (pure-logic + DB lane).
2026-08-11 21:16:44 +02:00
peter 2671ab4cc8 release: 0.62.0 (r11-feedback-symmetry) 2026-08-11 20:41:43 +02:00
peter c4fde626a4 chore(release): 0.62.0 — R11 feedback symmetry
VERSION bump + user-facing release notes for R11 (Plex error toasts,
download/edit ready notifications, saved-view rollbacks + retries, access-request
denial emails).
2026-08-11 20:40:46 +02:00
peter 359bac149b refactor: address /code-review on R11 feedback symmetry
- admin._decide: add an idempotency guard (409 unless status was 'pending') so a
  repeat/stale approve OR deny can't re-run its side effects — now that deny emails,
  this stops duplicate denial mails and a rejection mail to an already-approved user.
- SavedViewsWidget: extract runViewMutation(fn, failMsg) (returns success) — dedups
  the five try/catch+refresh blocks AND lets create/rename close their input only on
  success, so a failed create keeps the user's typed name.
- worker._notify_download_done: take user_id/title captured BEFORE the caller's commit
  (no post-commit refresh SELECT); add a  so the inbox labels an edit job
  'Edit ready' instead of 'Download ready' (new inbox.editDone i18n, HU+EN).

Gate green (ruff/tsc/eslint-0/prettier/knip/vitest 79/pytest 193); smoke-tested the
saved-views + notifications surfaces render clean on a fresh dev server.
2026-08-11 20:25:18 +02:00
peter e2fe6fa286 feat(ux): R11 feedback symmetry — Plex/download/rollback/deny notices
Give equal-weight actions equal-weight feedback (R11).

S1 Plex error toasts: the watched-toggles (card / season mark-all / episode /
info-panel / show mark-all) used to fail silently on 403/404/429; each now surfaces
the server reason via a shared notifyActionError() + resyncs the optimistic flip.
The playback auto-mark-watched stays intentionally silent (a mid-play toast is
noise; the next sync corrects it). New generic lib/actionErrors.ts (Plex + saved
views reuse it; notifyYouTubeActionError delegates its non-403 case to it).

S2 download-done inbox notification: worker.py emits a best-effort type=download_done
notice to the job owner on real completion (regular + edit jobs, not instant
cache-hits), rendered/localized in the inbox with an Open-Downloads link.

S3 optimistic rollbacks: SavedViewsWidget create/rename/toggle-default/remove/reorder
and the inbox revert-state no longer swallow failures — they toast + refetch to
resync (the reorder's optimistic order reverts to server truth).

S4 access-request deny: deny_invite now emails the requester (send_access_denied),
mirroring approve — no more silent limbo.
2026-08-11 05:05:50 +02:00
peter 354b3dd10c release: 0.61.1 (r10-btn-primary) 2026-08-11 04:24:22 +02:00
peter b5cf355ae4 chore(release): 0.61.1 — .btn-{tone} action-button recipe family
VERSION bump + chores release note for the R10 .btn-primary/.btn-danger/.btn-warn
recipe unification (refactor; a few primary buttons gained the standard hover).
2026-08-11 04:23:26 +02:00
peter fc186e1230 refactor(ui): address /code-review on .btn-primary — tone family + doc fixes
- Generalize the recipe into a .btn-{tone} family: add .btn-danger / .btn-warn
  siblings sharing one hover-dim, and migrate the two hand-rolled tone-action
  buttons (ConfirmProvider danger confirm, Banner warning action) onto them, so
  primary/danger/warn action paints can't drift.
- Reword the recipe comment: it owns paint + one standard hover-dim (not
  'PAINT-ONLY' — that mislabel obscured the hover behaviour).
- Drop the now-redundant hover:opacity-90 from Banner's action-button base (each
  tone's recipe supplies the hover).

The 3 previously hover-less primary buttons keep the standard hover-dim gained in
the prior commit (intentional consistency; now accurately documented). Gate green
(tsc/eslint-0/prettier/knip/vitest 79); self-E2E confirmed btn-danger/btn-warn
compute to their tone/tone-fg tokens.
2026-08-11 04:20:10 +02:00
peter 76f8de258c refactor(ui): R10 — .btn-primary recipe for the primary/action buttons
Add a paint-only .btn-primary recipe (solid accent bg + accent-fg text +
hover:opacity dim) in index.css @layer components, alongside the selection-paint
recipes (.pill/.chip/.is-current). Migrate ~38 primary/action buttons across 24
files off the hand-rolled 'bg-accent text-accent-fg hover:opacity-90', keeping
each site's geometry (padding/radius/font/gap/disabled/shadow).

Deliberately NOT migrated: accent BADGES/labels (spans), the chat message bubble,
selection toggles (tableHeader — R8 popover territory), and the round play FAB
(hover:scale, not the opacity-dim primary idiom). Three previously hover-less
primary buttons (SettingsPanel/SetupWizard/DraftSaveBar) gain the standard hover
dim for consistency.

Gate green (tsc/eslint-0/prettier/knip/vitest 79); self-E2E confirmed a migrated
button computes to accent/accent-fg and both recipe rules load.
2026-08-11 04:04:06 +02:00
peter 87da6155e1 release: 0.61.0 (fixpack-cards-nav-plex) 2026-08-11 03:37:57 +02:00
peter 3b41b68d22 chore(release): 0.61.0 — cards/nav/plex fixpack
VERSION bump + user-facing release notes for the 7-item UX batch (channel-page
priority stepper, cross-season Plex binge, unified card hover, slim icon nav,
scoped filter peek, full-viewport Plex backdrop, player end-of-queue exit).
2026-08-11 03:36:42 +02:00
peter 02754f479f refactor(ux): address /code-review high findings on the fixpack
- Extract the optimistic + debounced-flush feed-priority flow into a shared
  useChannelPriority hook (mirrors useChannelTagActions); the manager and the
  channel-detail page now share it instead of two drifting copies.
- NavSidebar: drop the always-true `slim` const and its dead expanded-rail
  branches; refresh the now-stale pinned/collapsible comments.
- Remove the orphaned `navCollapsed` localStorage key (no readers/writers left
  after the always-slim rail).
- Channel page: drop the wrapper native `title` that duplicated PriorityStepper's
  own Tooltip.

The keyboard-focus nav-label finding needed no change: the popover platform
already wires useFocus (visibleOnly), so a real Tab press shows the flyout label
(verified in-browser).
2026-08-11 03:29:08 +02:00
peter 71bbf35f5a fix(ux): dock filter tab flush to the nav + reliable full-viewport plex backdrop
Follow-ups on the 7-item batch:
- The slim nav slot was still w-20 while its card is w-14, leaving a ~24px dead
  gap the filter tab/bar couldn't dock against. Slot is now w-14 (exactly the
  card width), so the filter's flat-left tab sits flush on the rail's right edge
  (measured gap 0), both collapsed and expanded.
- The Plex art backdrop was invisible: a position:fixed layer at z-index -1 is
  painted BEHIND the body's opaque background here, so nothing showed. Paint the
  art on the app root (#root) instead — it's exactly the viewport and a real
  ancestor of the transparent nav/header/content, so the art fills the whole page
  edge-to-edge and the glass chrome floats over it (verified in-browser).
2026-08-11 02:53:55 +02:00
peter 9d7a2a75d8 feat(ux): card unification, slim icon nav, filter/plex fixes (7-item batch)
1A ChannelPage "Full history" button no longer wraps (whitespace-nowrap).
1B Extract the manager's press-and-hold priority stepper into a shared
   PriorityStepper and add it to the channel detail header (subscribed, non-demo);
   backend channel_detail now returns the per-subscription `priority`.
2  Unify YouTube + Plex media cards onto one look/behaviour: drop the whole-card
   hover LIFT (VideoCard) — which also fixes the clipped-top bug — and instead the
   inner thumbnail/poster ZOOMS on hover (both); give the flat Plex posters the same
   glass-card 3D depth + accent hover border as the feed cards (shadow-md is a
   transparent no-op here, so it's glass-card that provides the depth); clamp Plex
   card titles to a fixed 2-line height so 1- vs 2-line titles align.
3  Nav rail is now always a slim icon-only column, flush to the left edge; each icon
   reveals a floating name label on hover (Tooltip gains `always` + `openDelay`, and
   a right side). Removed the pin/unpin toggle, the expand-on-hover mode and the
   navCollapsed pref plumbing.
4  Scope the side-panel peek-open HOVER to the tab handle + the card (not the whole
   full-height slot next to the rail), so approaching the nav no longer pops the
   filter open; focus stays container-wide. useHoverFocusWithin gains a hover-out
   grace period + split hover/focus handler groups.
5  Plex art backdrop fills the whole viewport (a fixed inset-0 layer behind the
   transparent app) instead of only the inset <main> box.
6  Plex season binge continues across seasons: the season view's play queue now
   spans the whole show (allKeys), so a season's last episode rolls into the next
   season's first (seasonKeys stays for "add season to playlist").
7  When a Plex video/episode ends with nothing queued next, the player returns to
   the previous page (onEnded → onClose) instead of stranding a black screen.
2026-08-11 02:20:52 +02:00
peter e5d5735b9a release: 0.60.10 (r10-selected-paint) 2026-08-11 00:50:24 +02:00
peter a9b53bbc9d chore(release): 0.60.10 — VERSION bump + release note (R10 S4 selection-paint unification) 2026-08-11 00:48:26 +02:00
peter cb168c800a fix(ui): R10 S4 review r3 — preserve glass-card edge on pills, ease transform
Two low-severity follow-ups from the third /code-review round:
- .pill's resting border-color is now scoped `.pill:not(.glass-card)`, so a
  glass-card-resting pill (PlexSidebar scope/facet, PlexPlaylistView layout)
  keeps glass-card's own border incl. its lighter top-edge specular highlight,
  instead of having it flattened to a uniform var(--border).
- add `transform` to the recipe transition so the `active:translate-y-px` press
  nudge on the Sidebar tag/date pills eases again (it lost the utility transition).

Verified plain pills keep their resting border (Feed + YOUR TAGS) and active
pills still paint solid accent.
2026-08-11 00:45:14 +02:00
peter 90b33fc325 refactor(ui): R10 S4 — complete the selection-paint sweep (all pill idioms)
The first S4 pass migrated a subset, which left same-idiom selection pills
hand-rolled and made adjacent controls inconsistent (a /code-review finding).
This completes the sweep so every selected-among-options control shares the
recipe:

- plain pills → .pill (+ aria-pressed): Channels status filters + view tabs,
  Sidebar filter-tag chips + date presets + custom-dates, Stats tabs + ranges.
- glass-card-resting pills → .pill with the resting `glass-card glass-hover` kept
  in the non-pressed branch (solid accent when pressed): PlexSidebar library
  scope + the facet Chip (retired the round-1 "deferred" note — the recipe
  already expresses this via the resting branch, no new variant needed).
- custom-dates aria-pressed is coerced with !! so a date string can't leak into
  the attribute and miss the [aria-pressed="true"] paint.
- drop the dead `border` on ChannelTags' chip-solid (accent border on accent bg).

Out of scope (a separate concern, NOT selection-among-options): primary/action
buttons that share the accent fill — Banner action, ConfirmProvider primary,
PlexBrowse primary, PlayerModal watched-toggle — these want a future .btn-primary
recipe. Left by-design: tableHeader filter options (PopoverItem + keepSelectedPaint,
owned by the popover platform).
2026-08-11 00:27:04 +02:00