21142cae5f58022b260dfb5e999a3ba702231406
22
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
536fb4794c |
fix(collections): the create modal's seed POST 400'd — echo cannot bind a form string into an any-typed field
The first real-browser create of an Episodes-scope collection failed with "invalid request": the modal's hx-post submits application/x-www-form-urlencoded, and the form value for filter_tree had to bind into CreateCollectionRequest.FilterTree (any) — echo's form binder errors on any-typed fields instead of setting the string, so every form carrying a tree 400'd before validation even ran. JSON bodies (the save-filter bridge, the editor's Apply) were unaffected, which is why no API-level or Go test caught it: only a browser posting the real form shape exercised the transport. Fix: the form variant binds a dedicated string field (FilterTreeForm, form:"filter_tree") on both request structs while FilterTree keeps the JSON tag; resolveTreePayload picks whichever transport arrived and both ride the same parseRequestFilterTree validation. Pinned by TestEpisodeCollectionsFormSeedCreate (form-encoded seed → 201 → scope round-trips). |
||
|
|
cc0e9d7f91 |
feat(podcasts): the episodes authoring door + curated chain order
"Newest episodes from my selected podcasts" is now buildable in the UI, end to end — the user could not find ANY podcast-related filter surface because none existed: the New Collection modal could only create book-scope smart collections, and the engine's episodes scope (item 3.1) had no authoring door. The scope door: - The create modal's Smart card gains a Rules-scope segmented control (📚 Books / 🎧 Podcast episodes); the copy no longer claims smart means "match books by rules". Choosing Episodes seeds an EMPTY episodes tree ({"scope":"episodes","mode":"all","children":[]}) so the collection is born episode-scoped: the editor opens with the episode vocabulary (feed/downloaded/played/published date) and membership runs the episode engine from the first rule. - ParseFilterTree now accepts an EMPTY ROOT (nested empty groups stay invalid) — the seed's legal shape. The root arrives at depth 1 by call convention, hence the depth>1 exemption. - The composers define the empty-tree semantics: zero children matches NOTHING (ComposedLiveMedia/ComposedCount guards), mirroring EvaluateSmartCollection's zero-enabled-rules convention. Without the guard the composed query degrades to the bare scope head and a fresh collection would swallow every episode in scope — pinned by TestEpisodeCollectionsEmptySeedMatchesNothing (envelope book_count 0) and TestEmptyRootTreeSeedsEpisodesScope. The curated chain order (plan §10 R2, user need: the briefing plays NEWEST-first): - view_settings.chain_order ("native"/"newest"/"oldest"/"reverse") stored on the collection — curated content, not a player-side knob. - PUT /api/collections/:id/view-settings merges the key into the stored JSONB (a blind write would clobber the playlist toggles). - buildSmartCollectionSequenceContext routes OrderEpisodes through handlers.CollectionChainOrder; the detail header's Play-as-Sequence gains the order select next to it (PlayCollectionButton), which PUTs and reloads. Pinned by chain_follows_the_curated_order (reverse flips the chain indexes and hops). Caught by the new test, a latent clobber fixed: UpdateCollection treated a NIL view_settings payload as "write NULL" — any edit that carried no settings dropped every stored key (chain_order, playlist toggles from API clients, imported provenance). Nil now means untouched, the same convention filterTree already had. Composer guards also cover ComposedCount, so the builder's live count reads the same semantics. Tests: collectionChainOrder unit matrix, empty-root parse unit pin, two integration pins (seed matches nothing; curated order drives the chain and survives whole-map edits). |
||
|
|
4f71eda6be |
feat(podcasts): the rules page retires — the collection builder IS the filter builder (item 3.4)
The smart-collection editor at /collections/:id/rules now hosts the SAME builder island as All-Books (FilterBuilderRows — the rows markup extracted into one shared templ both mounts render; the shared filterBuilderExtra component). One vocabulary, one engine, one row UI; the collection differences are exactly three: the payload is the collection's scope (episode trees get the episode fields — books trees hide them), Apply writes the tree to the collection (PUT filter_tree through the validated write path), and a legacy rules array preloads as its CONVERTED tree (RulesToFilterTree rendered server-side) so the upgrade lands at the first Apply — read-compat never mutates data silently. Retired with the page: the per-rule CRUD endpoints and test-rules (their handler bodies, routes, api-docs rows, and the dead collections.ts island functions — the file's LIVE halves, the collection modal/navigate/ grid-refresh, stay; the dead rules half went). match-mode endpoint and preview survive (legacy channel + the dashboard custom-section-builder, flagged for its own migration). Contract-pin changelog documents the deletions per the asymmetry rule. The builder component gains collection mounts: scopeKey (the row universe for counts/apply), data-tree prefill, treeJSON (the apply wire with scope riding the top level), and an apply hook (the page owns the write; All-Books never sets one). ensureReadyRule defaults to the scope's first field instead of hardcoding author. Tests migrated onto trees: TestSmartCollections' live-membership / reject-mutations / include-auto / system-collection subtests now drive TREE collections; the deleted-endpoint subtests die with them. |
||
|
|
6b2125a45a |
feat(podcasts): smart collections ride the unified filter engine — filter_tree column (item 3.2)
collections gains filter_tree JSONB (idempotent in-place; same qf DSL as saved filters — the scope key included). IsCollectionSmart is the ONE detection (tree OR legacy rules), and EvaluateSmartMembership is the ONE evaluator: tree-bearing collections run the composed engine via ComposedLiveMedia (full media rows, so the collection surfaces keep their episode-card stamping, feed positions for the replay chain, and the family lens rides the mandatory head), while legacy rule arrays keep per-item FieldMatches until rewritten — one call surface, two storage epochs. All smart surfaces (detail JSON, counts, detail page, reader chain) dispatch through it; the search service is injected into the collection subsystem (variadic — main.go builds one shared instance; the test harness mirrors it). Write path: CreateCollection/UpdateCollection accept the tree, validated against the engine grammar at the HANDLER (an invalid tree is a 400, never a stored render-time 500) and exposed in the envelope (pin widened in TestContractPin_CollectionsJSON + changelog). Update semantics: an edit form that doesn't send a tree keeps the stored one. RulesToFilterTree converts legacy rules + match mode at upgrade time: episode rules map mechanically (feed→in-list, played/downloaded→ booleans, published_within→published_date+within_days), tree root mode absorbs rule_match_mode, episode rules force the episodes scope, and legacy starts_with/ends_with fall back to contains (documented). Read-compat by design: nothing is migrated at boot — legacy arrays evaluate exactly as before until the collection is rewritten. Pinned by TestRulesToFilterTree (unit conversion) + TestEpisodeCollectionsFilterTree (a tree collection evaluates through the engine, the chain follows the tree's live membership, invalid trees 400 at write, the tree wins when legacy rules coexist, and an edit without a tree keeps it). |
||
|
|
69773ec307 |
feat(podcasts): smart collections play as a sequence — the Daily Fiction acceptance (item 2.5)
ACCEPTANCE (plan §0 item 2.5): "Daily Fiction" = feed ∈ {A} ∧ unplayed
∧ <7d matches exactly today's episodes, and PLAY = today's episodes
back-to-back. Two pieces were missing after item 2.4:
- The chain builder only read static membership (collection_items) — a
smart collection has none. buildSmartCollectionSequenceContext now
evaluates the rules LIVE for the requesting user (mode from the
collection, family lens via the visible set) and chains the
downloaded episode matches in NATIVE feed order — the collection's
computed order is content (R1: never written back; OrderEpisodes is
the order chokepoint per R3). A match that aged out of the briefing
(played since, published beyond the window) falls back to the feed
queue instead of breaking the page.
- The smart-detail branch rendered match cards as books: episode
matches are now flagged (IsEpisode + download state) so they carry
the play affordance and the header's Play-as-Sequence button;
feed titles stay empty on smart cards for now (batch lookup is
polish, not acceptance).
Pinned by TestPodcastSmartCollections' acceptance subtests: the
briefing starts at index 1/2 and chains into the second with the
queue param on both hops; the order control stays hidden (computed
order); a stale match falls back to the feed queue honestly.
|
||
|
|
abb7c84833 |
feat(podcasts): smart episode collections — ALL/ANY match modes + episode rule fields (item 2.4)
The daily briefing needs AND: today's rules engine was OR-only. The
match semantics become a per-collection column
(collections.rule_match_mode, 'any' legacy default | 'all'), flipped on
its own endpoint (PUT /api/collections/:id/match-mode, owner-only —
nothing else on the row changes) and surfaced in the rules page as an
ANY/ALL toggle with matching copy.
Episode rule fields join the vocabulary (plan item 2.4): feed (the
multi-select rides the new `in` operator — comma-separated feed ids),
played (the canonical finished predicate — viewer progress ≥ 0.999,
same as hide_finished), downloaded, published_within (N days), plus
title/description contains. The rules candidate query now carries the
viewer's progress (LEFT JOIN reading_progress) so played/unplayed is
per-user, and TestRules/preview speak the SAME evaluator: the handler's
duplicated field switch is deleted and delegates to
CollectionService.FieldMatches — one vocabulary, one semantics.
Legitimate semantics note: in ANY mode `played:false` matches unplayed
BOOKS too (everything with no progress row) — correct per-rule
behavior, and exactly why the briefing mode exists.
Backward compatibility: every legacy row defaults to 'any' (the old
OR), UpdateCollection keeps the stored mode when the form doesn't send
one, and the evaluator treats absent data honestly (no date → no
match; no progress → unplayed).
Pinned by TestPodcastSmartCollections: the AND-mode briefing
(feed∈{A} ∧ unplayed ∧ <7d = exactly the new episode), OR-mode
widening, owner-only flips, the preview honoring the mode, and
downloaded/contains rules. Also fixes a latent bug the new query
surfaced: UpdateCollectionViewSettings referenced a nonexistent
updated_at column (never exercised until now — the new sibling query
was written without it).
|
||
|
|
adb93cba9e |
feat(podcasts): episode collections — picker scope, hint rows, the chain (item 2.3)
D11 lifts: podcast episodes (media_items rows) join collections, and "Play collection as sequence" feeds the player from the collection's order. No new tables — collection_items already carries ordered membership (item 2.1 audit). PICKER: the collection modal gains a Books/Episodes tab pair. The episodes tab hits /api/media-items/search?scope=episodes — a dedicated picker query (SearchEpisodesForPicker) whose visibility is entirely RLS: the media_items subscription predicate drops unsubscribed feeds' episodes, and the podcast_feeds join is governed the same way. The books scope keeps its podcasts exclusion (plan §9) — this scope is the only door in. Pending episodes are pickable (plan ahead; the detail page shows them as not downloaded). Selection rides the existing bookPicker store — ids never collide across scopes, and AddBooks works unchanged on episode ids. DETAIL: the All-Libraries membership view switches to GetCollectionItemsDetailed — LEFT JOIN + per-row visibility. An unsubscribed episode (or family-lens hidden book) survives as a Hidden card rendered as an "Unavailable — remove entry" hint row instead of silently vanishing; visible episodes render feed title, download state, and a Play link. The library-scoped view keeps its existing dashboard-query behavior (the switcher's contract is untouched for books). CHAIN: ?queue=collection:<id> on any reader URL makes buildPodcastSequenceContext produce the COLLECTION context (§10 R2 same shape, source=collection): downloaded episodes in collection order (positions are content — R1; no feed-order override), prev/next URLs carrying the queue param so the chain survives hops, and backToDetail landing on the collection page. Ownership gates the chain — foreign/deleted ids fall back to the feed queue; the order control stays hidden (2.2's source gate). The header's "Play as Sequence" button starts from the first downloaded episode in list order. Pinned by TestPodcastEpisodeCollections (7 subtests): RLS-scoped picker + family-lens auditor case (subscribed episode in a hidden library must not reach the picker), books-scope exclusion intact, hint rows, chain carrying the queue param, start-from-first, foreign fallback, and reorder driving the chain (the R4 primitive works on episode ids unchanged). |
||
|
|
a90e7debfd |
feat(podcasts): queue context source + player order control — item 2.2
Foundation rule R2 (§10): the queue context is the only queue, and every producer emits the same shape. PodcastSequenceContext gains source (feed|collection), feed_id, and order; the feed producer sets them now — the collection producer (2.3) slots into the same shape without the player ever special-casing beyond showing/hiding controls. The player header gains the FEED-mode order control next to the "Ep N/M" counter: a dropdown (native/reverse/oldest/newest) plus a one-click reverse button. Both PUT the existing per-user feed order override — the SAME state the feed page writes, so flipping it in the player updates the feed page's dropdown too. The control renders only when source=feed: a collection queue's order is curated content (R1), never a listening preference. Behavior is hx-put + hx-swap="none" (response discarded) with a module-level htmx:afterRequest listener in audio.ts reloading the page — the server re-renders prev/next/index in the new order; templ decides what it looks like, JS only triggers the round trip (CSP-clean, no inline handlers). Pinned by TestPodcastPlayerOrderControl: the config JSON carries source/feed_id/order/episode_index; the control renders feed-mode; an order override re-renders with the dropdown reflecting it (data- json pins must match templ's " attribute escaping). |
||
|
|
7bf0b1257d |
feat(podcasts): widen reader_settings key ceiling — item 2.1 foundation
reader_settings.setting_key was VARCHAR(50), and the composite podcast_order:<uuid> key already sits at exactly 50 chars — zero headroom. The deferred queue-editor idea (plan §0 item 2b) and any future per-feed/per-collection preference key would have been forced to payload-stuff inside the order row by a column width. Composite prefix:<uuid> keys are the norm now (§10 R5), so the ceiling widens to VARCHAR(120): idempotent in-place ALTER (varchar widening is metadata-only, no table rewrite), re-runs harmlessly on every boot per the schema convention. No new episode-membership tables: collection_items already carries ordered playlist membership (position, A2c) against media_items, which podcast episodes ARE — episodes join collections through the existing table. Pinned by TestReaderSettingsCompositeKeyHeadroom: a 57-char composite key (the podcast_custom_order:<uuid> shape) upserts and reads back. Under the old width this fails; sqlc codegen is byte-identical. |
||
|
|
f47734dd7d |
fix(podcasts): generic media delete refuses episode rows (invariant 2)
The admin endpoint DELETE /api/media-items/:id predated podcasts and would delete any media row — including podcast episodes — if hit directly. Invariant 2 (absolute form, user decision 2026-10-30) says episode rows are removed ONLY by whole-feed removal: "the feed should match the XML until the entire feed is gone." The plan audit found this endpoint was the one remaining surface that could violate it: the poller, unsubscribe, and the admin file-deletion endpoints are files-only, and the hash-conflict merge can't see episodes (they never hash). The guard sits right after the GetMediaItem read and BEFORE the storage-down check, so a refused episode never touches storage resolution. Same segregation pattern as the generic edit path (D9/D10, UpdateMediaItem). Pinned by TestDeleteMediaItemPodcastGuard: admin DELETE of an episode → 409 with the row surviving; a book delete through the same endpoint → 204 control. |
||
|
|
7d5bbd1177 |
feat(admin): multi-select episode file deletion — checkboxes on downloaded rows
The plan's per-EPISODE bulk select for the admin view (§0 item 8 follow-up — trimmed in the first admin-view commit; user called it back). Admins now tick checkboxes on DOWNLOADED episode rows and delete their files in one request: - Checkbox per row, rendered ONLY for admins + downloaded episodes (nothing to delete otherwise), via the existing podcastDetail Alpine component: selection ids/count survive the list's htmx swaps (reapplied on afterSwap — same pattern as the text filter) and clear after a successful bulk delete. - Toolbar segment outside #episode-list (survives swaps): 'N selected' + 'Delete selected files' (hx-include gathers the CHECKED boxes); disabled-looking at zero selection; hx-confirm states the re-materialization contract. CSP-safe method bindings only (bulkSelectionLabel/Class) — no ternaries in Alpine expressions. - POST /feeds/:id/episodes/delete-files (admin): repeated episode-file-select keys parsed via ParseForm directly (never bound — repeated-key wire is another echo-bind gotcha); each id validated to belong to THIS feed, so a forged foreign id is refused while the real selections apply; rows reset to pending, files removed. - Tests: bulk of two real ids + one forged id → deleted:2, files gone, rows pending; non-admin 403; all prior single/feed-level pins hold. |
||
|
|
0741e98ea5 |
feat(admin): all-podcasts view — disk-first maintenance for every feed
§0 item 8 (Phase-4 item 1 pulled forward, user directive: maintenance
tooling ships WITH the features it maintains):
- /admin/podcasts (AdminMiddleware): every feed from EVERY user via the
RLS admin escape (first-admin identity, poller/fetcher contract) —
feed + host, library, subscriber count/names, downloaded/total, DISK
USAGE, last fetch. Sorted disk DESC ('what's eating the disk'),
totals header (feeds · episodes · bytes on disk).
- Per-feed actions: Refresh · Delete files (rows → pending, disk freed;
confirms spell out the re-materialization contract) · Remove feed
(rows + files for everyone — the same admin delete as the detail
page). All confirms ride the global confirm modal.
- New sqlc: ListAllPodcastFeedsAdmin (correlated aggregates),
ResetPodcastEpisodeFileDeleted / ResetPodcastFeedFilesDeleted —
downloaded rows reset to pending with download-derived facts cleared
(audio_metadata, chapter_count, cover, size, mime); file_path KEPT
(fetch-maintained layout — a re-download lands on the same path).
file deletion via the existing DeleteFeedFiles file-removal pattern
(episode files + .cover.jpg siblings).
- Poll upsert never touches download_state, so deleted files STAY
deleted — a feed refresh re-lists metadata but re-materializes
nothing (pinned by TestAdminDeleteEpisodeFiles).
- Per-episode admin delete-file: POST /feeds/:id/episodes/:eid/
delete-file (admin) — one episode's file gone, row pending, siblings
untouched; HX swaps the episode list. (Found while testing: the
handler first read c.Param("id") — the FEED's uuid — instead of
:eid; the 404 'episode not found' trail led straight to it.)
- Tests: admin sees other users' feeds + subscriber names; regular
users 403 on page AND actions; single + feed-level deletion reset
rows/clear facts/delete files; refresh leaves pending rows pending.
- Admin nav gains 'Podcasts' under Administration.
Wrong-gotcha note for future agents: parseUUID on c.Param('id') inside
a /feeds/:id/episodes/:eid route resolves the FEED, not the episode.
|
||
|
|
5c545534c2 |
feat(podcasts): Download All queues in the LISTENING order + add-modal order dropdown
Download All previously queued in feed-native order — 'getting the last chapter first doesn't help me'. Now the queue follows the feed's effective listening order (§0 item 5b): QueueFeedDownloadsAs reads the CALLER's order override and runs episodes through services.OrderEpisodes (same helper as the sequence/reader/page surfaces — the consolidation from item 5a landed with the reverse-order commit). Queue tickets (SEQUENCE-stamped, item 4) make that enqueue order the claim order, so chapters land in the order you'll actually listen. Add modal (§0 item 5c): with 'Download all episodes now' checked, a 'Download order' select appears (pure-CSS :checked sibling reveal — no JS island, CSP-clean). Its choice is the feed's INITIAL order override (reader_settings upsert; 'feed' = no row) AND the queue order for the initial batch. Per-book by design: the override IS the download order. Test TestPodcastAddModalDownloadOrder: add with download_all+reverse → the override row exists AND persisted tickets run position 3→1. Tickets are asserted (not live queued rows) because the shared dev server's worker legitimately races this test's queue — tickets survive every state flip. |
||
|
|
0f3c4a8cec |
feat(podcasts): concurrent download pool — fair share, tickets, admin tunables
Replaces the sequential worker with a download-manager-style pool (§0 item 4). Model: the user's connection is the limit; semantics are fair-share ACROSS feeds, not goroutine-per-book (a global FIFO would make Book B wait behind Book A's 100 files). - Atomic claim: GetNextQueuedEpisode + SetPodcastEpisodeDownloading was a read-then-write race under concurrency; ClaimQueuedPodcastEpisode converts queued→downloading in ONE guarded statement (two claimants, one winner — the loser gets ErrNoRows). SetPodcastEpisodeDownloading retired. - Queue tickets: download_queue_pos BIGINT fed by a Postgres SEQUENCE stamped at enqueue — the enqueue loop's ordering IS the claim order (the foundation §0 item 5 builds on; previously all rows in a batch shared one updated_at, so within-feed order was arbitrary). - Pool: up to podcast_download_concurrency (default 4, live range 1–16) claim-loop goroutines per drain round; the setting re-reads every round so admin changes take effect without restart. - Fair share: claim prefers the feed least-recently-served (pickFeedToServe; never-served first), earliest ticket within a feed — books proceed in parallel while chapters land in order (start listening while the rest download). - Bandwidth: shared token bucket across all workers (podcast_download_speed_mbps, default 0 = unlimited); take() reserves bandwidth and callers sleep outside the lock. - Settings: new float setting type end to end (registry defaults + clamping, save validation, admin UI number-with-step input) + the two schema seeds; worker wires the registry via SetSettings (tests run on registered defaults). - Tests: unit coverage for the bucket reservation math + fair-share pick; integration TestPodcastDownloadPool pins ticket order = enqueue order, claim exclusivity, and a full 3-episode pool drain. Dev-DB note: the column/sequence/settings were applied live (idempotent DDL, same statements the schema runs at next boot) since the running container predates them. |
||
|
|
e3675536aa |
feat(podcasts): reverse order — position DESC, date-agnostic; single order helper
The 'Oldest first' order sorts by PUB DATE, which breaks on Scribl's migrated podiobooks feeds (messy/all-equal dates). New order value 'reverse' = feed position DESC with a date-DESC tiebreak — dates are not involved, so nothing can break. - services.OrderEpisodes: THE single order source of truth (§0 items 3+5a). The four surfaces (sequence JSON, reader chain context, SSR page list) previously carried three near-identical handwritten comparators — all now route through one generic helper. The SSR page list gains a SortDate key (lexical 2006-01-02) so the pub-date tiebreak is actually chronological; the old comparator compared 'Jan 2, 2006' display strings alphabetically (latent drift bug). - Dropdown becomes four options: Feed order / Feed order (reversed) / Oldest first (pub date) / Newest first (pub date). - Serial suggestion button renamed 'Reverse feed order' (sets reverse, not oldest) and its response out-of-band-swaps the order select (PodcastOrderSelectOOB — same mechanism as Download All's policy select), so the dropdown mirrors the server state immediately. - Validation: oneof=native reverse oldest newest everywhere. - Tests: reverse binds via the form wire; messy-dates sequence returns 3,2,1; existing reorder-safety stays green. |
||
|
|
7c7c08748e |
fix(podcasts): download_all binds on every wire — FlexBool + flip log
Reproduced the §0 item 2 suspect: echo v5 bool-binds form values via strconv.ParseBool, which accepts "true"/"1" but REJECTS the browser-native <input type=checkbox> value "on" — and a bind failure kills the WHOLE AddFeed request (400 "invalid request"), not just the checkbox. The add modal posts urlencoded (htmx), so any checkbox-serialization that emits "on" made Download All silently unusable. Fix: FlexBool (encoding.TextUnmarshaler-style UnmarshalParam) binds download_all permissively on every wire — "true"/"on"/"yes"/"checked" => true, explicit false/0/off => false, ABSENCE (unchecked box on any wire) => false; JSON bool binding is native and unaffected. Tolerates the exact wires htmx and browsers emit. AddFeed's subscription flip now logs "[PODCAST] flip ... <feed> — user" so the live flip is provable in app logs (user suspected it never fired). Integration test TestPodcastDownloadAllFormEncoding pins the urlencoded wire (what htmx actually sends): true + on both flip the caller's policy to 'all', an unchecked add leaves the default. |
||
|
|
7ea84043fa |
fix(auth): logout actually signs out — clear the HttpOnly session cookie
Release / build-and-push (push) Successful in 3m9s
Logout wiped localStorage but never cleared the token cookie (HttpOnly — only a server response can) and never called the logout API, leaving the refresh token live for 30 days and every cookie-authenticated SSR page accessible: the fake-signout bug. Reproduced on prod and dev; unrelated to RLS (it sits behind authentication) and long-standing. - /api/auth/logout now ALWAYS clears the session cookie (Set-Cookie empty + Max-Age=-1, HttpOnly/Path/SameSite mirroring login) — even a malformed body must sign the browser out; refresh-token revocation unchanged. - The header logout button (and the rules-page copy) now POST to the API with the stored refresh token BEFORE clearing local state and redirecting — best effort: local cleanup never blocks on API failure. - toast 401 handler fixed: it cleared 'refreshToken' but storage uses 'refresh_token' — the real key. - Regression test TestLogoutClearsSession: real login → logout → response deletes the cookie; logged-out refresh token can't mint access tokens; body-less logout still clears. Also carries regenerated artifacts from the confirm-modal and episode-list work (header_templ/podcast_detail_templ/style.css). |
||
|
|
30766020c8 |
feat(podcasts): real-time episode list — byte progress bars, direct swaps (issues #1/#3)
WHY: downloads needed manual refreshes to see progress; the 204 + HX-Trigger event indirection missed its listener, and the worker buffered whole enclosures so no progress existed to show. - Worker streams enclosures to disk through a counting loop (64KiB chunks, scheme/redirect/size guards preserved) with an in-memory per-episode progress snapshot (received/total; total from Content-Length, seeded by the feed's declared length). Deliberately transient — the DB row state remains the durable truth. - Episode-list renderer seam: the router injects a closure into PodcastHandler (handlers can't import templates — MediaDetail cycle) so htmx download/retry/Download All/refresh/order/policy actions answer with the REFRESHED partial directly (200, hx-target= #episode-list) — no events to miss. Non-HX callers keep JSON; tests without a renderer keep 204+trigger. - Polling tightens to 2s while downloads are active; downloading rows render a percentage bar + MB counter (quote-free inline style per templ gotcha #8) or an indeterminate bar when the server sends no length. - Issue #3: Download All (button AND the add-feed checkbox) flips the CALLER's subscription policy to 'all' — per-user, never global (D6: files shared, policies not) — and the auto-download select out-of-band-swaps so the dropdown shows what the user is getting. They can turn it down afterwards; the flip is honest communication, not coercion (user-approved 2026-10-29). PINs: Download All with HX answers 200 + episode-list + oob select and flips newest → all; add-feed download_all sets policy all. |
||
|
|
ffc80f7ba7 |
fix(podcasts): page search hits episode titles/descriptions (issue #8)
WHY: the /podcasts search box filtered feed CARDS only (title, display name, host, publisher, feed description). D14's episode search existed only as the /api/podcasts/search endpoint and was never wired into the page — searching 'Quarter' (present only in episode titles) cleared the grid while 'Trader' (feed metadata) survived. The page query now consults SearchMyPodcastEpisodes (subscription-scoped via RLS — the caller's identity) when a query is present; a feed surfaces when its OWN metadata OR any of its episodes matches, with an 'N matching episode(s)' badge on the card. PIN: SSR test with a needle that exists only in an episode title — the feed must surface with the match count. |
||
|
|
c753780817 |
fix(podcasts): order selector validation — bind the htmx form wire format (issue #2)
WHY: htmx sends non-GET requests urlencoded by default; the SetFeedOrder request struct only carried a json tag, so the binder left Order empty and 'required' fired — the exact error the order dropdown AND the Serial-suggestion button produced (hx-vals JSON also arrives form-encoded). One form tag fixes both controls. Regression test drives the exact wire format: urlencoded PUT with order=oldest/native → 200, empty/invalid → 400. |
||
|
|
5c10bbb90e |
test(podcasts): reorder-safety pin — progress survives position renumbering (task 2.6)
Invariant 3: progress anchors on the episode row (guid identity + chapter/offset), never the ordinal. Renumbering feed_position (the fetcher's per-fetch renumber case) leaves the progress row untouched and the sequence membership stable — downloaded-only with no gaps. |
||
|
|
49b0b6eeb9 |
test(podcasts): integration suite + RLS auditor + contract widening (task 1.10)
cmd/server/tests/podcast_test.go — six suites, the shared-dev-DB doctrine (test*-named libraries, cascading cleanup, no binary fixtures): - TestPodcastRLSSurfaces: THE AUDITOR (invariant 13) — non-subscriber 404 on feed detail + SSR page (existence leak), episodes absent from generic media reads, admin escape, subscriber visibility, then the hidden-library flip hiding an already-subscribed feed (the feed policy's library-visibility join). Seeding runs as the FIRST ADMIN — the seed identity has neither subscription nor users row, so podcast INSERT..RETURNING (SELECT policy under FORCE) 42501s for it; production pods run as exactly that admin (invariant 12). All direct row probes read through the admin scope for the same reason. - TestPodcastAddFeedFlow: member add + subscribe + metadata-only upsert; duplicate-URL dedupe (no second row); allow_member_feeds gate (member 403 / admin 201 / subscribe-still-allowed). - TestPodcastShrunkenFeedRetention: feed purges an episode from the XML, both rows persist (upsert-without-delete pin, D14). - TestPodcastEditSegregation: generic edit 400 + write-to-file refusal for episodes; control book edit unaffected. - TestPodcastDownloadWorker (ffmpeg-generated tone at runtime): file lands at <download-root>/<feed-slug>/NNNN-…, audio_metadata probed; re-download of a downloaded episode never re-fetches (idempotency pin, enclosure-hit counted); dead enclosure → failed + persisted reason + metadata intact. Fetch/SSRF tests swap the client via SetPodcastHTTPClientForTests (the production client refuses loopback httptest servers by design — the real client gets its own negative test in services). - TestPodcastScannerExclusion: SetFolders on a podcasts library root yields zero scanner folders (invariant 4 pin) — MediaScanner .FolderCount() accessor added. Unit: internal/services/podcast_rss_test.go — namespace-aware parsing fixture (three XML namespaces, fallback chains, hosts/guests), duration spellings, RFC-822 variants, explicit variants, slug/path layout, enclosure-extension selection, stripHTML, and the SSRF matrix (private/loopback/link-local/multicast/mapped-v6 blocked; a loopback httptest server PROVES the production client refuses it). suites green: full integration run 161s / 0 failures; vitest 12 files 163 tests. Contract widening: TestContractPin_MediaListBothBranches' raw-row allowlist gains the 15 podcast episode columns (additive; documented in docs/contract_pins.md — inert defaults on scan rows). Bruno: bruno/ podcasts/ mirrors the §8 endpoints (12 requests) + Bookhoard env vars. tests/ui/COVERAGE.md rows 18-23: podcasts pages/lifecycle/RLS/chain — Playwright sweep pending. |