Read, starred and position move to entry_state; subscriptions carry each
person's keywords, auto-download, explicit and per-scan limit. The feed
list and unread counts are per person, and the existing library is
adopted by the admin on first start.
The feed URL, folder and schedule stay shared and admin-only: one file
serves everyone, so they describe the file rather than a preference.
Scanning merges subscribers' wants -- anyone wanting an item is enough --
via merge_policy, which is pure and tested.
Also: the test fixture wiped its data directory from every Playwright
worker, deleting the database out from under the running daemon.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
The per-feed schedule picker is gone and global Settings is admin-only,
enforced in the handlers with 403s rather than just hidden: polling costs
bandwidth and affects everyone reading the feed, so it belongs to the
operator. Folders, keywords and per-feed limits stay open to anyone.
docs/sso.md covers Cloudflare Zero Trust and Authentik end to end,
including why trusted_proxies names the proxy and not a subnet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
epAction's redraw closure called itself when handed a row, so Mark read
recursed until the stack blew; it now swaps that row in place. Opening an
item also marks it read, redrawn where it stands so nothing vanishes from
under the pointer on the Unread tab.
Step A of multi-user: users and sessions tables, Argon2id, a session
cookie, ipx user subcommands, and a trusted proxy header for Cloudflare
Zero Trust -- honoured only from a trusted_proxies address. The shared
token still works and is the admin. A new database starts with
admin/ipodderx.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
Half the library is text feeds, so the UI no longer assumes a podcast:
counts, search, the empty detail pane, the phone back button, retention
and per-feed settings, and the download dialog all say item. S1E1 badges
and the episode column stay -- those are the itunes:episode field, which
only appears when a feed publishes it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
85 feeds meant 85 feed_done events, each rebuilding the sidebar and
reloading the episode list. Bursts collapse into one refresh, per-feed
"N new" toasts add up into one summary, and both lists keep their scroll
position across a rebuild. A full scan now costs 11 feed refreshes
instead of 85.
Settings and Log move to a footer under the feed list.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
Opening a feed whose episodes are all read showed an empty list, so All
leads the tabs and is the default. OPML import/export moves under
Settings -- an occasional job, not a daily control -- freeing the
sidebar. Feed actions become icon pills, with Unsubscribe pushed to the
far end and quietened; it sat beside Settings looking identical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
The ☰ button lived in the player bar, which is hidden until something
plays, so the feed list was unreachable on a phone. It moves to a bar
that is always present, and the sidebar gets a scrim.
The reading pane takes the whole screen over the list with a back
button, the player stacks into two rows above it, and the page no longer
scrolls sideways -- a grid column is min-content wide by default, so one
long headline dragged everything off the right edge.
Covered by a Playwright case at 390x844.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
Marking the subscription read did nothing: its own row holds no entries.
read-all now resolves the feeds grouped under the id -- via
subscriptions(), so a child promoted to config is included -- and marks
those. Button sits before Unsubscribe, where every other feed keeps it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
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
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
Resolves in order of deliberateness: itunes:image, media:thumbnail, a
media:content that says it is an image, then an image enclosure -- which
is where a blog's article picture actually lives, so those entries had
artwork available all along and showed none. Audio enclosures are never
taken for pictures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
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
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
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 & 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
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
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
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
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
Moving OPML feeds into the database left several call sites still
searching config.toml only, so anything inside a subscription looked
unsubscribed: Download failed outright, status and the startup line
counted 3 feeds instead of 85, add could duplicate or collide with a
derived feed, and rm could not remove one.
The first grep for this missed the failing call because the method chain
spans lines; searching with newlines collapsed found all of them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
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
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
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
A torrent ran inline on the single sequential worker, so it blocked every
feed scan, HTTP download and status command behind it -- for up to
stall_mins waiting on metadata, and for up to seed_time_mins seeding after
finishing. A live daemon was wedged with 47 pending torrents and would not
answer a status command for 15s.
spawn_torrent detaches the job behind a 2-permit semaphore and marks the
row 'downloading' so a rescan cannot queue it twice. One-shot CLI runs
stay inline, or the process would exit mid-download.
Torrents themselves verified working: a 755 MB Debian netinst downloaded
to completion against a real swarm.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
"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
$('#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
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
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
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
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
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
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
Confirmed byte-for-byte against a live feed, and tested against a fixture
whose itunes:title differs: the RSS title wins, and season/episode stay
metadata rather than being folded into the displayed name. An item with a
season but no episode number keeps a null episode instead of inventing one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
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
POST /api/enclosures/{id}/download requeued the row and asked for a
normal scan, but a scan takes the lowest-id pending rows up to
max_new_per_check. With a large backlog and a small cap the requested
row was never a candidate, so other episodes downloaded while it stayed
pending.
A queue expresses what is outstanding, not what was asked for. Download
is now its own command that fetches one specific enclosure immediately,
ignoring queue order and the per-scan cap, still via the single worker.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
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
Splits the forbidden set: / \ | : were separating words, so they become
"-"; ? * < > " ' just go. Runs of dashes and spaces collapse to " - "
when the run held whitespace and to a bare "-" when it did not, so
"Show | Series" reads "Show - Series" while "AC/DC" stays "AC-DC".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
A real feed titled with pipe separators produced a folder named
"Get in the Trunk Anthology Series Delta Green": removing a forbidden
character left the gap around it. Runs of whitespace now collapse, and
control characters map to a space rather than vanishing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
librqbit replaces the vendored BitTorrent 4.2.1 tree. Torrents download
in place because seeding serves the files it downloaded, so the planned
stage-then-move would have broken it. The stall budget now also covers
magnet metadata resolution, which otherwise never returns against a dead
swarm and wedged the scan.
Adds add/rm/import/export, tracing setup, systemd units and README.
A successful swarm download is unverified: no reachable peers here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
Unix-socket JSON-lines protocol replacing the printMSG sentinels, with
an Emitter so scan code is agnostic about whether a terminal or a UI is
watching. Daemon serves clients and runs a TTL-aware scheduler; commands
funnel through one worker so scans cannot overlap, and the CLI proxies to
a running daemon rather than competing with it.
The pre-fetch retention sweep was emitting the terminal ReapDone event,
which would have ended a client's read before the scan began.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
Oldest-first reaper with the original's 50 MB headroom pad, plus a
reconcile pass for files deleted by hand and pruning of stale entries.
The Python meant to reap only read, unflagged episodes but a missing
import and a typo made that filter throw on every candidate. Requiring
read=1 would be equally dead headless, so flagged is the keep-forever
marker and read only decides ordering.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
Streaming download with progress, content sniffing that replaces the
long-dead detectFileType(), HTML-body rejection, and placement by rename
from an incomplete dir on the same filesystem.
Filters are applied once at discovery and recorded in enclosures.state,
so the download queue is the table rather than the parse result and
max_new_per_check defers work instead of dropping it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
Conditional GET plus an RSS-first, Atom-fallback parser normalising both
into one entry model, with iTunes explicit and ttl handling carried over
from the Python. Enclosures are recorded as pending; nothing downloads yet.
GUID falls back guid -> link -> enclosure url -> title rather than hashing
the description as the original did.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
config.rs replaces iPXSettings.py and feeds.plist; db.rs replaces the
per-feed .ipxd plists, history.dat and qmcache.dat. enclosures.url is
UNIQUE, which is the dedupe key the old pickle history provided.
ipx list is the first working subcommand.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
Rust rewrite of ipodderx-core: cargo project, pinned dependency set,
MIT license carrying the original 2010 copyright, README lineage note,
and PROGRESS.md as the resumable build record.
Dependency tree is rustls-only: librqbit's default features pull in
native-tls and an OpenSSL sha1 backend, so it is built with rust-tls.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe