Commit Graph

22 Commits

Author SHA1 Message Date
8f5e2749ff Sum a subscription's counts from the feeds it holds
An OPML folder has no entries of its own, so its row always showed zero
unread however much was waiting inside. Summed from every feed it holds
-- not just the ones a filter left showing, so the count doesn't move as
you type -- with the badge capped at 999+ so a four-digit number doesn't
eat the title beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-10 18:42:42 +00:00
dfcdf47143 Line up the feed sidebar
Every row reserves the chevron slot, so artwork and titles share one
column instead of stepping left when a feed has no children. Children
keep a single icon size and read as nested from the indent alone. Labels
stack on one line-height, and the unread count has a min-width so a
three-digit feed doesn't shove its own title.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-10 18:39:25 +00:00
c77d152015 Stop showing the skip reason next to a skipped enclosure
The chip already names the kind ("image"), so the sentence beside it added
nothing. A reason is now shown only when the state is an error. It is
still recorded and still reaches the API and the log, where it is useful.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-10 18:16:41 +00:00
5190a29cdb Refetch when a 304 arrives with nothing stored, and soften a skip reason
Deleting entries during a cleanup left each feed's ETag in place, so the
rescan got 304s, skipped parsing, and 57 feeds stayed empty until a
publisher happened to change something. A 304 while the feed holds zero
entries means the validator has outlived the data, so the daemon drops it
and asks again.

"not a wanted media type" was jargon, and storing it in last_error painted
an ordinary filter decision red. It reads "not audio or video" now, and a
reason is only shown as an error when the state actually is one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-10 18:14:57 +00:00
dd9c0881ee Keep every enclosure on an item, and view without downloading
The rss crate keeps at most one enclosure per item and, when a feed ships
several, silently keeps the last -- so a two-file item lost its first
file. enclosures_by_item reads them from the XML in document order,
unescaping attributes so a URL's &amp; survives. The row summarises the
one you would act on and counts the rest; the pane below lists them all.

Non-media enclosures gain a View link opening in a new tab: the
publisher's URL, or the local copy once downloaded. A direct link, not a
proxy, so the daemon does not become a fetch-anything relay.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-10 18:01:25 +00:00
6d6b4dd1e4 Do not offer a player for a file that is not audio or video
The media-type filter stopped new image enclosures being fetched, but ones
already on disk still got a play button and an <audio> element, because
the UI tested for a path rather than for a playable type. Four places did
this, including play() itself, which picked the first downloaded enclosure
whatever it was.

isPlayable() checks audio/* or video/*, falling back to the extension when
a feed declares no type. A downloaded non-media file now shows as its kind
with Save and Delete, so it stays available without posing as an episode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-10 17:50:39 +00:00
ddb9dbb7e1 Three-pane layout: feeds, items, item text
Feeds beside, the feed's items above, and the selected item's text with
its enclosures below -- the shape iPodderX used. Selecting a row fills the
pane below instead of expanding inline; enclosures render there as a
player when the file is present and a labelled download when it is not.
The divider drags and its position is remembered.

The archived site kept no usable screenshot of the original window, only
marketing panels, so this follows the description rather than reference
art.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-10 17:45:18 +00:00
a83f62ccd3 Do not auto-download enclosures that are not audio or video
Blog feeds put each article's header image in an <enclosure>, so a text
feed read as a podcast full of episodes: 149 images, 74 MB across 11
feeds. media_types defaults to audio and video, with a per-feed override.

Such enclosures stay listed and stay downloadable by hand; the row names
what it is rather than saying "skipped". An unknown type is allowed, since
the real type is only known after downloading, and a torrent is allowed as
a container judged once unpacked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-10 17:13:59 +00:00
8b21d19365 Log tabs with a daemon I/O view, and Playwright UI tests
The log view splits into All / Daemon I/O / Scans / HTTP. Daemon I/O is
the control protocol itself, logged where every command funnels through
so it covers socket clients, the CLI and the web UI alike. stderr and the
in-app buffer now have separate filters, so the UI can keep debug detail
the terminal should not carry.

Playwright drives a real browser against a daemon on fixture feeds. Eight
tests, each mapping to a bug that reached a user -- the Rust tests and the
stub-DOM smoke test cannot see a wrong selector or a dead handler.

It immediately found one: OPML folders rendered expanded by default,
because the code stored closed groups, so any folder never toggled counted
as open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-10 17:07:19 +00:00
665d5b8ecb Keep OPML feeds out of config, cap downloads, log daemon work
Writing 82 derived feeds into a hand-edited config.toml made it
unreadable. The OPML is the source of truth, so its feeds are re-derived
each scan and held in the database, inheriting the subscription's
settings; editing one promotes it to a real entry. A migration moves
existing children out -- 611 lines to 38 -- keeping all entries and files.

max_new_per_check defaulted to unlimited, so subscribing to an OPML of 82
feeds pulled whole back catalogues. It now defaults to 3 via [general],
capping every feed that does not set its own, and the pending queue orders
by publish date so a cap of 3 means the three newest.

Scans and downloads travelled as socket events only, so the log view
showed no daemon activity. They are mirrored into tracing, with routine
skips at debug -- at 82 feeds those alone would flush the buffer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-10 15:15:54 +00:00
c86d698363 Subscribe to an OPML, not just import one
A feed whose body sniffs as OPML is treated as a subscription list and
re-read on every scan, as iPodderX did. Listed feeds become real config
entries grouped under it, inherit its settings, land in one nested folder,
and are scanned in the same run.

When a feed leaves the OPML: removed if nothing was downloaded, kept and
flagged otherwise, so a downloaded file is never orphaned.

folder_for sanitized the whole folder string and would have flattened the
nesting; each segment is sanitized separately now, and a traversal still
cannot escape the download directory. Db::memory() also runs migrate(),
which it did not, so a migration-only column passed tests while missing in
production.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-10 15:01:03 +00:00
5f6e2a8dc1 Add a log view, and Docker packaging
The Log button shows the running daemon live: feed scans, downloads,
torrents and every HTTP request. It reads a ring buffer filled by a
tracing layer rather than tailing a file, so it works under Docker where
logs go to stdout. The access-log middleware skips /api/logs, or the
panel's poll would log itself forever.

Detached torrents could leave a row stuck in 'downloading' across a
restart, where nothing would ever revisit it; those are requeued at
startup.

Dockerfile, entrypoint and compose: 114 MB runtime, config bound to
0.0.0.0 on first run since container loopback is unreachable, drops to
PUID:PGID for Unraid, and a healthcheck that goes through the control
socket so a wedged worker reads as unhealthy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-10 12:06:54 +00:00
e97f2b9c2f Schedule pickers, and target progress at one row
"Check feeds every" becomes a number plus a unit dropdown in both global
and per-feed settings; parse_interval gained weeks to back it. The
per-feed dropdown can select the global default, clearing the override.

Fixes progress painting every pending row: the event carried no enclosure
id, so the handler had nothing to target and set the width on all of them.
Adding a feed looked like it was downloading everything. Progress,
DownloadDone and DownloadError now carry the enclosure id.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-10 02:38:14 +00:00
9960befed5 Fix the UI dying at load, and add a page smoke test
$('#prefs').onclick referenced a prefsModal that was never defined, and an
uncaught ReferenceError stops the whole script -- taking the theme toggle,
the feed filter, the event stream and loadFeeds() down with it, so the app
rendered an empty shell.

The scheduling patch had anchored on a function the rewrite already
deleted; str.replace matched nothing and said nothing.

tests/page-smoke.js executes the page against a stub DOM so this class of
failure is visible, since every server-side check passed while the UI was
completely dead. Handler wiring now skips a bad reference instead of
throwing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
2026-09-10 02:31:18 +00:00
9dc4c1ddfa Add feed check scheduling, global with per-feed override
general.schedule and feeds.<id>.schedule take "every 30m", "4h", "1d" or
bare minutes. The legacy interval_mins is still read. An explicit per-feed
schedule wins over the publisher's ttl; without one, ttl still raises the
interval when they ask to be polled less often.

Fixes two bugs found while testing it:

null never cleared a field. serde maps JSON null onto the outer None of an
Option<Option<T>>, so "clear this" was indistinguishable from "not
supplied" and every clear silently no-opped with a 204.

The daemon ignored SIGTERM while working. select! races branches only at
selection time, so a signal queued behind an in-flight download and the
process had to be SIGKILLed. The stop signal now cancels work in progress:
SIGTERM mid-download exits in 1s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
2026-09-10 02:25:38 +00:00
9c5e7716c5 Name the app iPodderX and cut the icon's white background
A blanket white-to-transparent would have holed the device, whose body is
also white, so the background is flood-filled from the corners inward and
stops at the outline. Verified composited on the dark theme background
rather than by trusting the alpha channel.

The uppercase transform on the heading had to go too, or the name renders
as IPODDERX.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
2026-09-10 02:13:12 +00:00
cbd5e68d13 Derive the colour scheme from the iPodderX icon
Palette sampled from the icon rather than chosen to resemble it: the
silver device ramp, the screen blues, and the amber EQ bars. Dark theme
builds down from the screen navy; light theme uses the device body with
the deeper blue as accent.

Contrast was measured, not assumed: --faint, which carries dates and
sizes, failed AA in both themes and was moved along the icon's own grey
ramp until it passed. Lowest pair anywhere is now 4.54.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
2026-09-10 02:08:59 +00:00
e05ea62051 Use the original iPodderX icon instead of a placeholder
Recovered from the Internet Archive's capture of ipodderx.com: the icon
was never in either repo, since only the Python engine was open-sourced
and the .icns lived in the Cocoa bundle. Kept as a file in web/ and
embedded as a data URI for the sidebar mark and the favicon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
2026-09-10 02:03:48 +00:00
aecad18d22 Make the feed URL editable, with a copy button
Entries and history are keyed by feed id, so changing a URL keeps them --
the point being that a feed URL can carry an auth token that gets rotated.
Changing it clears the stored ETag/Last-Modified, which belong to the old
URL and could otherwise produce a bogus 304.

The copy button cannot use navigator.clipboard: that needs a secure
context and this is served over plain HTTP on a LAN address. Falls back to
execCommand.

Invalid input now returns 400 rather than 500.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
2026-09-10 02:00:20 +00:00
44623098ae Mark episodes read when finished, not when started
play() set read=1 the instant playback began. The default view is the
Unread tab, so pressing play removed the episode from the list being
looked at, which reads as the episode going missing. All four affected
rows had position=0: started, never listened to.

Read is now set on 'ended' or past 90% of the duration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
2026-09-10 01:55:46 +00:00
5666166769 Full-featured web UI
Rewrites the page around a persistent player (speed, seek, resume,
MediaSession, keyboard shortcuts), artwork, filter tabs, episode search,
pagination and live progress, with modals and toasts replacing prompt()
and a status line.

Backend gains the metadata that makes that possible: feed and episode
artwork, durations, season/episode numbers and playback position, plus
filters, search, totals, mark-all-read, download-latest and OPML over
HTTP. Schema changes arrive through a real migration, since CREATE TABLE
IF NOT EXISTS does nothing to an installed database.

Fixes filtering, which returned 500 whenever no search term was given:
the search clause was dropped while its parameter was still bound.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
2026-09-10 01:42:55 +00:00
74ec6e9281 Phase 2: web front end
axum served from inside the daemon so it reads SQLite and the event bus
directly: browse feeds, read show notes, play with seeking, download and
delete files, mark read/flag, and edit feed settings.

Config is now hot-reloadable (Ctx.cfg behind RwLock<Arc<Config>>), so UI
edits apply without a daemon restart. Access is a shared token minted from
/dev/urandom, carried in a cookie because an <audio> element cannot send
headers. Show notes are untrusted feed HTML and are sanitized with ammonia
server-side.

read/flagged finally have a writer, which retention has needed since it
started ordering by them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
2026-09-10 00:55:16 +00:00