17 Commits

Author SHA1 Message Date
0443177471 Release 0.6.1
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 19:19:44 +00:00
0a83c716eb Time left and finished go by the length the player measured
A feed can be minutes out: ReThinking's gave 41:23 for a 43:48 file,
which read 0:08 left with 2:33 to play. The player's length is kept in
entry_state beside the position, per listener, where no scan can put
the feed's figure back, and preferred to the feed's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 18:39:40 +00:00
b9e0d9f3cb Save a position only from a player that has played since its last save
A tab left paused further into an episode saved its older place as it
reloaded, over where the listener had got to since, and the episode
dropped out of Currently Listening. A jump back is now saved at once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 18:29:10 +00:00
2dee3b722c Release 0.6.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 18:13:29 +00:00
8daa7a1991 Currently Listening: the EQ bars mark what is playing
The row in the player gets the amber EQ bars, as the item list's does,
and its progress rail and time left move as it plays. Rows say how
much is left, and their buttons are quiet so that row stands out.

savePos no longer saves before the file has loaded: currentTime is 0
then, and a failed load or an early pause wiped the saved position.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 18:08:21 +00:00
f89ca2aceb Currently Listening: a cross takes an episode off the list
It forgets the saved position, which is what puts an episode on the
list. The one in the player is closed without saving first, or its
next save would put it straight back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 17:54:17 +00:00
57a6fb5daa Currently Listening: finished is 90% played, not read
Opening an episode marks it read, so filtering on read hid every
episode anyone had started. The player now also reports the length it
measured, filling in one the feed left out. Fixes #14.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 17:47:57 +00:00
49dafedbbc Inter, one file where WordPress listed two, and a pin heading on line
Inter (#11): the pages are set in Inter's variable font, served from the
binary at /inter.woff2 as the icon is, with its OFL licence beside it in
web/. Classic keeps Lucida Grande, the 2004 app's face.

Double audio (#12): WordPress numbers each audio player on a page by
adding ?_=N to its file's URL, so a post that embeds the file it encloses
listed it twice, and it was downloaded twice. The parser keeps the first
of an item's enclosures that differ only by that number. At startup the
repeats already stored fold into the first; where only the repeat had
been downloaded its file moves to the first rather than being deleted.

Pin heading (#13): the rows' icon buttons kept the browser's side
padding, which pushed their 16px icon 3px right of centre, and the
heading's icon sat at the left of its column. Both are centred now, and
the heading row takes the pixel of border the rows have, so every
heading sits over its column.

Closes #11, closes #12, closes #13.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 17:31:03 +00:00
b83523fc92 Directory: let an admin give a blog its category
Almost no blog names a category the Directory can use, so a feed can
carry one of its own in config.toml, set by an admin in the feed's
settings and used when the feed names none. The feed's own iTunes
category still wins. The field offers the categories the Directory
already shows, so a blog about games joins Games rather than starting a
second chip. Setting it on a feed from an OPML promotes it to config, as
any other shared setting does.

Closes #10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 17:19:59 +00:00
8ae5c332c3 Pin, not flag
Keeping an item is pinning it now: a thumbtack where the flag was, and
Pin, Pinned and Unpin where Keep, Kept and Stop keeping were, on the
toolbar, the item's own buttons, the filter tab, the table column, the
retention hint and the warning before deleting a shared file. Pinned is
the solid thumbtack and not pinned the same shape outlined, as the flag
had its regular and solid pair. The API and database keep `flagged`.

The icon test compared glyphs by their path alone, which the two pins
share; it compares the whole glyph now.

Closes #9.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 17:14:59 +00:00
420f9eb9a0 Keyboard shortcuts, after Feedly's
j/n and k/p step through the items, Shift-J and Shift-K through the feed
list, and g with a letter goes to a place: All Subscriptions, Directory,
Popular, Currently Listening, Settings. o plays the selected item, m marks
it read or unread and s keeps it, each by pressing the toolbar's own
button; v opens the original, Shift-A marks all read, r refreshes, [ hides
the feed list, and ? lists them all. None fire while typing, with a
modifier held, or with a dialog open.

Closes #8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 17:08:54 +00:00
4314b11237 Retire davewiner's 922 rows, without taking the ones people still read
davewiner's OPML left config.toml before retire_group existed, so its
derived rows were skipped by every scan but never cleared. At startup the
daemon now retires every group whose parent is gone from config. And
retire_group unmanages a feed that has its own config entry instead of
dropping it: eleven of davewiner's were promoted without being unmanaged,
and dropping them as derived would have deleted their entries. That also
covers removing an OPML or Patreon subscription from the page.

Closes #3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 17:01:27 +00:00
eaea520020 Give Currently Listening its own place, below Popular
It was a section at the bottom of the Popular page, so finding the
episode you were halfway through meant opening a list of feeds first.
It is a place in the feed list now, between Popular and All
Subscriptions, with its own page and count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 16:49:11 +00:00
b8d22904f1 Read a title's HTML entities as the characters they stand for
An Atom title of type="html", and an RSS title in CDATA, reach the parser
with their entities intact, so The Verge's "Meta&#8217;s" showed as typed:
55 stored titles across 17 feeds. Titles are decoded one entity at a time
with quick-xml's HTML5 table, leaving an & that starts none ("Q&A") alone
rather than failing the whole title.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 16:46:47 +00:00
cbd16ce3f6 Directory: Podcasts and Blogs as a filter, beside the category chips
What a feed is and what it is about are two questions, so they are two
controls that combine: the same .tabs the item filters use for All,
Podcasts and Blogs, and a row of category chips that is always there,
offering only the categories among the feeds the filter lets through. A
picked chip lifts on a second press, so there is no second All.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 16:30:59 +00:00
017cfd7e28 Directory: file a show under its iTunes subcategory where it has one
Apple puts every tabletop and gaming show under Leisure, so the top level
alone put most of this server's podcasts behind one chip. Games says what
they are; a show with no subcategory keeps its top-level one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 16:20:49 +00:00
954173cacf Directory: chips by kind and category over a grid of cover art
Feeds take their channel's first <itunes:category> into a new feeds.category
column; the migration drops ETag and Last-Modified once so every feed re-reads
on its normal schedule and picks one up. /api/popular and /api/directory carry
category and podcast (any audio or video enclosure). Directory becomes a grid of
cover-art tiles under a chip rail: All, Podcasts, Blogs, and a podcast's
categories once Podcasts is picked. Popular and Add a feed keep their rows.

Closes #4, closes #5, closes #6, closes #7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 16:09:47 +00:00
22 changed files with 1130 additions and 139 deletions

View File

@@ -10,6 +10,64 @@ The long form, with what was wrong before and how it was found, is in
## [Unreleased]
## [0.6.1] - 2026-09-15
### Fixed
- Time left, and when an episode counts as finished, go by the length your player measured
rather than the feed's, which can be minutes out: one episode said 0:08 left with 2:33 to play.
- A player left open in another tab or on another device no longer saves its older place over
where you have got to since, which could drop an episode out of Currently Listening.
## [0.6.0] - 2026-09-15
### Added
- Each episode in Currently Listening has a cross that takes it off the list. It forgets where you
got to, so playing it again starts from the beginning.
- An admin can give a feed a Directory category in its settings (`category` in config.toml), for
the blogs and other feeds that name none of their own. A feed's own iTunes category still wins.
- Keyboard shortcuts after Feedly's: j and k through items, Shift-J and Shift-K through feeds,
g and a letter to go to a place, o to play, s to pin, and more. Press ? for the whole list.
- Directory can be filtered to Podcasts or Blogs, and by each show's own iTunes category as a row
of chips, the narrower one where a show gives two (Games, not Leisure). The two combine, and
both filter in place.
### Changed
- Currently Listening marks the episode in the player with the EQ bars, as the item list does,
and its progress and time left move as it plays. Each row says how much is left.
- Directory shows each feed as its cover art in a grid, title and subscriber count underneath,
instead of a list. Popular and the Add a feed dialog keep their rows.
- The pages are set in Inter, served by ipx itself. Classic keeps Lucida Grande.
- Keeping an item is now pinning it: a thumbtack in place of the flag, and Pin, Pinned and Unpin
in place of Keep, Kept and Stop keeping. A pinned item is still never deleted.
- Currently Listening is its own place in the feed list, below Popular, instead of a section at
the bottom of the Popular page.
- The first scan after upgrading fetches every feed in full once, on its usual schedule, so each
picks up its category without waiting for the publisher to change something.
### Fixed
- An episode that fails to load, or is paused before it has, no longer forgets where you left off
in it, and so no longer drops out of Currently Listening.
- Currently Listening lists the episodes you have started. It left out anything marked read, and
opening an episode marks it read, so it usually showed nothing. An episode now leaves the list
once 90% of it has played.
- A WordPress post that embeds the file it encloses no longer lists, and downloads, that file
twice. Items that already had it twice are folded into one at startup, and the spare copy
deleted.
- The pinned column's heading lines up with the pins under it, and every heading sits a pixel
further right, over its column.
- The feeds left behind by an OPML subscription removed before ipx retired them are cleared at
startup: forgotten if nothing was downloaded, kept as orphaned if something was. Feeds from it
that have since been given their own settings stay as they are, with their items. Removing an
OPML or Patreon subscription no longer deletes the items of a feed inside it that has its own
settings.
- Titles that arrive as HTML, such as The Verge's, no longer show their entities as text:
"Meta&#8217;s" reads "Metas". Titles already stored are corrected the next time their feed
changes.
## [0.5.5] - 2026-09-14
### Changed
@@ -372,7 +430,9 @@ The long form, with what was wrong before and how it was found, is in
- Torrent enclosures through librqbit, seeding to a ratio or a time, with a stall timeout.
- `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`.
[unreleased]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.5...main
[unreleased]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.1...main
[0.6.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.0...v0.6.1
[0.6.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.5...v0.6.0
[0.5.5]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.4...v0.5.5
[0.5.4]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.3...v0.5.4
[0.5.3]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.2...v0.5.3

2
Cargo.lock generated
View File

@@ -1605,7 +1605,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
[[package]]
name = "ipx"
version = "0.5.5"
version = "0.6.1"
dependencies = [
"ammonia",
"anyhow",

View File

@@ -1,6 +1,6 @@
[package]
name = "ipx"
version = "0.5.5"
version = "0.6.1"
edition = "2024"
[dependencies]
@@ -15,7 +15,7 @@ futures-util = { version = "0.3.34", default-features = false, features = ["std"
librqbit = { version = "9.0.1", default-features = false, features = ["rust-tls", "http-api-client"] }
opml = "1.1.6"
percent-encoding = "2.3.2"
quick-xml = "0.42.0"
quick-xml = { version = "0.42.0", features = ["escape-html"] }
reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "http2", "gzip", "stream", "json", "charset", "system-proxy"] }
rss = "2.1.1"
rusqlite = { version = "0.40.2", features = ["bundled"] }

View File

@@ -12,7 +12,7 @@ behind iPodderX (2004-2008, Ray Slakinski & August Trometer).
- **The web UI.** It has a toolbar, and a feed list that opens with Directory, Popular and All
Subscriptions. Items sit in a sortable table with a Files pane, and there is a player bar. It
comes in Dark, Light and Classic themes, and works on a phone.
- **Several people, one copy.** Each person has their own subscriptions and their own read, kept
- **Several people, one copy.** Each person has their own subscriptions and their own read, pinned
and playback state. There is one file on disk per episode, however many people want it. People
sign in with a password or through a proxy (Cloudflare Zero Trust or Authentik), and admins
manage accounts and settings.
@@ -21,7 +21,7 @@ behind iPodderX (2004-2008, Ray Slakinski & August Trometer).
on new downloads per scan.
- **Downloads.** Files come over HTTP or BitTorrent and are filed into a folder per feed.
Retention deletes the oldest files to stay under a disk quota or an age limit, and never touches
an item someone has kept.
an item someone has pinned.
- **OPML.** You can import and export your own subscriptions. You can also subscribe to an OPML
URL, which keeps a whole list in step as a folder.

View File

@@ -119,7 +119,7 @@ else a `401`.
| `POST /api/enclosures/{id}/download`, `DELETE /api/enclosures/{id}` | `?force=true` overrides the shared-file warning |
| `POST /api/fetch` | |
| `GET /api/opml`, `POST /api/opml` | export your subscriptions; subscribe to every feed in an OPML |
| `GET /api/popular`, `GET /api/directory`, `POST /api/popular/{id}` | the ten most subscribed feeds, and every listable feed A to Z, with an OPML's feeds in place of the OPML and everyone counted (id, title, art, count, whether it is yours; never a URL, never a private feed); subscribe by id |
| `GET /api/popular`, `GET /api/directory`, `POST /api/popular/{id}` | the ten most subscribed feeds, and every listable feed A to Z, with an OPML's feeds in place of the OPML and everyone counted (id, title, art, count, whether it is yours, the feed's iTunes category, whether it carries audio or video; never a URL, never a private feed); subscribe by id |
| `GET /api/settings`, `PATCH /api/settings` | admin-only to write |
| `GET /api/users`, `POST /api/users`, `PATCH /api/users/{id}`, `DELETE /api/users/{id}` | admin-only; the only admin cannot be demoted or removed |
| `GET /api/events` | SSE, the same broadcast the socket carries |

View File

@@ -97,6 +97,7 @@ url = "https://atp.fm/rss"
folder = "Accidental Tech Podcast" # default: the feed title
schedule = "every 6h" # overrides [general] for this feed
media_types = ["audio"] # overrides [general] for this feed
category = "Technology" # the Directory's, if the feed names none
username = "ray" # HTTP basic auth
password_env = "IPX_ATP_PASS" # preferred over a literal `password`
```

View File

@@ -6,6 +6,68 @@ reasoning lives. New write-ups go at the top.
See [README.md](../README.md) for what the thing is.
## 2026-09-15 — Kristen Bell again: a length minutes out, and an idle player
After 0.6.0 the Kristen Bell episode dropped out of Currently Listening again, with eleven
minutes left in the player. Its saved position was 41:15 of a file 43:48 long, 94% and finished
by any measure, while the listener was at about 32:48. The first guess was a wrong length from
the feed, and it was wrong: the feed does say 41:23, but no length makes 41:15 unfinished.
The log had the answer. Saves every ten seconds while it played, then two that were not
playback: one beside the only request for the file since the restart, and the last in the same
millisecond as a page load. The likeliest reading was a player left paused further on, in
another tab or on another device, saving its place as its page reloaded, since every save goes
through `savePos` and it saved whatever the player held whether or not anyone had played it
since. That was never confirmed. Fifteen minutes later the listener was playing on from 41:15
and heard 2:33 left, which fits that position having been theirs all along.
`savePos` now saves only once the player has played since its last save, so an idle one never
writes, and only playing counts: the seek to where you left off happens paused, and counting it
would save back whatever the list said, however old. A jump back is also saved at once, where
the ten-second check only ever looked forward. The fixture audio does not decode, so the browser
test stands in for a loaded player and counts what `savePos` sends.
What was certainly wrong was the length. At 41:15 the item list said 0:08 left and the player
2:33: the feed gives 41:23 for a file that decodes to 43:48. The length the player measures now
goes into `entry_state` beside the position, and wins over the feed's for the 90% line and every
time left. Not into `entries`, where each scan writes the feed's figure back, and per listener,
so one person's player never changes what another sees.
## 2026-09-15 — Currently Listening, empty for anyone who opens what they play
Issue #14: an episode 32 minutes into 41 was missing from Currently Listening, which said nothing
was in progress. The filter was "position past five seconds and unread", on the reasoning that
`markPlayed` marks an episode read at 90%. But opening an item marks it read too, and you open an
episode to play it. In production every one of the twelve episodes with a saved position was read,
so the list was empty for everyone. The browser test for it had set the episode unread before
checking, to cope with an earlier test having opened it, and so tested around exactly this.
Finished now means the saved position is 90% of the episode's length or more, the same line
`markPlayed` draws, and read plays no part. Three of those twelve episodes had no length in their
feed, and with no length there is no telling finished from started, so they would have stayed
listed for good. The player now sends the length it measured with each saved position, and that
fills in a missing one, never replacing a length the feed gave.
## 2026-09-15 — davewiner's 922 rows, retired at last
davewiner's OPML subscription left config.toml before `retire_group` existed, so nothing ever
retired the 922 feeds derived from it. The backstop in `subscriptions()` kept them from being
scanned, but the rows stayed, and 56 of the 75 errors stored in production were theirs: stale, and
never going to change.
Retiring them the obvious way would have done damage. Eleven of the 922 -- xkcd, The Verge,
TechCrunch, Hacker News and others -- had since been given config entries of their own and were
scanned from there, but their rows still said `managed = 1`. `managed_feeds()` returned them, so
`retire_group("davewiner")` would have dropped the ten with no files as derived feeds, deleting
the entries people were reading. `unmanage` exists for exactly this and had never been applied.
`retire_group` now unmanages a feed that has its own config entry instead of dropping it, which
also covers removing any OPML or Patreon subscription from the page, and the daemon retires every
group whose parent is gone from config when it starts. In production that is davewiner alone: 792
rows with nothing downloaded forgotten, 119 with files kept as orphaned, 11 unmanaged. One of the
792, `ars-technica-all-content-2`, has a subscriber but neither a config entry nor a file; the
backstop had already hidden it, so nobody could see it to lose it.
## 2026-09-14 — Settings, for everyone with an account
A user reported that Settings disappeared shortly after they signed in: it showed for a moment,

View File

@@ -8,7 +8,7 @@ fetch, one parse and one file.
| Yours alone | The same for everyone |
|---|---|
| Read, kept, playback position | The feed's URL |
| Read, pinned, playback position | The feed's URL |
| Which feeds you see at all | Its download folder |
| Keywords, auto-download, explicit, per-scan cap | When it is scanned |
| | The file on disk |
@@ -34,10 +34,10 @@ is shared.
Deleting a file deletes everyone's copy. A feed with other subscribers labels the button **Delete
for everyone** and names them in the confirmation, and the server has the last word: if anyone else
has kept the item or not played it yet, `DELETE /api/enclosures/{id}` answers `409` with the
has pinned the item or not played it yet, `DELETE /api/enclosures/{id}` answers `409` with the
reason, and only `?force=true` goes through.
Retention follows the same rule: an item anyone kept keeps its file, and it counts as read only once
Retention follows the same rule: an item anyone pinned keeps its file, and it counts as read only once
every subscriber has read it.
## Signing in
@@ -72,7 +72,9 @@ re-subscribing does not pull the back catalogue again.
**Popular** and **Directory** sit at the top of the feed list, above your own feeds. Popular, also
shown in the Add feed dialog, lists the ten feeds with the most subscribers on this server, you
included. Directory lists every one of them A to Z. Your own feeds are marked Subscribed.
included. Directory shows every one of them A to Z as a grid of cover art. Above it, a filter
picks Podcasts (anything with audio or video) or Blogs (the rest), and chips pick the category
each show gives itself in iTunes; the two combine. Your own feeds are marked Subscribed.
It shows a title, artwork and a count, never a URL or who reads it. An OPML subscription is listed
as the feeds inside it, one by one, and never the OPML itself, so you can take just the shows you
want. Anything that looks private is left out: a login configured for the feed, credentials in its URL,

View File

@@ -115,6 +115,10 @@ pub struct Feed {
/// Download folder name; defaults to the sanitized feed title.
#[serde(skip_serializing_if = "Option::is_none")]
pub folder: Option<String>,
/// The Directory's category for a feed that names none of its own, as most blogs do not.
/// The feed's own iTunes category wins where there is one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
/// Every whitespace-separated word of a keyword must appear in the
/// url/title/description/categories for an enclosure to be taken.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
@@ -469,7 +473,7 @@ mod tests {
taken.insert("the-daily".to_string(), Feed {
url: "u".into(), folder: None, group: None, media_types: None, schedule: None, keywords: vec![], allow_explicit: false,
auto_download: true, max_new_per_check: None, username: None,
password: None, password_env: None,
password: None, password_env: None, category: None,
});
assert_eq!(unique_slug("The Daily", &taken), "the-daily-2");
}
@@ -489,6 +493,7 @@ mod tests {
username: Some("ray".into()),
password: Some("literal".into()),
password_env: None,
category: None,
};
assert_eq!(f.password().as_deref(), Some("literal"));

240
src/db.rs
View File

@@ -17,6 +17,8 @@ CREATE TABLE IF NOT EXISTS feeds (
url TEXT NOT NULL,
title TEXT,
image TEXT,
-- The channel's first <itunes:category>, for the Directory.
category TEXT,
etag TEXT,
last_modified TEXT,
last_checked INTEGER,
@@ -103,6 +105,8 @@ CREATE TABLE IF NOT EXISTS entry_state (
read INTEGER NOT NULL DEFAULT 0,
flagged INTEGER NOT NULL DEFAULT 0,
position INTEGER NOT NULL DEFAULT 0,
-- The length this person's player measured, beside the position it is measured against.
duration INTEGER,
PRIMARY KEY (user_id, feed_id, guid)
);
@@ -170,6 +174,8 @@ fn migrate(conn: &Connection) -> Result<()> {
("users", "created", "INTEGER"),
("users", "last_login", "INTEGER"),
("feeds", "error_since", "INTEGER"),
("feeds", "category", "TEXT"),
("entry_state", "duration", "INTEGER"),
];
let retired: &[(&str, &str)] = &[
// Read state from before accounts, long since moved to entry_state. Two bugs came from
@@ -192,6 +198,13 @@ fn migrate(conn: &Connection) -> Result<()> {
if !has(table, column)? {
tracing::info!(table, column, "adding column");
conn.execute_batch(&format!("ALTER TABLE {table} ADD COLUMN {column} {ty}"))?;
// A category is only read from a 200, and a feed with a validator mostly gets a
// 304, so most would never pick one up until the publisher changed something.
// Dropping the validators once makes each re-read on its normal schedule; leaving
// last_checked alone, unlike clear_validators, keeps them from all coming due at once.
if (*table, *column) == ("feeds", "category") {
conn.execute_batch("UPDATE feeds SET etag = NULL, last_modified = NULL")?;
}
}
}
for (table, column) in retired {
@@ -208,6 +221,7 @@ fn migrate(conn: &Connection) -> Result<()> {
pub struct FeedSummary {
pub title: Option<String>,
pub image: Option<String>,
pub category: Option<String>,
/// Came from a subscribed OPML that no longer lists it, but it has downloads, so it
/// was kept rather than removed.
pub orphaned: bool,
@@ -256,7 +270,8 @@ impl Db {
let conn = self.conn.lock().unwrap();
let mut sum: FeedSummary = conn
.query_row(
"SELECT title, image, last_checked, last_error, coalesce(orphaned, 0), error_since
"SELECT title, image, last_checked, last_error, coalesce(orphaned, 0), error_since,
category
FROM feeds WHERE id = ?1",
[feed_id],
|r| {
@@ -267,6 +282,7 @@ impl Db {
last_error: r.get(3)?,
orphaned: r.get::<_, i64>(4)? != 0,
error_since: r.get(5)?,
category: r.get(6)?,
..Default::default()
})
},
@@ -328,11 +344,14 @@ impl Db {
last_modified: Option<&str>,
ttl_mins: Option<u64>,
image: Option<&str>,
category: Option<&str>,
) -> Result<()> {
let conn = self.conn.lock().unwrap();
// category is taken as it comes, unlike title and image: a show that leaves a category
// should leave the Directory's chip too.
conn.execute(
"INSERT INTO feeds (id, url, title, etag, last_modified, last_checked, ttl_mins, last_error, image)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8)
"INSERT INTO feeds (id, url, title, etag, last_modified, last_checked, ttl_mins, last_error, image, category)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8, ?9)
ON CONFLICT(id) DO UPDATE SET
url = excluded.url,
title = coalesce(excluded.title, feeds.title),
@@ -341,9 +360,10 @@ impl Db {
last_checked = excluded.last_checked,
ttl_mins = excluded.ttl_mins,
image = coalesce(excluded.image, feeds.image),
category = excluded.category,
last_error = NULL,
error_since = NULL",
rusqlite::params![feed_id, url, title, etag, last_modified, now(), ttl_mins.map(|t| t as i64), image],
rusqlite::params![feed_id, url, title, etag, last_modified, now(), ttl_mins.map(|t| t as i64), image, category],
)?;
Ok(())
}
@@ -685,9 +705,12 @@ pub enum Filter {
Unread,
Downloaded,
Flagged,
/// Started (a saved playback position past the first few seconds) but not finished
/// (`markPlayed` in the UI marks an item read at 90% played, so unread is "not finished"
/// here too). Currently Listening, below Popular, is this filter on every feed at once.
/// Started (a saved playback position past the first few seconds) and short of the 90%
/// where `markPlayed` in the UI calls it finished. Not `read`: opening an item marks it
/// read, so filtering on that hid nearly every episode anyone had started. The length is the
/// one this person's player measured where there is one (`set_position`), else the feed's;
/// with neither, the episode counts as unfinished.
/// Currently Listening, below Popular, is this filter on every feed at once.
InProgress,
}
@@ -713,7 +736,11 @@ impl Filter {
"EXISTS (SELECT 1 FROM enclosures x
WHERE x.feed_id = e.feed_id AND x.guid = e.guid AND x.path IS NOT NULL)"
}
Self::InProgress => "coalesce(s.position, 0) > 5 AND coalesce(s.read, 0) = 0",
Self::InProgress => {
"coalesce(s.position, 0) > 5
AND (coalesce(s.duration, e.duration, 0) = 0
OR s.position * 10 < coalesce(s.duration, e.duration) * 9)"
}
}
}
}
@@ -751,7 +778,8 @@ impl Db {
.unwrap_or_default();
let sql = format!(
"SELECT e.guid, e.feed_id, e.title, e.link, e.published, e.description,
coalesce(s.read, 0), coalesce(s.flagged, 0), e.image, e.duration,
coalesce(s.read, 0), coalesce(s.flagged, 0), e.image,
coalesce(s.duration, e.duration),
e.episode, e.season, coalesce(s.position, 0)
FROM entries e
LEFT JOIN entry_state s
@@ -849,12 +877,26 @@ impl Db {
}
/// Where playback got to, so it resumes there next time -- for this listener only.
pub fn set_position(&self, user_id: i64, feed_id: &str, guid: &str, secs: i64) -> Result<()> {
/// `duration` is the length their player measured, kept beside the position and preferred to
/// the feed's for time left and for when an episode counts as finished. A feed can be minutes
/// out: ReThinking's gave 41:23 for a 43:48 file, which said 0:08 left with 2:33 to play.
/// Not written to `entries`, where every scan puts the feed's figure back, and kept per
/// listener so one person's player never changes what anyone else sees.
pub fn set_position(
&self,
user_id: i64,
feed_id: &str,
guid: &str,
secs: i64,
duration: Option<i64>,
) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO entry_state (user_id, feed_id, guid, position) VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(user_id, feed_id, guid) DO UPDATE SET position = excluded.position",
rusqlite::params![user_id, feed_id, guid, secs.max(0)],
"INSERT INTO entry_state (user_id, feed_id, guid, position, duration)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(user_id, feed_id, guid) DO UPDATE SET position = excluded.position,
duration = coalesce(excluded.duration, duration)",
rusqlite::params![user_id, feed_id, guid, secs.max(0), duration.filter(|d| *d > 0)],
)?;
Ok(())
}
@@ -972,6 +1014,17 @@ impl Db {
Ok(out)
}
/// Feeds with any audio or video enclosure: the Directory's Podcasts, with the rest Blogs.
/// Reaped files keep their rows, so a show whose files have all been purged still counts.
pub fn media_feeds(&self) -> Result<std::collections::HashSet<String>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT DISTINCT feed_id FROM enclosures WHERE mime LIKE 'audio/%' OR mime LIKE 'video/%'",
)?;
let out = stmt.query_map([], |r| r.get(0))?.collect::<rusqlite::Result<_>>()?;
Ok(out)
}
/// Who else would miss this file: subscribers other than `user_id` who have starred
/// the item or have not read it yet. Deleting is deleting their copy too.
pub fn others_wanting(&self, enclosure_id: i64, user_id: i64) -> Result<(i64, i64)> {
@@ -1317,6 +1370,58 @@ impl Db {
Ok(())
}
/// Folds enclosures of one item that `key` says are the same file into the first of them, for
/// WordPress's numbered player URLs (`feed::same_file_key`). The first is the one the parser
/// keeps, so it keeps its row, taking a repeat's file if it has none of its own; the repeats'
/// rows go. Returns how many went and the copies left spare, for the caller to delete.
pub fn merge_repeated_enclosures(&self, key: impl Fn(&str) -> String) -> Result<(usize, Vec<String>)> {
use std::collections::hash_map::Entry;
let mut conn = self.conn.lock().unwrap();
let tx = conn.transaction()?;
let rows: Vec<(i64, String, String, String, Option<String>)> = {
let mut stmt = tx.prepare(
"SELECT id, feed_id, guid, url, path FROM enclosures
WHERE (feed_id, guid) IN
(SELECT feed_id, guid FROM enclosures WHERE url GLOB '*[?&]_=[0-9]*')
ORDER BY id",
)?;
stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)))?
.collect::<rusqlite::Result<_>>()?
};
// The first row of each file, and whether it has the file on disk yet.
let mut first: std::collections::HashMap<(String, String, String), (i64, bool)> = Default::default();
let (mut gone, mut spare) = (0, vec![]);
for (id, feed, guid, url, path) in rows {
match first.entry((feed, guid, key(&url))) {
Entry::Vacant(v) => {
v.insert((id, path.is_some()));
}
Entry::Occupied(mut o) => {
let (keep, has) = o.get_mut();
if let Some(p) = path {
if *has {
spare.push(p);
} else {
// The only copy is the repeat's: Rands' episode 97 was downloaded
// under its ?_=2 URL alone.
tx.execute(
"UPDATE enclosures SET (path, state, bytes_done, downloaded_at) =
(SELECT path, state, bytes_done, downloaded_at FROM enclosures WHERE id = ?2)
WHERE id = ?1",
params![*keep, id],
)?;
*has = true;
}
}
tx.execute("DELETE FROM enclosures WHERE id = ?1", [id])?;
gone += 1;
}
}
}
tx.commit()?;
Ok((gone, spare))
}
/// Stops treating a feed as derived, because it now has its own config entry.
pub fn unmanage(&self, id: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
@@ -1441,6 +1546,64 @@ pub fn now() -> i64 {
mod tests {
use super::*;
#[test]
fn a_file_wordpress_listed_twice_is_folded_into_one() {
let db = Db::memory().unwrap();
db.exec_for_test(
"INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
(1,'f','a','https://x/a.mp3','/d/a-2.mp3','done'),
(2,'f','a','https://x/a.mp3?_=2','/d/a.mp3','done'),
(3,'f','b','https://x/b.mp3',NULL,'reaped'),
(4,'f','b','https://x/b.mp3?_=2','/d/b.mp3','done'),
(5,'f','c','https://x/c.mp3?_=1','/d/c.mp3','done'),
(6,'f','d','https://x/d1.mp3?_=1','/d/d1.mp3','done'),
(7,'f','d','https://x/d2.mp3?_=2','/d/d2.mp3','done');",
)
.unwrap();
let key = crate::feed::same_file_key;
assert_eq!(db.merge_repeated_enclosures(key).unwrap(), (2, vec!["/d/a.mp3".to_string()]));
{
let conn = db.conn.lock().unwrap();
let ids: Vec<i64> = conn
.prepare("SELECT id FROM enclosures ORDER BY id")
.unwrap()
.query_map([], |r| r.get(0))
.unwrap()
.collect::<rusqlite::Result<_>>()
.unwrap();
assert_eq!(ids, [1, 3, 5, 6, 7], "a lone ?_=1 and two different files stay");
let (path, state): (String, String) =
conn.query_row("SELECT path, state FROM enclosures WHERE id = 3", [], |r| Ok((r.get(0)?, r.get(1)?))).unwrap();
assert_eq!((path.as_str(), state.as_str()), ("/d/b.mp3", "done"), "the only copy moves, not deleted");
}
assert_eq!(db.merge_repeated_enclosures(key).unwrap(), (0, vec![]), "and only once");
}
#[test]
fn adding_category_drops_validators_once_and_keeps_the_schedule() {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(SCHEMA).unwrap();
// A database from before the column, holding a feed that would answer 304.
conn.execute_batch(
"ALTER TABLE feeds DROP COLUMN category;
INSERT INTO feeds (id, url, etag, last_modified, last_checked) VALUES ('f','u','e','lm',5);",
)
.unwrap();
let row = |conn: &Connection| -> (Option<String>, Option<String>, Option<i64>) {
conn.query_row("SELECT etag, last_modified, last_checked FROM feeds", [], |r| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?))
})
.unwrap()
};
migrate(&conn).unwrap();
assert_eq!(row(&conn), (None, None, Some(5)), "re-read on its normal schedule");
// Only the once: every later open keeps the validators the next poll stored.
conn.execute_batch("UPDATE feeds SET etag = 'e2'").unwrap();
migrate(&conn).unwrap();
assert_eq!(row(&conn).0.as_deref(), Some("e2"));
}
#[test]
fn every_sort_column_runs_and_orders_both_ways() {
let db = Db::memory().unwrap();
@@ -1520,7 +1683,7 @@ mod tests {
// Starring and position are just as private.
db.set_entry_flag(1, "f", "b", EntryFlag::Flagged, true).unwrap();
db.set_position(2, "f", "b", 42).unwrap();
db.set_position(2, "f", "b", 42, Some(600)).unwrap();
let order = order_sql("published", "desc");
let page = |user| db.entries_in(user, Some("f"), Filter::All, None, 0, 50, &order).unwrap();
let (ray, sam) = (page(1), page(2));
@@ -1528,6 +1691,9 @@ mod tests {
let sam_b = sam.iter().find(|e| e.guid == "b").unwrap();
assert!(ray_b.flagged && ray_b.position == 0);
assert!(!sam_b.flagged && sam_b.position == 42);
// So is the length sam's player measured.
assert_ne!(ray_b.duration, Some(600));
assert_eq!(sam_b.duration, Some(600));
// Marking a whole feed read is likewise one person's business.
assert_eq!(db.mark_all_read(2, &["f".to_string()]).unwrap(), 2);
@@ -1659,13 +1825,14 @@ mod tests {
// so plain filtering failed with "Wrong number of parameters passed to query".
let db = Db::memory().unwrap();
db.exec_for_test(
"INSERT INTO entries (feed_id, guid, title, description, first_seen) VALUES
('f','a','Alpha dive','notes one',100),
('f','b','Beta', 'notes two',200),
('f','c','Gamma dive','notes three',300),
('f','d','Delta', 'notes four',400),
('f','e','Epsilon', 'notes five',500),
('f','g','Gimel', 'notes six',600);
"INSERT INTO entries (feed_id, guid, title, description, first_seen, duration) VALUES
('f','a','Alpha dive','notes one', 100,NULL),
('f','b','Beta', 'notes two', 200,NULL),
('f','c','Gamma dive','notes three',300,NULL),
('f','d','Delta', 'notes four', 400,NULL),
('f','e','Epsilon', 'notes five', 500,45),
('f','g','Gimel', 'notes six', 600,NULL),
('f','h','Heth', 'notes seven',700,900);
INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
(1,'f','b','u1','/tmp/b','done');
-- Read and starred belong to a person now, so say which one.
@@ -1673,12 +1840,15 @@ mod tests {
INSERT INTO entry_state (user_id, feed_id, guid, read, flagged, position) VALUES
(7,'f','b',1,0,0),
(7,'f','c',1,1,0),
-- Started and not finished: this is Currently Listening.
-- Started, length unknown: this is Currently Listening.
(7,'f','d',0,0,42),
-- Already finished: not Currently Listening, however far it got.
-- 42 of 45 seconds is past the 90% the player calls finished.
(7,'f','e',1,0,42),
-- Barely touched (opened, closed within seconds): not Currently Listening.
(7,'f','g',0,0,3);",
(7,'f','g',0,0,3),
-- Opened, and so read, but 42 of 900 seconds in: still Currently Listening.
-- Filtering on read hid exactly these (issue #14).
(7,'f','h',1,0,42);",
)
.unwrap();
@@ -1694,7 +1864,7 @@ mod tests {
assert_eq!(rows.len() as i64, n, "{f:?} with search disagrees");
}
assert_eq!(db.count_in(7, Some("f"), Filter::All, None).unwrap(), 6);
assert_eq!(db.count_in(7, Some("f"), Filter::All, None).unwrap(), 7);
assert_eq!(db.count_in(7, Some("f"), Filter::Unread, None).unwrap(), 3);
assert_eq!(db.count_in(7, Some("f"), Filter::Downloaded, None).unwrap(), 1);
assert_eq!(db.count_in(7, Some("f"), Filter::Flagged, None).unwrap(), 1);
@@ -1703,8 +1873,24 @@ mod tests {
"search is case-insensitive and covers the description");
// Currently Listening: started, not finished, and not just an accidental tap.
let listening = db.entries_in(7, Some("f"), Filter::InProgress, None, 0, 50, &order_sql("published", "desc")).unwrap();
assert_eq!(listening.iter().map(|e| e.guid.as_str()).collect::<Vec<_>>(), ["d"]);
let order = order_sql("published", "desc");
let listening = || {
let rows = db.entries_in(7, Some("f"), Filter::InProgress, None, 0, 50, &order).unwrap();
rows.into_iter().map(|e| e.guid).collect::<Vec<_>>()
};
assert_eq!(listening(), ["h", "d"]);
// The player's measured length is the one that counts, in place of a missing one or over
// the feed's: d, 42 of a measured 45 seconds, is finished; e, which its feed calls 45
// seconds long, is 42 into a 9000-second file and is not. Times left use it too.
db.set_position(7, "f", "d", 42, Some(45)).unwrap();
db.set_position(7, "f", "e", 42, Some(9000)).unwrap();
assert_eq!(listening(), ["h", "e"]);
let rows = db.entries_in(7, Some("f"), Filter::All, None, 0, 50, &order).unwrap();
assert_eq!(rows.iter().find(|r| r.guid == "e").unwrap().duration, Some(9000));
// A save without one (before the player knows) keeps the length already measured.
db.set_position(7, "f", "e", 43, None).unwrap();
assert_eq!(listening(), ["h", "e"]);
}
#[test]

View File

@@ -387,7 +387,7 @@ mod tests {
let mut f = crate::config::Feed {
url: "u".into(), folder: Some("Subscriptions/Some | Show".into()), group: None, media_types: None,
schedule: None, keywords: vec![], allow_explicit: false, auto_download: true,
max_new_per_check: None, username: None, password: None, password_env: None,
max_new_per_check: None, username: None, password: None, password_env: None, category: None,
};
assert_eq!(folder_for(&cfg, "id", &f, None), "Subscriptions/Some - Show");

View File

@@ -11,6 +11,8 @@ pub struct ParsedFeed {
pub title: Option<String>,
pub ttl_mins: Option<u64>,
pub image: Option<String>,
/// The channel's first `<itunes:category>`, for the Directory's chips.
pub category: Option<String>,
pub entries: Vec<Entry>,
}
@@ -478,7 +480,7 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
.filter_map(|(idx, item)| {
// Straight from the XML, so an item with several keeps all of them. Falls
// back to the parsed one if the scan and the parser disagree on item count.
let enclosures: Vec<Enclosure> = per_item.get(idx).cloned().unwrap_or_else(|| {
let mut enclosures: Vec<Enclosure> = per_item.get(idx).cloned().unwrap_or_else(|| {
item.enclosure()
.into_iter()
.map(|e| Enclosure {
@@ -489,6 +491,7 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
.filter(|e| !e.url.is_empty())
.collect()
});
drop_player_repeats(&mut enclosures);
let guid = pick_guid(
item.guid().map(|g| g.value()),
@@ -505,7 +508,7 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
Some(Entry {
guid,
title: non_empty(item.title()),
title: title_text(item.title()),
link: non_empty(item.link()),
published: item.pub_date().and_then(parse_date),
// Content wins over description, as __getEntries preferred entry.content.
@@ -527,7 +530,7 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
.collect();
ParsedFeed {
title: non_empty(Some(ch.title())),
title: title_text(Some(ch.title())),
ttl_mins: ch.ttl().and_then(|t| t.trim().parse().ok()),
// itunes:image is the square artwork; <image><url> is the older, often smaller one.
image: ch
@@ -535,6 +538,14 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
.and_then(|i| i.image())
.map(str::to_owned)
.or_else(|| ch.image().map(|i| i.url().to_owned())),
// Only the iTunes one: Apple's list is fixed, while a plain <category> is freeform and
// would fill the Directory with one-off tags. The subcategory where there is one: Apple
// files every tabletop and gaming show under Leisure, which says little; Games says it.
category: ch
.itunes_ext()
.and_then(|i| i.categories().first())
.map(|c| c.subcategory().filter(|s| !s.text().trim().is_empty()).unwrap_or(c))
.and_then(|c| non_empty(Some(c.text().trim()))),
entries,
}
}
@@ -545,7 +556,7 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
.iter()
.filter_map(|e| {
// Atom carries enclosures as <link rel="enclosure">.
let enclosures: Vec<Enclosure> = e
let mut enclosures: Vec<Enclosure> = e
.links()
.iter()
.filter(|l| l.rel() == "enclosure")
@@ -556,6 +567,7 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
})
.filter(|e| !e.url.is_empty())
.collect();
drop_player_repeats(&mut enclosures);
let alt = e
.links()
@@ -572,7 +584,7 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
Some(Entry {
guid,
title: non_empty(Some(e.title().as_str())),
title: title_text(Some(e.title().as_str())),
link: alt.map(str::to_owned),
published: e.published().or(Some(e.updated())).map(|d| d.timestamp()),
description: body(e.content().and_then(|c| c.value()), e.summary().map(|s| s.as_str())),
@@ -588,9 +600,10 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
.collect();
ParsedFeed {
title: non_empty(Some(feed.title().as_str())),
title: title_text(Some(feed.title().as_str())),
ttl_mins: None,
image: feed.logo().or_else(|| feed.icon()).map(str::to_owned),
category: None,
entries,
}
}
@@ -620,6 +633,64 @@ fn non_empty(s: Option<&str>) -> Option<String> {
s.map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned)
}
/// WordPress numbers each audio player on a page by adding `?_=N` to its file's URL, so a post
/// that embeds the file it encloses lists the same file twice: Rands in Repose's "The Promotion
/// Paradox" was downloaded twice and offered two play buttons for one mp3. The first stays.
fn drop_player_repeats(encs: &mut Vec<Enclosure>) {
let mut seen = std::collections::HashSet::new();
encs.retain(|e| seen.insert(same_file_key(&e.url)));
}
/// An enclosure URL without WordPress's player number, for telling repeats of one file apart
/// from different files.
pub fn same_file_key(url: &str) -> String {
let Ok(mut u) = url::Url::parse(url) else { return url.to_owned() };
let kept: Vec<(String, String)> = u
.query_pairs()
.filter(|(k, v)| !(k == "_" && !v.is_empty() && v.bytes().all(|b| b.is_ascii_digit())))
.map(|(k, v)| (k.into_owned(), v.into_owned()))
.collect();
if kept.is_empty() {
u.set_query(None);
} else {
u.query_pairs_mut().clear().extend_pairs(kept);
}
u.to_string()
}
/// A title as plain text. An Atom title of `type="html"`, or an RSS one in CDATA, comes through
/// the XML parser with its HTML entities intact: The Verge's "Meta&#8217;s" reached the page as
/// typed. Decoded one entity at a time, so an `&` that starts none, as in "Q&A", stays as it is
/// instead of failing the whole title.
fn title_text(s: Option<&str>) -> Option<String> {
let s = non_empty(s)?;
let mut out = String::with_capacity(s.len());
let mut rest = s.as_str();
while let Some(at) = rest.find('&') {
out.push_str(&rest[..at]);
rest = &rest[at..];
let len = 1 + rest[1..]
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '#'))
.unwrap_or(rest.len() - 1);
let decoded = rest[len..]
.starts_with(';')
.then(|| quick_xml::escape::unescape_with(&rest[..=len], quick_xml::escape::resolve_html5_entity).ok())
.flatten();
match decoded {
Some(v) => {
out.push_str(&v);
rest = &rest[len + 1..];
}
None => {
out.push('&');
rest = &rest[1..];
}
}
}
out.push_str(rest);
non_empty(Some(&out))
}
/// An item's show notes: its full body when that is whole, else its description.
///
/// libsyn served Daily Meditation Podcast's `content:encoded` cut at the `>` inside a class name
@@ -708,6 +779,7 @@ mod tests {
assert_eq!(feed.title.as_deref(), Some("Test Cast"));
assert_eq!(feed.ttl_mins, Some(45));
assert_eq!(feed.category.as_deref(), Some("Podcasting"), "the first, by its subcategory");
assert_eq!(feed.entries.len(), 3);
let ep = &feed.entries[0];
@@ -732,6 +804,37 @@ mod tests {
);
}
#[test]
fn a_file_wordpress_lists_twice_is_one_enclosure() {
let xml = br#"<?xml version="1.0"?><rss version="2.0"><channel><title>R</title>
<item><title>The Promotion Paradox</title><guid>p</guid>
<enclosure url="https://x/ep.mp3" length="1" type="audio/mpeg"/>
<enclosure url="https://x/ep.mp3?_=2" length="1" type="audio/mpeg"/>
<enclosure url="https://x/other.mp3?_=3&amp;key=k" length="1" type="audio/mpeg"/>
</item></channel></rss>"#;
let urls: Vec<String> =
parse(xml).unwrap().entries[0].enclosures.iter().map(|e| e.url.clone()).collect();
assert_eq!(urls, ["https://x/ep.mp3", "https://x/other.mp3?_=3&key=k"], "the repeat goes, a different file stays");
assert_eq!(same_file_key("https://x/a.mp3?key=k&_=2"), same_file_key("https://x/a.mp3?key=k"));
assert_ne!(same_file_key("https://x/a.mp3?_=x"), same_file_key("https://x/a.mp3"), "only a number");
}
#[test]
fn titles_are_read_as_text_not_html() {
// The Verge: an Atom title of type="html", its entity inside CDATA.
let xml = br#"<?xml version="1.0"?>
<feed xmlns="http://www.w3.org/2005/Atom"><title type="text">V</title><id>v</id>
<updated>2026-09-15T00:00:00Z</updated>
<entry><title type="html"><![CDATA[Meta&#8217;s new One]]></title><id>e1</id>
<updated>2026-09-15T00:00:00Z</updated></entry></feed>"#;
assert_eq!(parse(xml).unwrap().entries[0].title.as_deref(), Some("Meta\u{2019}s new One"));
// HTML names as well as numbers; a bare `&` and an unknown name are left as they are.
assert_eq!(
title_text(Some("Pe&ntilde;a &amp; &#x201C;Q&A&#8221; &bogus; AT&T;")).as_deref(),
Some("Pe\u{f1}a & \u{201c}Q&A\u{201d} &bogus; AT&T;")
);
}
#[test]
fn a_body_cut_off_mid_tag_gives_way_to_the_description() {
// How libsyn served Daily Meditation Podcast #3477: content:encoded began inside a tag.

View File

@@ -392,6 +392,27 @@ async fn daemon(
Err(e) => tracing::warn!(error = ?e, "could not requeue interrupted downloads"),
}
match retire_stranded(&ctx) {
Ok(0) => {}
Ok(n) => tracing::info!(feeds = n, "retired feeds whose OPML is no longer in config"),
Err(e) => tracing::warn!(error = ?e, "could not retire feeds whose OPML is no longer in config"),
}
// Before the parser knew WordPress's numbered player URLs, a file it listed twice was
// downloaded twice. The repeats fold into the first, and their spare copies are deleted.
match ctx.db.merge_repeated_enclosures(feed::same_file_key) {
Ok((0, _)) => {}
Ok((n, spare)) => {
for path in &spare {
if let Err(e) = std::fs::remove_file(path) {
tracing::warn!(path, error = %e, "could not delete a spare copy");
}
}
tracing::info!(enclosures = n, files = spare.len(), "folded files WordPress listed twice");
}
Err(e) => tracing::warn!(error = ?e, "could not fold files WordPress listed twice"),
}
let (tx_cmd, mut rx_cmd) = mpsc::channel::<Cmd>(64);
let web = start_web(&ctx, &config_path, web_addr, &tx_cmd, &events).await?;
@@ -581,6 +602,7 @@ pub async fn add_one(
username: None,
password: None,
password_env: None,
category: None,
};
let title = match feed::fetch(&ctx.client, &probe, None, None).await {
@@ -707,6 +729,7 @@ pub fn subscribe_opml(
username: None,
password: None,
password_env: None,
category: None,
},
);
grew = true;
@@ -950,6 +973,7 @@ pub fn subscriptions(ctx: &Ctx) -> Result<Vec<Sub>> {
username: parent.and_then(|p| p.username.clone()),
password: parent.and_then(|p| p.password.clone()),
password_env: parent.and_then(|p| p.password_env.clone()),
category: None,
},
managed: true,
});
@@ -962,10 +986,16 @@ pub fn subscriptions(ctx: &Ctx) -> Result<Vec<Sub>> {
/// Patreon feed that listed them: the same rule `sync_group` applies to one the list drops --
/// removed if nothing was downloaded, orphaned and kept otherwise. Called once the parent
/// itself is removed, since `subscriptions()` would otherwise keep scanning them under a
/// fallback policy meant for a feed with no parent at all.
/// fallback policy meant for a feed with no parent at all. A feed promoted to config is not
/// derived any more, so it is only unmanaged.
pub fn retire_group(ctx: &Ctx, parent_id: &str) -> Result<()> {
let cfg = ctx.cfg();
for m in ctx.db.managed_feeds()?.into_iter().filter(|m| m.group_id == parent_id) {
if ctx.db.downloaded_count(&m.id).unwrap_or(1) > 0 {
if cfg.feeds.contains_key(&m.id) {
// Scanned from its config entry and still read. Dropped as derived, its stored
// entries would go with it: davewiner's 11 were promoted without being unmanaged.
ctx.db.unmanage(&m.id)?;
} else if ctx.db.downloaded_count(&m.id).unwrap_or(1) > 0 {
ctx.db.set_orphaned(&m.id, true)?;
} else {
ctx.db.drop_managed(&m.id)?;
@@ -974,6 +1004,24 @@ pub fn retire_group(ctx: &Ctx, parent_id: &str) -> Result<()> {
Ok(())
}
/// Retires every group whose parent is gone from config, and returns how many derived rows that
/// dropped or unmanaged. An OPML removed before `retire_group` existed left its feeds behind:
/// davewiner's 922 were skipped by every scan and never cleared, and their stale errors were
/// most of the ones stored.
fn retire_stranded(ctx: &Ctx) -> Result<usize> {
let cfg = ctx.cfg();
let before = ctx.db.managed_feeds()?;
let stranded: std::collections::BTreeSet<&str> = before
.iter()
.map(|m| m.group_id.as_str())
.filter(|g| !cfg.feeds.contains_key(*g))
.collect();
for group in stranded {
retire_group(ctx, group)?;
}
Ok(before.len() - ctx.db.managed_feeds()?.len())
}
/// Seconds to wait before re-checking a feed.
///
/// A per-feed schedule is an explicit instruction and wins outright. Without one, the
@@ -1084,6 +1132,7 @@ async fn scan_one(
last_modified.as_deref(),
parsed.ttl_mins,
parsed.image.as_deref(),
parsed.category.as_deref(),
)?;
let policy = policy_for(ctx, id, feed_cfg)?;
@@ -1627,6 +1676,7 @@ mod tests {
username: None,
password: None,
password_env: None,
category: None,
}
}
@@ -1712,4 +1762,24 @@ mod tests {
assert!(managed.iter().any(|m| m.id == "has-file"), "has a file on disk, so it is kept");
assert!(ctx.db.feed_summary("has-file").unwrap().orphaned, "and flagged as orphaned");
}
#[test]
fn a_stranded_group_is_retired_but_a_promoted_feed_keeps_its_entries() {
// davewiner: the OPML left config before retire_group existed, and 11 of its feeds
// promoted to config since still said managed = 1.
let mut cfg = config::Config::default();
cfg.feeds.insert("promoted".into(), feed());
cfg.feeds.insert("live-opml".into(), feed());
let ctx = test_ctx(cfg);
ctx.db.upsert_managed("promoted", "http://x/p.xml", "Promoted", "gone-opml").unwrap();
ctx.db.upsert_managed("empty", "http://x/e.xml", "Empty", "gone-opml").unwrap();
ctx.db.upsert_managed("listed", "http://x/l.xml", "Listed", "live-opml").unwrap();
ctx.db.record_entry("promoted", &feed::Entry { guid: "g1".into(), ..Default::default() }).unwrap();
assert_eq!(retire_stranded(&ctx).unwrap(), 2, "empty dropped, promoted unmanaged");
let managed: Vec<String> = ctx.db.managed_feeds().unwrap().into_iter().map(|m| m.id).collect();
assert_eq!(managed, ["listed"], "a group still in config is left alone");
assert_eq!(ctx.db.feed_summary("promoted").unwrap().entries, 1, "its entries survive");
}
}

View File

@@ -65,6 +65,7 @@ pub fn router(state: WebState) -> Router {
.route("/login", get(login_page))
.route("/api/login", post(login))
.route("/icon.png", get(icon))
.route("/inter.woff2", get(inter))
.layer(middleware::from_fn(access_log))
.with_state(state)
}
@@ -452,6 +453,16 @@ async fn icon() -> impl IntoResponse {
)
}
/// Inter, the pages' typeface, served from the binary as the icon is, so neither page loads
/// anything from anyone else. Outside the auth layer for the sign-in page. Its licence, the SIL
/// Open Font License, is web/Inter-LICENSE.txt.
async fn inter() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "font/woff2"), (header::CACHE_CONTROL, "max-age=604800")],
include_bytes!("../web/InterVariable.woff2").as_slice(),
)
}
fn constant_time_eq(a: &str, b: &str) -> bool {
let (a, b) = (a.as_bytes(), b.as_bytes());
if a.len() != b.len() {
@@ -471,6 +482,9 @@ struct FeedRow {
title: Option<String>,
image: Option<String>,
folder: Option<String>,
/// The Directory category an admin gave it, and the one the feed names itself, which wins.
category: Option<String>,
feed_category: Option<String>,
keywords: Vec<String>,
allow_explicit: bool,
auto_download: bool,
@@ -538,6 +552,8 @@ async fn feeds(
title: s.title,
image: s.image,
folder: feed.folder.clone(),
category: feed.category.clone(),
feed_category: s.category,
keywords: mine
.keywords
.clone()
@@ -627,6 +643,11 @@ struct PopularRow {
subscribers: i64,
/// Yours already. Everyone counts, you included, so your own feeds are listed too.
subscribed: bool,
/// The feed's own iTunes category, if it names one; most blogs do not.
category: Option<String>,
/// Any audio or video enclosure. Unlike category, every feed has an answer, so the
/// Directory's Podcasts and Blogs between them hold everything.
podcast: bool,
}
/// Every feed that may be listed, with everyone counted, you included, most subscribers
@@ -638,6 +659,7 @@ fn popular(state: &WebState, user_id: i64) -> Result<Vec<PopularRow>> {
let mine: std::collections::HashSet<String> =
db.subscriptions_for(user_id)?.into_iter().map(|s| s.feed_id).collect();
let counts = db.subscriber_counts()?;
let media = db.media_feeds()?;
let catalogue = crate::subscriptions(&state.ctx)?;
let by_id: std::collections::HashMap<&str, &crate::config::Feed> =
catalogue.iter().map(|s| (s.id.as_str(), &s.cfg)).collect();
@@ -657,7 +679,16 @@ fn popular(state: &WebState, user_id: i64) -> Result<Vec<PopularRow>> {
}
let sum = db.feed_summary(&s.id)?;
let subscribed = mine.contains(&s.id);
out.push(PopularRow { id: s.id.clone(), title: sum.title, image: sum.image, subscribers: n, subscribed });
out.push(PopularRow {
id: s.id.clone(),
title: sum.title,
image: sum.image,
subscribers: n,
subscribed,
// The feed's own wins; an admin's is for the feeds, mostly blogs, that name none.
category: sum.category.or_else(|| s.cfg.category.clone()),
podcast: media.contains(&s.id),
});
}
out.sort_by(|a, b| b.subscribers.cmp(&a.subscribers).then_with(|| sort_name(a).cmp(&sort_name(b))));
Ok(out)
@@ -779,6 +810,7 @@ mod tests {
username: None,
password: None,
password_env: None,
category: None,
};
assert!(!looks_private(&f("https://feeds.twit.tv/twit.xml")));
assert!(!looks_private(&f("https://example.com/rss?format=mp3")));
@@ -825,7 +857,7 @@ mod tests {
crate::config::Feed {
url: url.into(), folder: None, group: None, media_types: None, schedule: None, keywords: vec![], allow_explicit: false,
auto_download: true, max_new_per_check: None, username: None,
password: None, password_env: None,
password: None, password_env: None, category: None,
}
}
@@ -837,9 +869,10 @@ mod tests {
assert!(absent.schedule.is_none() && absent.folder.is_none());
let cleared: FeedPatch =
serde_json::from_str(r#"{"schedule":null,"folder":null,"max_new_per_check":null}"#).unwrap();
serde_json::from_str(r#"{"schedule":null,"folder":null,"category":null,"max_new_per_check":null}"#).unwrap();
assert_eq!(cleared.schedule, Some(None), "null must mean clear");
assert_eq!(cleared.folder, Some(None));
assert_eq!(cleared.category, Some(None));
assert_eq!(cleared.max_new_per_check, Some(None));
let set: FeedPatch = serde_json::from_str(r#"{"schedule":"every 6h"}"#).unwrap();
@@ -1024,6 +1057,8 @@ struct FeedPatch {
schedule: Option<Option<String>>,
#[serde(default, deserialize_with = "double_option")]
folder: Option<Option<String>>,
#[serde(default, deserialize_with = "double_option")]
category: Option<Option<String>>,
keywords: Option<Vec<String>>,
allow_explicit: Option<bool>,
auto_download: Option<bool>,
@@ -1077,13 +1112,14 @@ async fn patch_feed(
// The rest describes the feed itself -- where its files land, its address, when it is
// polled -- and there is one of those however many people read it.
let feed_level = body.url.is_some() || body.folder.is_some() || body.schedule.is_some();
let feed_level =
body.url.is_some() || body.folder.is_some() || body.schedule.is_some() || body.category.is_some();
if !feed_level {
return Ok(StatusCode::NO_CONTENT);
}
if !user.is_admin {
return Err(ApiError::forbidden(
"the feed's address, folder and schedule are the same for everyone, so only an admin changes them",
"the feed's address, folder, schedule and category are the same for everyone, so only an admin changes them",
));
}
let mut cfg = (*state.ctx.cfg()).clone();
@@ -1130,6 +1166,9 @@ async fn patch_feed(
if let Some(v) = body.folder {
feed.folder = v.filter(|s| !s.trim().is_empty());
}
if let Some(v) = body.category {
feed.category = v.map(|s| s.trim().to_owned()).filter(|s| !s.is_empty());
}
cfg.save(&state.config_path)?;
state.ctx.reload_cfg(&state.config_path)?;
if url_changed {
@@ -1245,9 +1284,9 @@ async fn delete_file(
let complaint = match (starred, unread) {
(0, 0) => None,
(0, u) => Some(format!("{} subscribed to this feed {} not played it yet", people(u), if u == 1 { "has" } else { "have" })),
(st, 0) => Some(format!("another {} kept it", people(st))),
(st, 0) => Some(format!("another {} pinned it", people(st))),
(st, u) => Some(format!(
"another {} kept it, and {} not played it yet",
"another {} pinned it, and {} not played it yet",
people(st),
if u == 1 { "one person has".to_string() } else { format!("{u} have") }
)),
@@ -1335,6 +1374,8 @@ async fn media(
#[derive(Deserialize)]
struct Position {
secs: i64,
/// The length the player measured, for an episode whose feed gives none.
duration: Option<i64>,
}
async fn set_position(
@@ -1343,7 +1384,7 @@ async fn set_position(
user: crate::db::User,
Json(body): Json<Position>,
) -> Result<StatusCode, ApiError> {
state.ctx.db.set_position(user.id, &feed_id, &guid, body.secs)?;
state.ctx.db.set_position(user.id, &feed_id, &guid, body.secs, body.duration)?;
Ok(StatusCode::NO_CONTENT)
}

View File

@@ -6,6 +6,8 @@
<description>A synthetic feed used by the parser tests.</description>
<ttl>45</ttl>
<itunes:explicit>no</itunes:explicit>
<itunes:category text="Technology"><itunes:category text="Podcasting"/></itunes:category>
<itunes:category text="News"/>
<item>
<title>Episode One</title>

View File

@@ -88,8 +88,10 @@ const drive = [
['opmlModal', () => ctx.opmlModal()],
['selectFeed (directory)', () => ctx.selectFeed(':directory')],
['selectFeed (popular)', () => ctx.selectFeed(':popular')],
['selectFeed (currently listening)', () => ctx.selectFeed(':listening')],
['selectFeed (all subscriptions)', () => ctx.selectFeed(':all')],
['logsModal', () => ctx.logsModal()],
['keysModal', () => ctx.keysModal()],
// `const S` is not reachable from here: top-level const/let do not become properties
// of a vm context the way var and function declarations do.
['renderGroup', () => ctx.renderGroup(feed, [{ ...feed, id: 'child', group: 'f', orphaned: true }])],

View File

@@ -155,30 +155,69 @@ test('an item with several enclosures lists them all', async ({ page }) => {
await expect(page.locator('#files .encbox').nth(1).locator('.kind[title^="image"]')).toBeVisible();
});
test('Currently Listening, below Popular, resumes an episode you started', async ({ page }) => {
// Second Episode (900s) is 42 seconds in and unfinished. Which of Test Show's two episodes
// the daemon auto-downloaded is not fixed (see the three-panes test above), so an earlier
// test may have opened -- and so read -- this one already; reset it before relying on it.
test('Currently Listening, its own place below Popular, resumes an episode you started or forgets it', async ({ page }) => {
// Second Episode (900s) is 42 seconds in and unfinished. An earlier test may have opened it,
// and opening marks it read; it is listed all the same, because read is not finished. This
// test used to set it unread first, which hid exactly the bug in issue #14.
await page.evaluate(() =>
api('/api/entries/test-show/ui-2/flags', { method: 'POST', body: JSON.stringify({ read: false }) }));
api('/api/entries/test-show/ui-2/flags', { method: 'POST', body: JSON.stringify({ read: true }) }));
await page.evaluate(() =>
api('/api/entries/test-show/ui-2/position', { method: 'POST', body: JSON.stringify({ secs: 42 }) }));
// Popular lists feeds and nothing else; the episodes have a place of their own under it.
await page.locator('#feedlist .place', { hasText: 'Popular' }).click();
await expect(page.locator('#popular')).toBeVisible();
await expect(page.locator('#listening')).toHaveCount(0);
const places = await page.locator('#feedlist .place b').allTextContents();
expect(places.indexOf('Currently Listening')).toBe(places.indexOf('Popular') + 1);
await page.locator('#feedlist .place', { hasText: 'Currently Listening' }).click();
const row = page.locator('#listening .childrow', { hasText: 'Second Episode' });
await expect(row).toBeVisible({ timeout: 20_000 });
await expect(row).toContainText('0:42 of 15:00');
await expect(row).toContainText('14:18 left');
// Removing it forgets where you got to, so it is still gone on the next visit.
await row.locator('[data-a=remove]').click();
await expect(row).toHaveCount(0);
await expect(page.locator('#player')).not.toBeVisible();
await page.locator('#feedlist .place', { hasText: 'Currently Listening' }).click();
await expect(page.locator('#listening')).not.toContainText('Second Episode', { timeout: 20_000 });
// Started again, it is back, and clicking the row resumes it. Finishing it (90%) is
// covered in the Rust tests; here the player's own save on close would race it.
await page.evaluate(() =>
api('/api/entries/test-show/ui-2/position', { method: 'POST', body: JSON.stringify({ secs: 42 }) }));
await page.locator('#feedlist .place', { hasText: 'Currently Listening' }).click();
await expect(row).toBeVisible({ timeout: 20_000 });
await row.click();
await expect(page.locator('#player')).toBeVisible();
await expect(page.locator('#ptitle')).toHaveText('Second Episode');
// The row in the player carries the EQ bars, as the feed view's does, until the player closes.
await expect(row).toHaveClass(/\bnow\b/);
await expect(row.locator('.eq')).toBeVisible();
await page.locator('#pclose').click();
await expect(row).not.toHaveClass(/\bnow\b/);
await expect(row.locator('.eq')).toBeHidden();
});
// Finished (read) drops it from the list, however far it got.
await page.evaluate(() =>
api('/api/entries/test-show/ui-2/flags', { method: 'POST', body: JSON.stringify({ read: true }) }));
await page.locator('#feedlist .place', { hasText: 'Popular' }).click();
await expect(page.locator('#listening')).not.toContainText('Second Episode', { timeout: 20_000 });
test('a player nobody has played since it last saved does not save again', async ({ page }) => {
// A tab left paused at 41:15 saved that as it reloaded, over the 32:48 another had reached,
// and the episode dropped out of Currently Listening. The fixture audio does not decode, so
// this stands in for a loaded file paused at 2 seconds and counts what savePos sends.
const sent = await page.evaluate(() => {
Object.defineProperty(audio, 'readyState', { get: () => 4 });
Object.defineProperty(audio, 'currentTime', { get: () => 2, set() {} });
let n = 0;
navigator.sendBeacon = () => (n++, true);
player.guid = 'ui-2'; player.feed = 'test-show'; player.entry = null; player.moved = false;
savePos(); // what a reload, a pause or the close button calls
const idle = n;
player.moved = true; // what playing sets
savePos();
savePos(); // and once saved, it is idle again
return [idle, n];
});
expect(sent).toEqual([0, 1]);
});
test('the filter tabs change what is listed', async ({ page }) => {
@@ -190,7 +229,7 @@ test('the filter tabs change what is listed', async ({ page }) => {
await page.locator('.tabs button', { hasText: 'Unread' }).first().click();
expect(await page.locator('.ep').count()).toBeLessThanOrEqual(all);
await page.locator('.tabs button', { hasText: 'Kept' }).first().click();
await page.locator('.tabs button', { hasText: 'Pinned' }).first().click();
await expect(page.locator('#count')).toContainText('0 items');
});
@@ -695,25 +734,46 @@ test('Popular lists what everyone here reads, but never a private feed', async (
expect(top.every(t => ids.includes(t.id))).toBe(true);
expect(ids).not.toContain('paid-show');
// Subscribe from the directory this time; the popular list shares the same rows.
// Subscribe from the directory this time: the same feeds, as a grid of cover art.
const tiles = piper.locator('#popular .tile');
const pick = (row, name) => piper.locator(`#dirbar .${row} button`, { hasText: new RegExp(`^${name}$`) });
await piper.locator('#feedlist .place', { hasText: 'Directory' }).click();
await expect(piper.locator('#count')).toContainText(`Directory: ${dir.length} feed`);
await expect(offered.filter({ hasText: 'Test Show' })).toBeVisible();
await expect(offered.filter({ hasText: /Grouped Show|grouped-show/ })).toBeVisible();
await expect(offered.filter({ hasText: /Test Subscriptions/ })).toHaveCount(0);
await expect(offered.filter({ hasText: /Paid Show|paid-show/ })).toHaveCount(0);
await expect(tiles.filter({ hasText: 'Test Show' })).toBeVisible();
await expect(tiles.filter({ hasText: /Grouped Show|grouped-show/ })).toBeVisible();
await expect(tiles.filter({ hasText: /Test Subscriptions/ })).toHaveCount(0);
await expect(tiles.filter({ hasText: /Paid Show|paid-show/ })).toHaveCount(0);
await expect(tiles).toHaveCount(dir.length);
// Two filters that combine: what a feed is, and what it is about.
expect(dir.find(p => p.id === 'test-show')).toMatchObject({ podcast: true, category: 'Technology' });
expect(dir.find(p => p.id === 'picture-blog')).toMatchObject({ podcast: false });
await pick('tabs', 'Blogs').click();
await expect(tiles).toHaveCount(dir.filter(p => !p.podcast).length);
await expect(tiles.filter({ hasText: 'Test Show' })).toHaveCount(0);
// No empty chips: no blog here names Technology, so Blogs does not offer it.
await expect(pick('chips', 'Technology')).toHaveCount(0);
await pick('tabs', 'Podcasts').click();
await pick('chips', 'Technology').click();
await expect(pick('chips', 'Technology')).toHaveAttribute('aria-pressed', 'true');
await expect(tiles).toHaveCount(dir.filter(p => p.podcast && p.category === 'Technology').length);
// A second press lifts the chip and leaves the kind as it was.
await pick('chips', 'Technology').click();
await expect(tiles).toHaveCount(dir.filter(p => p.podcast).length);
await pick('tabs', 'All').click();
await expect(tiles).toHaveCount(dir.length);
// Add a feed opened over Directory fills its own list, not the pane behind it.
await piper.locator('#addFeed').click();
await expect(piper.locator('#modalCard .childrow', { hasText: 'Test Show' })).toBeVisible();
await expect(offered).toHaveCount(dir.length);
await expect(tiles).toHaveCount(dir.length);
await piper.locator('#modalCard button[title="Cancel"]').click();
const row = async () =>
(await (await piper.request.get('/api/popular')).json()).find(p => p.id === 'test-show');
const before = await row();
expect(before.subscribed).toBe(false);
await offered.filter({ hasText: 'Test Show' }).locator('button[title="Subscribe"]').click();
await tiles.filter({ hasText: 'Test Show' }).locator('button[title="Subscribe"]').click();
await expect(piper.locator('#feedlist .feed', { hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
// Everyone counts, you included: it stays listed, marked as yours, with one more subscriber.
@@ -759,13 +819,14 @@ test('a deleted file looks as if it was never downloaded', async ({ page }) => {
});
test('one action, one icon: the toolbar, the page and every dialog agree', async ({ page }) => {
const icon = loc => loc.locator('svg path').first().getAttribute('d');
// The whole glyph, not just its path: pinned and not pinned share one outline and differ in fill.
const icon = loc => loc.locator('svg').first().innerHTML();
await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click();
// Unsubscribe is a minus in the toolbar and the feed header, never the x that closes things.
expect(await icon(page.locator('#content .acts [data-a="rm"]'))).toBe(await icon(page.locator('#tbRemove')));
// The toolbar's read and keep show the selected item's state, as its own buttons do, and follow
// The toolbar's read and pin show the selected item's state, as its own buttons do, and follow
// a change made from the toolbar.
await page.locator('.ep').first().click();
const pair = async a => [await icon(page.locator(a === 'read' ? '#tbRead' : '#tbFlag')),
@@ -890,3 +951,67 @@ test('someone the proxy signs in never sees the password page, and signs out thr
await expect(proxied).toHaveURL(/\/signed-out-by-the-proxy$/);
await ctx.close();
});
test('keys move through items and places, after Feedly', async ({ page }) => {
// Last in the file: selecting an item marks it read, which would change what later tests see.
await page.locator('.feed', { hasText: 'Test Show' }).click();
const rows = page.locator('#eps .ep');
await expect(rows.nth(1)).toBeVisible({ timeout: 20_000 });
const sel = page.locator('#eps .ep.sel');
const guid = i => rows.nth(i).getAttribute('data-guid');
await page.keyboard.press('j');
await expect(sel).toHaveAttribute('data-guid', await guid(0));
await page.keyboard.press('j');
await expect(sel).toHaveAttribute('data-guid', await guid(1));
await page.keyboard.press('k');
await expect(sel).toHaveAttribute('data-guid', await guid(0));
// g and a letter go somewhere; typed into a box, the same letters are only text.
await page.keyboard.press('g');
await page.keyboard.press('d');
await expect(page.locator('#count')).toContainText('Directory');
await page.locator('#epSearch').focus();
await page.keyboard.type('ga');
await expect(page.locator('#count')).toContainText('Directory');
await page.locator('#epSearch').fill('');
await page.locator('#epSearch').blur();
await page.keyboard.press('?');
await expect(page.locator('#modalCard')).toContainText('Keyboard shortcuts');
await page.keyboard.press('Escape');
await page.keyboard.press('g');
await page.keyboard.press('a');
await expect(page.locator('#count')).toContainText('All Subscriptions');
});
test('an admin can give a blog its Directory category', async ({ page }) => {
const patch = (id, category) => page.evaluate(([id, category]) =>
api(`/api/feeds/${id}`, { method: 'PATCH', body: JSON.stringify({ category }) }), [id, category]);
const listed = async id => (await page.evaluate(() => api('/api/directory'))).find(p => p.id === id);
await patch('picture-blog', 'Visual Arts');
expect(await listed('picture-blog')).toMatchObject({ podcast: false, category: 'Visual Arts' });
// A feed's own iTunes category wins over one given here.
await patch('test-show', 'Comedy');
expect((await listed('test-show')).category).toBe('Technology');
for (const id of ['picture-blog', 'test-show']) await patch(id, null);
expect((await listed('picture-blog')).category).toBeNull();
});
test('the pinned heading sits over its pins, and the page is set in Inter', async ({ page }) => {
await page.locator('.feed', { hasText: 'Test Show' }).click();
await expect(page.locator('#eps .ep').first()).toBeVisible({ timeout: 20_000 });
const boxes = {
headCell: await page.locator('.ephead [data-sort="kept"]').boundingBox(),
headIcon: await page.locator('.ephead [data-sort="kept"] svg').first().boundingBox(),
rowCell: await page.locator('#eps .ep .fl').first().boundingBox(),
rowIcon: await page.locator('#eps .ep .fl svg').first().boundingBox(),
};
const mid = b => b.x + b.width / 2;
expect(Math.abs(mid(boxes.headIcon) - mid(boxes.rowIcon)), JSON.stringify(boxes)).toBeLessThan(1);
// From ipx itself, not a font service.
expect((await page.request.get('/inter.woff2')).headers()['content-type']).toBe('font/woff2');
expect(await page.evaluate(() => document.fonts.ready.then(() => document.fonts.check('14px Inter')))).toBe(true);
});

View File

@@ -2,6 +2,7 @@
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
<channel><title>Test Show</title><link>http://127.0.0.1:8792/</link><description>A fixture feed.</description>
<itunes:image href="http://127.0.0.1:8792/art.png"/>
<itunes:category text="Technology"/>
<item><title>First Episode</title><guid>ui-1</guid>
<pubDate>Mon, 01 Sep 2026 10:00:00 +0000</pubDate>
<description>&lt;p&gt;Show notes for the first one.&lt;/p&gt;</description>

92
web/Inter-LICENSE.txt Normal file
View File

@@ -0,0 +1,92 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

BIN
web/InterVariable.woff2 Normal file

Binary file not shown.

View File

@@ -7,6 +7,10 @@
<title>iPodderX</title>
<link rel="icon" href="/icon.png">
<style>
/* Inter, served by ipx itself (/inter.woff2) rather than a font CDN, so the page asks nothing of
anyone else. One variable file covers every weight used here; italics are synthesized. Classic
keeps Lucida Grande, the 2004 app's face. */
@font-face{font-family:Inter;src:url(/inter.woff2) format("woff2");font-weight:100 900;font-display:swap}
/* Palette taken from the 2004 iPodderX icon: the silver device body, the blue
screen, and the amber EQ bars. Hex values in comments are sampled straight from it. */
:root {
@@ -91,7 +95,7 @@
html,body{height:100%}
body{
margin:0;background:var(--bg);color:var(--fg);
font:14.5px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
font:14.5px/1.55 Inter,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
display:grid;grid-template-rows:auto 1fr auto auto;overflow:hidden;
/* iOS Safari's address bar collapses and expands without firing a resize, so 100vh is
measured against whichever state happened to be current -- sized too tall while the bar
@@ -115,6 +119,11 @@ a{color:var(--accent)}
/* ---------- shell ---------- */
#shell{display:grid;grid-template-columns:290px 1fr;min-height:0;min-width:0;overflow:hidden}
/* [ hides the feed list. A phone slides it over the page instead, so this is for wider screens. */
@media (min-width:821px){
body.nosb #shell{grid-template-columns:minmax(0,1fr)}
body.nosb #sidebar{display:none}
}
#sidebar{
background:var(--panel);border-right:1px solid var(--line);
display:flex;flex-direction:column;min-height:0;
@@ -195,6 +204,43 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
.childrow .art{width:32px;height:32px;font-size:12px}
.childrow .txt{flex:1;min-width:0}
.childrow .txt b{display:block;font-weight:500;font-size:13.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* Currently Listening: how far into each episode you are, as a rail along the foot of its row.
The one in the player is marked as it is everywhere else, by the amber EQ bars, and its rail
and time left move as it plays. The rest stay neutral, so that one is what stands out. */
#listening .childrow{position:relative;overflow:hidden;gap:12px;padding:10px 10px 13px}
#listening .childrow .art{width:44px;height:44px;font-size:13px}
#listening .txt b{display:flex;align-items:center;gap:7px}
#listening .txt b span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#listening .txt small{display:flex;gap:12px;margin-top:3px;color:var(--faint);font-size:11.5px}
#listening .txt small .fd{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#listening .txt small .left{flex:none;color:var(--dim)}
#listening .childrow:not(.now) .eq{display:none}
#listening .rail{position:absolute;left:0;right:0;bottom:0;height:3px;background:var(--raise)}
#listening .rail i{display:block;height:100%;width:0;background:var(--faint);transition:width .25s linear}
#listening .now{border-color:color-mix(in srgb,var(--accent2) 60%,var(--line))}
#listening .now .eq,#listening .now .left{color:var(--accent2)}
#listening .now .rail i{background:var(--accent2)}
/* Quiet buttons: a filled one on every row outshouted the row that is playing. */
#listening [data-a=play]{color:var(--accent)}
#listening [data-a=remove]{color:var(--faint)}
/* Directory's filters: .tabs for what a feed is, as the item filters are, and chips for what it
is about. A picked chip is underlined in --accent2, as the download bar and the now-playing EQ
are; a .badge's fill already means unread in the sidebar. */
.dirbar{display:flex;flex-wrap:wrap;align-items:center;gap:8px 14px;margin-top:4px}
.dirbar .tabs{flex:none}
.chips{display:flex;flex-wrap:wrap;gap:2px 6px;flex:1 1 0;min-width:0}
.chips button{flex:none;padding:4px 6px;font-size:13px;color:var(--dim);white-space:nowrap;border-bottom:2px solid transparent}
.chips button:hover{color:var(--fg)}
.chips button[aria-pressed="true"]{color:var(--fg);border-bottom-color:var(--accent2)}
/* Square cover art, title underneath: podcast art is made to be known at a glance. The subscribe
button stays visible, since a button that only shows on hover cannot be reached on a phone. */
.tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:18px 14px;margin-top:14px}
.tiles>.hint{grid-column:1/-1}
.tile{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 2px;align-items:start;cursor:pointer}
.tile .art{grid-column:1/-1;width:100%;height:auto;aspect-ratio:1;font-size:34px}
.tile .txt b{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;overflow-wrap:anywhere;font-weight:500;font-size:13px;line-height:1.3}
.tile .txt small{display:block;color:var(--faint);font-size:11.5px}
.tile:hover .txt b{color:var(--accent)}
.tag{
font-size:11px;font-weight:600;
padding:1px 5px;border-radius:4px;background:var(--raise);color:var(--warn);flex:none;
@@ -219,7 +265,7 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
}
.badge.zero{background:var(--raise);color:var(--faint)}
/* Counts, sizes and times that change in place should not jiggle the text around them. */
.badge,.feed small,.childrow small,.ep,.fhead .sub,.dmeta,#status,#seekrow{font-variant-numeric:tabular-nums}
.badge,.feed small,.childrow small,.tile small,.ep,.fhead .sub,.dmeta,#status,#seekrow{font-variant-numeric:tabular-nums}
/* ---------- main ---------- */
#main{overflow:hidden;min-height:0;min-width:0;display:flex;flex-direction:column}
@@ -284,6 +330,12 @@ a.btn{text-decoration:none;color:inherit}
.btn.ico{padding:4px 9px;min-width:32px;font-size:14px;line-height:1.25;text-align:center}
/* Inline in a sentence, next to a plain-word error explanation. */
.btn.tiny{padding:2px 7px;font-size:11.5px;border-radius:6px;margin-left:2px}
/* The keys ? lists. */
.keys{border-collapse:collapse;width:100%;font-size:13px}
.keys th{text-align:left;font-weight:600;padding:12px 0 4px;color:var(--fg)}
.keys td{padding:3px 0;color:var(--dim)}
.keys td:first-child{width:10em;white-space:nowrap}
kbd{font:inherit;font-size:12px;color:var(--fg);background:var(--panel2);border:1px solid var(--line);border-radius:4px;padding:0 5px}
/* An icon (Font Awesome, embedded as SVG) in the button's own colour. */
.i{display:inline-block;width:16px;height:16px;vertical-align:-3px;flex:none;fill:currentColor}
/* A file's type as an icon, in place of the old DOWNLOADED / PENDING / audio chips: green once
@@ -309,8 +361,10 @@ a.btn{text-decoration:none;color:inherit}
display:grid;gap:8px;align-items:center;
grid-template-columns:22px 22px minmax(120px,1fr) minmax(0,160px) 56px minmax(0,72px) minmax(0,92px) 64px;
}
/* A pixel more side padding than a row's: a row has a 1px border the heading has not, and without
it every heading sat a pixel left of its column. */
.ephead{
position:sticky;top:0;z-index:2;background:var(--bg);padding:7px 10px 5px;
position:sticky;top:0;z-index:2;background:var(--bg);padding:7px 11px 5px;
border-bottom:1px solid var(--line);margin-bottom:3px;
font-size:12px;color:var(--faint);
}
@@ -322,13 +376,19 @@ a.btn{text-decoration:none;color:inherit}
.ephead .hs .arr .i{width:8px;height:8px}
.ephead .hs .arr.asc{transform:rotate(-90deg)}
.ephead .hs .arr.desc{transform:rotate(90deg)}
/* An icon heading is centred over its column, as the icons under it are, and its caret hangs
outside so sorting by it does not push the icon off line. */
.ephead .hs.h-ic{justify-content:center;position:relative;width:100%}
.ephead .hs.h-ic .arr{position:absolute;left:100%}
.ep{padding:4px 10px;border-radius:7px;border:1px solid transparent;cursor:pointer;margin-bottom:1px}
.ep>*{min-width:0}
.ep.sel{background:var(--raise);border-color:var(--line)}
.ep:hover{background:var(--panel)}
/* Amber means new: the unread dot, the badges and a download under way. Blue is for the one
primary action and links, so a count no longer looks like a button. */
.ep .st,.ep .fl{width:22px;height:22px;border-radius:5px;display:grid;place-items:center;font-size:11px;color:var(--accent2)}
/* No padding: a button keeps the browser's side padding, which left too little room for the 16px
icon, so it spilled 3px right of centre and off line with its heading. */
.ep .st,.ep .fl{width:22px;height:22px;padding:0;border-radius:5px;display:grid;place-items:center;font-size:11px;color:var(--accent2)}
.ep .fl{color:var(--faint);font-size:13px}
.ep .fl.on{color:var(--fg)}
/* The icon's EQ bars mark what is playing: standing still, and moving only while it plays. A
@@ -515,6 +575,10 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
#pnow .txt small,#pright #vol{display:none}
.wrap.plain{padding:14px}
.card{padding:16px}
/* The chips get a line of their own and scroll sideways rather than wrap, so they never push
the grid down. */
.chips{flex:1 1 100%;flex-wrap:nowrap;overflow-x:auto}
.tiles{grid-template-columns:repeat(auto-fill,minmax(104px,1fr));gap:14px 10px}
/* A log line has no room for four columns: keep time and message, wrap as prose. */
#logbox{font-size:11.5px;padding:8px}
@@ -593,7 +657,7 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
<div class="tgroup item">
<button id="tbPlay" title="Play the selected item" aria-label="Play the selected item" data-icon="play" disabled></button>
<button id="tbRead" title="Mark the selected item read or unread" aria-label="Mark read or unread" data-icon="check" disabled></button>
<button id="tbFlag" title="Keep the selected item, so it is never deleted" aria-label="Keep" data-icon="flag" disabled></button>
<button id="tbFlag" title="Pin the selected item, so it is never deleted" aria-label="Pin" data-icon="pin" disabled></button>
</div>
<span class="grow"></span>
<input type="search" id="epSearch" placeholder="Search items…">
@@ -672,8 +736,11 @@ const ICON={
play:fa('0 0 448 512','<path fill="currentColor" d="M91.2 36.9c-12.4-6.8-27.4-6.5-39.6 .7S32 57.9 32 72l0 368c0 14.1 7.5 27.2 19.6 34.4s27.2 7.5 39.6 .7l336-184c12.8-7 20.8-20.5 20.8-35.1s-8-28.1-20.8-35.1l-336-184z"/>'), // solid/play
check:fa('0 0 448 512','<path fill="currentColor" d="M434.8 70.1c14.3 10.4 17.5 30.4 7.1 44.7l-256 352c-5.5 7.6-14 12.3-23.4 13.1s-18.5-2.7-25.1-9.3l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l101.5 101.5 234-321.7c10.4-14.3 30.4-17.5 44.7-7.1z"/>'), // solid/check
checks:fa('0 0 384 512','<path fill="currentColor" d="M249.9 66.8c10.4-14.3 7.2-34.3-7.1-44.7s-34.3-7.2-44.7 7.1l-106 145.7-37.5-37.5c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l64 64c6.6 6.6 15.8 10 25.1 9.3s17.9-5.5 23.4-13.1l128-176zm128 136c10.4-14.3 7.2-34.3-7.1-44.7s-34.3-7.2-44.7 7.1l-170 233.7-69.5-69.5c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l96 96c6.6 6.6 15.8 10 25.1 9.3s17.9-5.5 23.4-13.1l192-264z"/>'), // solid/check-double
flag:fa('0 0 448 512','<path fill="currentColor" d="M48 24C48 10.7 37.3 0 24 0S0 10.7 0 24L0 488c0 13.3 10.7 24 24 24s24-10.7 24-24l0-100 80.3-20.1c41.1-10.3 84.6-5.5 122.5 13.4 44.2 22.1 95.5 24.8 141.7 7.4l34.7-13c12.5-4.7 20.8-16.6 20.8-30l0-279.7c0-23-24.2-38-44.8-27.7l-9.6 4.8c-46.3 23.2-100.8 23.2-147.1 0-35.1-17.6-75.4-22-113.5-12.5L48 52 48 24zm0 77.5l96.6-24.2c27-6.7 55.5-3.6 80.4 8.8 54.9 27.4 118.7 29.7 175 6.8l0 241.8-24.4 9.1c-33.7 12.6-71.2 10.7-103.4-5.4-48.2-24.1-103.3-30.1-155.6-17.1l-68.6 17.2 0-237z"/>'), // regular/flag
flagOn:fa('0 0 448 512','<path fill="currentColor" d="M64 32C64 14.3 49.7 0 32 0S0 14.3 0 32L0 480c0 17.7 14.3 32 32 32s32-14.3 32-32l0-121.6 62.7-18.8c41.9-12.6 87.1-8.7 126.2 10.9 42.7 21.4 92.5 24 137.2 7.2l37.1-13.9c12.5-4.7 20.8-16.6 20.8-30l0-247.7c0-23-24.2-38-44.8-27.7l-11.8 5.9c-44.9 22.5-97.8 22.5-142.8 0-36.4-18.2-78.3-21.8-117.2-10.1L64 54.4 64 32z"/>'), // solid/flag
// Pinned is the solid thumbtack; not pinned, the same shape outlined, as the flag had its regular
// and solid pair (Font Awesome's free set has no regular thumbtack). Both share a viewBox padded
// for the outline's stroke, so the two draw the same size.
pin:fa('-18 -18 420 548','<path fill="none" stroke="currentColor" stroke-width="36" stroke-linejoin="round" d="M32 32C32 14.3 46.3 0 64 0L320 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-29.5 0 11.4 148.2c36.7 19.9 65.7 53.2 79.5 94.7l1 3c3.3 9.8 1.6 20.5-4.4 28.8s-15.7 13.3-26 13.3L32 352c-10.3 0-19.9-4.9-26-13.3s-7.7-19.1-4.4-28.8l1-3c13.8-41.5 42.8-74.8 79.5-94.7L93.5 64 64 64C46.3 64 32 49.7 32 32zM160 384l64 0 0 96c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-96z"/>'), // solid/thumbtack, outlined
pinOn:fa('-18 -18 420 548','<path fill="currentColor" d="M32 32C32 14.3 46.3 0 64 0L320 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-29.5 0 11.4 148.2c36.7 19.9 65.7 53.2 79.5 94.7l1 3c3.3 9.8 1.6 20.5-4.4 28.8s-15.7 13.3-26 13.3L32 352c-10.3 0-19.9-4.9-26-13.3s-7.7-19.1-4.4-28.8l1-3c13.8-41.5 42.8-74.8 79.5-94.7L93.5 64 64 64C46.3 64 32 49.7 32 32zM160 384l64 0 0 96c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-96z"/>'), // solid/thumbtack
scan:fa('0 0 512 512','<path fill="currentColor" d="M65.9 228.5c13.3-93 93.4-164.5 190.1-164.5 53 0 101 21.5 135.8 56.2 .2 .2 .4 .4 .6 .6l7.6 7.2-47.9 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l128 0c17.7 0 32-14.3 32-32l0-128c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 53.4-11.3-10.7C390.5 28.6 326.5 0 256 0 127 0 20.3 95.4 2.6 219.5 .1 237 12.2 253.2 29.7 255.7s33.7-9.7 36.2-27.1zm443.5 64c2.5-17.5-9.7-33.7-27.1-36.2s-33.7 9.7-36.2 27.1c-13.3 93-93.4 164.5-190.1 164.5-53 0-101-21.5-135.8-56.2-.2-.2-.4-.4-.6-.6l-7.6-7.2 47.9 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L32 320c-8.5 0-16.7 3.4-22.7 9.5S-.1 343.7 0 352.3l1 127c.1 17.7 14.6 31.9 32.3 31.7S65.2 496.4 65 478.7l-.4-51.5 10.7 10.1c46.3 46.1 110.2 74.7 180.7 74.7 129 0 235.7-95.4 253.4-219.5z"/>'), // solid/arrows-rotate
download:fa('0 0 448 512','<path fill="currentColor" d="M256 32c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 210.7-41.4-41.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l96 96c12.5 12.5 32.8 12.5 45.3 0l96-96c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L256 242.7 256 32zM64 320c-35.3 0-64 28.7-64 64l0 32c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-32c0-35.3-28.7-64-64-64l-46.9 0-56.6 56.6c-31.2 31.2-81.9 31.2-113.1 0L110.9 320 64 320zm304 56a24 24 0 1 1 0 48 24 24 0 1 1 0-48z"/>'), // solid/download
save:fa('0 0 448 512','<path fill="currentColor" d="M64 32C28.7 32 0 60.7 0 96L0 416c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-242.7c0-17-6.7-33.3-18.7-45.3L352 50.7C340 38.7 323.7 32 306.7 32L64 32zm32 96c0-17.7 14.3-32 32-32l160 0c17.7 0 32 14.3 32 32l0 64c0 17.7-14.3 32-32 32l-160 0c-17.7 0-32-14.3-32-32l0-64zM224 288a64 64 0 1 1 0 128 64 64 0 1 1 0-128z"/>'), // solid/floppy-disk
@@ -833,6 +900,8 @@ const VIEWS={
blurb:'Every feed anyone on this server subscribes to, A to Z. The feeds inside an OPML are listed one by one, not the OPML.'},
':popular':{title:'Popular',icon:ICON.popular,url:'/api/popular',
blurb:'The ten feeds with the most subscribers here. The feeds inside an OPML count one by one, not the OPML.'},
':listening':{title:'Currently Listening',icon:ICON.audio,url:'/api/entries?filter=in_progress&limit=50',
blurb:'Episodes you started and have not finished, across every feed you subscribe to. Pick one up where you left off.'},
':all':{title:'All Subscriptions',icon:ICON.all},
};
function renderFeeds(){
@@ -976,7 +1045,7 @@ function renderFeed(){
</div>`) + `
<div class="toolbar">
<div class="tabs">
${[['all','All'],['unread','Unread'],['downloaded','Downloaded'],['flagged','Kept']].map(([t,label])=>
${[['all','All'],['unread','Unread'],['downloaded','Downloaded'],['flagged','Pinned']].map(([t,label])=>
`<button data-f="${t}" class="${S.filter===t?'on':''}">${label}</button>`).join('')}
</div>
</div>`;
@@ -1003,11 +1072,11 @@ function renderFeed(){
/// The item table's headings, each a button that sorts by its column. The first click goes the
/// natural way round (A to Z; newest, largest and kept first) and the next one reverses it.
const COLS=[['kept','Kept',ICON.flag],['title','Title'],['feed','Feed'],['type','File'],['size','Size'],['published','Published']];
const COLS=[['kept','Pinned',ICON.pin],['title','Title'],['feed','Feed'],['type','File'],['size','Size'],['published','Published']];
function sortHead(){
return '<div class="ephead"><span></span>'+COLS.map(([k,label,icon])=>{
const on=S.sort.col===k;
return `<button class="hs${on?' on':''}${k==='feed'?' h-fd':''}" data-sort="${k}"`+
return `<button class="hs${on?' on':''}${k==='feed'?' h-fd':''}${icon?' h-ic':''}" data-sort="${k}"`+
` title="Sort by ${label.toLowerCase()}" aria-label="Sort by ${label.toLowerCase()}">${icon||label}`+
`${on?`<span class="arr ${S.sort.dir}">${ICON.caret}</span>`:''}</button>`;
}).join('')+'<span></span></div>';
@@ -1159,7 +1228,7 @@ function epEl(e){
<button class="st" data-a="read" title="Mark ${e.read?'unread':'read'}">${
player.guid===e.guid?EQ:(e.read?'':'●')}</button>
<button class="fl${e.flagged?' on':''}" data-a="flag" title="${
e.flagged?'Stop keeping':'Keep, so it is never deleted'}">${e.flagged?ICON.flagOn:ICON.flag}</button>
e.flagged?'Unpin':'Pin, so it is never deleted'}">${e.flagged?ICON.pinOn:ICON.pin}</button>
<div class="body">
<span class="t">${esc(e.title||'(untitled)')}</span>
<div class="line">${[
@@ -1284,10 +1353,10 @@ function syncTools(e){
$('#tbRead').disabled=$('#tbFlag').disabled=!e;
// The same icons as the item's own buttons beside its title, so the two never disagree.
$('#tbRead').innerHTML=e&&e.read?ICON.unread:ICON.check;
$('#tbFlag').innerHTML=e&&e.flagged?ICON.flagOn:ICON.flag;
$('#tbFlag').innerHTML=e&&e.flagged?ICON.pinOn:ICON.pin;
if(e){
$('#tbRead').title=`Mark ${e.read?'unread':'read'}`;
$('#tbFlag').title=e.flagged?'Stop keeping':'Keep, so it is never deleted';
$('#tbFlag').title=e.flagged?'Unpin':'Pin, so it is never deleted';
}
}
@@ -1317,8 +1386,8 @@ function showDetail(e){
.filter(Boolean).map(s=>`<span>${s}</span>`).join('<span class="dot"></span>')}
<button class="btn ico" data-a="read" title="Mark ${e.read?'unread':'read'}"
aria-label="Mark ${e.read?'unread':'read'}">${e.read?ICON.unread:ICON.check}</button>
<button class="btn ico" data-a="flag" title="${e.flagged?'Kept: never deleted. Stop keeping':'Keep, so it is never deleted'}"
aria-label="${e.flagged?'Stop keeping':'Keep'}">${e.flagged?ICON.flagOn:ICON.flag}</button>
<button class="btn ico" data-a="flag" title="${e.flagged?'Pinned: never deleted. Unpin':'Pin, so it is never deleted'}"
aria-label="${e.flagged?'Unpin':'Pin'}">${e.flagged?ICON.pinOn:ICON.pin}</button>
${e.link?`<a class="btn ico" href="${esc(e.link)}" target="_blank" rel="noopener noreferrer"
title="Open the original" aria-label="Open the original">${ICON.open}</a>`:''}
</div>
@@ -1438,7 +1507,7 @@ function play(e,enc=e.enclosures.find(isPlayable)){
// The same file carries on where it was; another of the item's files starts from its top.
const resuming = player.guid===e.guid && player.enc===enc.id;
if(!resuming){
player.guid=e.guid; player.feed=e.feed_id; player.entry=e; player.enc=enc.id;
player.guid=e.guid; player.feed=e.feed_id; player.entry=e; player.enc=enc.id; player.moved=false;
document.body.classList.toggle('has-video', kindOf(enc)==='video');
audio.src=`/media/${enc.id}`;
audio.currentTime=0;
@@ -1470,23 +1539,36 @@ audio.addEventListener('timeupdate',()=>{
$('#pcur').textContent=clock(audio.currentTime);
$('#pdur').textContent=clock(d);
if(d) $('#seek').value=String(Math.round(audio.currentTime/d*1000));
// Persist roughly every 10s so a reload resumes where you were.
if(player.guid && audio.currentTime-player.saveAt>10){ savePos(); }
// Only playing counts as moving: the seek to where you left off happens paused, and saving
// that would write back whatever the list said, however old.
if(!audio.paused) player.moved=true;
// Persist roughly every 10s so a reload resumes where you were. Either way: a jump back used
// to wait for the next pause to be saved.
if(player.guid && Math.abs(audio.currentTime-player.saveAt)>10){ savePos(); }
if(d && audio.currentTime/d >= 0.9) markPlayed();
});
function savePos(){
if(!player.guid) return;
// Before the file has loaded, currentTime is 0 rather than where you are: saving it then --
// a failed load, or a pause before the seek to where you left off -- wiped the position.
// Nor from a player nobody has played since it last saved: one left paused in another tab
// saved its older place as that tab reloaded, over where you had got to since.
if(!player.guid||!audio.readyState||!player.moved) return;
player.moved=false;
player.saveAt=audio.currentTime;
if(player.entry) player.entry.position=Math.floor(audio.currentTime);
// The measured length stands in for one the feed left out: without it Currently Listening
// cannot tell a finished episode from a started one. NaN before metadata, Infinity on a stream.
const duration=isFinite(audio.duration)?Math.floor(audio.duration):null;
navigator.sendBeacon?.(
`/api/entries/${encodeURIComponent(player.feed)}/${encodeURIComponent(player.guid)}/position`,
new Blob([JSON.stringify({secs:Math.floor(audio.currentTime)})],{type:'application/json'}));
new Blob([JSON.stringify({secs:Math.floor(audio.currentTime),duration})],{type:'application/json'}));
}
audio.addEventListener('pause',savePos);
audio.addEventListener('ended',()=>{savePos();markPlayed();$('#pplay').innerHTML=ICON.play});
// body.playing is what sets the EQ bars moving.
audio.addEventListener('play',()=>{ $('#pplay').innerHTML=ICON.pause; document.body.classList.add('playing'); });
audio.addEventListener('pause',()=>{ $('#pplay').innerHTML=ICON.play; document.body.classList.remove('playing'); });
for(const ev of ['play','pause','timeupdate']) audio.addEventListener(ev,syncListening);
window.addEventListener('beforeunload',savePos);
$('#pplay').onclick=()=>audio.paused?audio.play():audio.pause();
$('#pback').onclick=()=>audio.currentTime-=15;
@@ -1494,7 +1576,9 @@ $('#pfwd').onclick=()=>audio.currentTime+=30;
$('#seek').oninput=e=>{const d=audio.duration;if(d)audio.currentTime=d*e.target.value/1000};
$('#rate').onchange=e=>{audio.playbackRate=+e.target.value;localStorage.setItem('ipx.rate',e.target.value)};
$('#vol').oninput=e=>{audio.volume=e.target.value/100;localStorage.setItem('ipx.vol',e.target.value)};
$('#pclose').onclick=()=>{savePos();audio.pause();audio.removeAttribute('src');player.guid=null;$('#player').classList.remove('on');document.body.classList.remove('has-video');renderEntries()};
$('#pclose').onclick=()=>{savePos();audio.pause();audio.removeAttribute('src');player.guid=null;$('#player').classList.remove('on');document.body.classList.remove('has-video');renderEntries();
// Called here, not left to the pause event: closing a player already paused fires none.
syncListening()};
(function restore(){
const r=localStorage.getItem('ipx.rate'), v=localStorage.getItem('ipx.vol');
if(r){$('#rate').value=r;audio.playbackRate=+r}
@@ -1511,8 +1595,73 @@ document.addEventListener('keydown',ev=>{
else if(ev.key==='ArrowLeft'&&player.guid){audio.currentTime-=15}
else if(ev.key==='ArrowRight'&&player.guid){audio.currentTime+=30}
else if(ev.key==='/'){ev.preventDefault();$('#epSearch')?.focus()}
else if(!ev.ctrlKey&&!ev.metaKey&&!ev.altKey&&!$('#modal').classList.contains('on')) typed(ev);
});
// Feedly's keys, vim's j and k among them: a letter to move through items or feeds, g and a
// letter to go somewhere, ? to list them. None fire with Ctrl, Alt or Cmd held, so the browser's
// own shortcuts still work, or while a dialog is open.
const GO={a:':all',d:':directory',p:':popular',l:':listening'};
let gAt=0;
function stepEntry(by){
if(VIEWS[S.feed]?.url||!S.entries.length) return;
const i=S.entries.findIndex(x=>x.guid===S.sel);
const e=S.entries[i<0?0:Math.min(S.entries.length-1,Math.max(0,i+by))];
selectEntry(e);
$(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`)?.scrollIntoView({block:'nearest'});
}
function stepFeed(by){
const rows=$$('#feedlist [data-id]'), i=rows.findIndex(r=>r.dataset.id===S.feed), id=rows[i+by]?.dataset.id;
if(!id) return;
selectFeed(id);
$(`#feedlist [data-id="${CSS.escape(id)}"]`)?.scrollIntoView({block:'nearest'});
}
const KEYS={
j:()=>stepEntry(1), n:()=>stepEntry(1), k:()=>stepEntry(-1), p:()=>stepEntry(-1),
J:()=>stepFeed(1), K:()=>stepFeed(-1),
// The toolbar's own buttons, so a key does exactly what the click does, and nothing while
// they are disabled.
o:()=>$('#tbPlay').click(), m:()=>$('#tbRead').click(), s:()=>$('#tbFlag').click(),
v:()=>{ const e=cur(); if(e&&e.link) window.open(e.link,'_blank','noopener'); },
A:()=>$('#content .fhead [data-a="read"], #content .fhead [data-a="readall"]')?.click(),
r:async()=>{ await loadFeeds(true); if(S.feed){ renderFeed(); loadEntries(); } },
'[':()=>matchMedia('(max-width:820px)').matches
? nav(!$('#sidebar').classList.contains('open')) : document.body.classList.toggle('nosb'),
'?':()=>keysModal(),
g:()=>{ gAt=Date.now(); },
};
function typed(ev){
// The second key of a g pair counts only if it follows within a second and a half.
const pair=Date.now()-gAt<1500; gAt=0;
const fn=pair ? (GO[ev.key]&&(()=>selectFeed(GO[ev.key])))||(ev.key==='s'&&prefsModal) : KEYS[ev.key];
if(!fn) return;
ev.preventDefault(); fn();
}
/// What ? shows: every key, grouped as Feedly's own list is.
function keysModal(){
const k=s=>`<kbd>${esc(s)}</kbd>`, g=c=>k('g')+' '+k(c);
const rows=[
['Go to'],
[g('a'),'All Subscriptions'],[g('d'),'Directory'],[g('p'),'Popular'],
[g('l'),'Currently Listening'],[g('s'),'Settings'],
[k('Shift')+' '+k('J'),'Next feed'],[k('Shift')+' '+k('K'),'Previous feed'],
[k('/'),'Search items'],[k('r'),'Refresh'],[k('['),'Show or hide the feed list'],
['Items'],
[k('j')+' or '+k('n'),'Next item'],[k('k')+' or '+k('p'),'Previous item'],
[k('Shift')+' '+k('A'),'Mark all read'],
['The selected item'],
[k('o'),'Play it'],[k('m'),'Mark it read or unread'],[k('s'),'Pin it, or unpin it'],
[k('v'),'Open the original in a new tab'],
['The player'],
[k('Space'),'Play or pause'],[k('←')+' '+k('→'),'Back 15 seconds, forward 30'],
['Anywhere'],
[k('?'),'This list'],[k('Esc'),'Close a dialog'],
];
openModal(`<h3>Keyboard shortcuts</h3><table class="keys">${rows.map(([a,b])=>b===undefined
?`<tr><th colspan="2">${a}</th></tr>`:`<tr><td>${a}</td><td>${b}</td></tr>`).join('')}</table>
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button></div>`);
}
/* ---------------- modals ---------------- */
function openModal(html,wide){
$('#modalCard').innerHTML=html;
@@ -1646,30 +1795,73 @@ $('#addFeed').onclick=()=>{
// What everyone here reads, you included, as a place to start. The rows carry an id, never a
// URL, so a key in someone's feed address never reaches this page. The caller hands over the
// box: looked up by id, the Add a feed dialog's list landed in the Directory pane behind it.
const NONE_LISTED='<p class="hint">Nothing yet. Feeds people here subscribe to show up here.</p>';
async function listFeeds(url,box){
let rows=[];
try{ rows=await api(url)||[]; }catch{}
box.innerHTML=rows.length?'':'<p class="hint">Nothing yet. Feeds people here subscribe to show up here.</p>';
for(const p of rows){
const el=document.createElement('div');
el.className='childrow';
el.innerHTML=artHTML(p.image,p.title||p.id)+
`<div class="txt"><b>${esc(p.title||p.id)}</b>`+
`<small class="meta">${p.subscribers} subscriber${p.subscribers===1?'':'s'}</small></div>`+
// Green, as a downloaded file is: it is already yours. Plus, beside it, is the way to get one.
(p.subscribed?`<span class="subbed" title="Subscribed: click to open it" aria-label="Subscribed">${ICON.subbed}</span>`
:`<button class="btn ico" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.subbed}</button>`);
// Yours already: the row opens it instead.
if(p.subscribed){ el.onclick=()=>{ closeModal(); selectFeed(p.id); }; box.appendChild(el); continue; }
$('[data-a="sub"]',el).onclick=async()=>{
try{
await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'});
closeModal(); toast(`Subscribed to ${p.title||p.id}`);
await loadFeeds(true); selectFeed(p.id);
}catch(e){ toast(e.message,true); }
box.innerHTML=rows.length?'':NONE_LISTED;
for(const p of rows) box.appendChild(listedFeed(p,'childrow'));
return rows.length;
}
/// One listed feed: a row in Popular and the Add a feed dialog, a tile in Directory's grid. The
/// parts are the same either way; the class lays them out.
function listedFeed(p,cls){
const el=document.createElement('div');
el.className=cls;
el.innerHTML=artHTML(p.image,p.title||p.id)+
`<div class="txt"><b>${esc(p.title||p.id)}</b>`+
`<small class="meta">${p.subscribers} subscriber${p.subscribers===1?'':'s'}</small></div>`+
// Green, as a downloaded file is: it is already yours. Plus, beside it, is the way to get one.
(p.subscribed?`<span class="subbed" title="Subscribed: click to open it" aria-label="Subscribed">${ICON.subbed}</span>`
:`<button class="btn ico" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.subbed}</button>`);
// Yours already: the row opens it instead.
if(p.subscribed){ el.onclick=()=>{ closeModal(); selectFeed(p.id); }; return el; }
$('[data-a="sub"]',el).onclick=async()=>{
try{
await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'});
closeModal(); toast(`Subscribed to ${p.title||p.id}`);
await loadFeeds(true); selectFeed(p.id);
}catch(e){ toast(e.message,true); }
};
return el;
}
// Directory's filters. Kept out here because a finished scan redraws the pane, which would
// otherwise clear them.
let dirKind='All', dirCat=null;
const KINDS={All:()=>true,Podcasts:p=>p.podcast,Blogs:p=>!p.podcast};
/// Directory: every listed feed as its cover art, under two filters that combine: what a feed is
/// (Podcasts, anything with audio or video, or Blogs, the rest) and what it is about (its iTunes
/// category, as chips). Both filter in place, without asking the server again.
async function renderDirectory(url,box){
let rows=[];
try{ rows=await api(url)||[]; }catch{}
if(!rows.length){ box.innerHTML=NONE_LISTED; return 0; }
const bar=$('#dirbar');
// Only a filter when the server has both kinds.
const both=rows.some(KINDS.Podcasts)&&rows.some(KINDS.Blogs);
const btn=(k,v,on)=>`<button type="button" data-${k}="${esc(v)}" class="${on?'on':''}" aria-pressed="${on}">${esc(v)}</button>`;
const draw=()=>{
if(!both) dirKind='All';
const ofKind=rows.filter(KINDS[dirKind]);
// No empty chips: only the categories among the feeds the kind lets through.
const cats=[...new Set(ofKind.map(p=>p.category).filter(Boolean))].sort();
if(!cats.includes(dirCat)) dirCat=null;
bar.innerHTML=
(both?`<div class="tabs" role="group" aria-label="Kind">${Object.keys(KINDS).map(k=>btn('kind',k,k===dirKind)).join('')}</div>`:'')+
(cats.length?`<div class="chips" role="group" aria-label="Category">${cats.map(c=>btn('cat',c,c===dirCat)).join('')}</div>`:'');
// A picked chip lifts on a second press. Everything is redrawn, so the keyboard goes back to
// the button just pressed.
for(const b of $$('button',bar)) b.onclick=()=>{
const k=b.dataset.kind!=null?'kind':'cat', v=b.dataset[k];
if(k==='kind') dirKind=v; else dirCat=dirCat===v?null:v;
draw(); $(`[data-${k}="${CSS.escape(v)}"]`,bar)?.focus();
};
box.appendChild(el);
}
box.innerHTML='';
for(const p of ofKind.filter(p=>!dirCat||p.category===dirCat)) box.appendChild(listedFeed(p,'tile'));
};
draw();
return rows.length;
}
@@ -1680,42 +1872,76 @@ async function renderListed(v){
$('#tbRemove').disabled=true;
syncTools(null);
$('#epSearch').placeholder='Search items…';
const listening=v===VIEWS[':popular'];
const listening=v===VIEWS[':listening'], grid=v===VIEWS[':directory'];
box.innerHTML=`
<div class="fhead slim">
<div class="art">${v.icon}</div>
<div class="meta"><h2>${v.title}</h2>
<div class="sub">${v.blurb} Everyone counts, you included. Private feeds are never listed.</div></div>
<div class="sub">${v.blurb}${listening?'':' Everyone counts, you included. Private feeds are never listed.'}</div></div>
</div>
<div class="childlist" id="popular"><p class="hint">Loading…</p></div>
${listening?`<div class="sub" style="margin:18px 0 8px;font-weight:600;color:var(--fg)">Currently Listening</div>
<div class="childlist" id="listening"><p class="hint">Loading…</p></div>`:''}`;
${grid?'<div class="dirbar" id="dirbar"></div>':''}
<div class="${grid?'tiles':'childlist'}" id="${listening?'listening':'popular'}"><p class="hint">Loading…</p></div>`;
$('#count').textContent=v.title;
const n=await listFeeds(v.url,$('#popular',box));
if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${n} feed${n===1?'':'s'}`;
if(listening) renderListening();
const n=await (listening?renderListening:grid?renderDirectory:listFeeds)(v.url,$(listening?'#listening':'#popular',box));
if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${plural(n,listening?'episode':'feed')}`;
}
/// Below Popular: episodes you started and have not finished, across every feed you
/// Currently Listening: episodes you started and have not finished, across every feed you
/// subscribe to. A row resumes the episode in the player bar on click -- a shortcut back to
/// where you left off, not another way to browse.
async function renderListening(){
const box=$('#listening');
/// where you left off, not another way to browse. The one in the player pauses instead.
async function renderListening(url,box){
let rows=[];
try{ rows=(await api('/api/entries?filter=in_progress&limit=10')).entries||[]; }catch{}
try{ rows=(await api(url)).entries||[]; }catch{}
box.innerHTML=rows.length?'':'<p class="hint">Nothing in progress. Episodes you start and do not finish show up here.</p>';
for(const e of rows){
const el=document.createElement('div');
el.className='childrow';
const pct=e.duration?Math.min(100,Math.round(e.position/e.duration*100)):0;
el.entry=e;
el.innerHTML=artHTML(e.image||feedArt(e.feed_id),e.title||'')+
`<div class="txt"><b>${esc(e.title||'(untitled)')}</b>`+
`<small class="meta">${esc(feedName(e.feed_id))} · ${clock(e.position)} of ${e.duration?clock(e.duration):'?'}</small>`+
`<div class="dlbar live"><i style="width:${pct}%"></i></div></div>`+
`<button class="btn ico primary" title="Resume" aria-label="Resume">${ICON.play}</button>`;
el.onclick=()=>play(e);
`<div class="txt"><b>${EQ}<span>${esc(e.title||'(untitled)')}</span></b>`+
`<small><span class="fd">${esc(feedName(e.feed_id))}</span><span class="left"></span></small></div>`+
`<button class="iconbtn" data-a="play"></button>`+
`<button class="iconbtn" data-a="remove" title="Remove from Currently Listening" aria-label="Remove from Currently Listening">${ICON.close}</button>`+
`<div class="rail"><i></i></div>`;
el.onclick=ev=>ev.target.closest('[data-a=remove]')?forget(e)
:el.classList.contains('now')&&!audio.paused?audio.pause():play(e);
paintListenRow(el);
box.appendChild(el);
}
return rows.length;
}
/// One row's time left, progress and play button, taken from the player when it is the one in it.
function paintListenRow(el){
const e=el.entry, now=player.guid===e.guid&&player.feed===e.feed_id;
// Zero until the player has sought to where you left off; the saved position stands till then.
if(now&&audio.currentTime) e.position=Math.floor(audio.currentTime);
// The player's own length first: a feed's can be minutes out.
const d=(now&&isFinite(audio.duration)&&Math.floor(audio.duration))||e.duration;
el.classList.toggle('now',now);
$('.left',el).textContent=d?`${clock(d-e.position)} left`:`${clock(e.position)} in`;
// With no length there is nothing to show, and an empty rail reads as a heavy border.
const rail=$('.rail',el); rail.hidden=!d;
$('i',rail).style.width=`${d?Math.min(100,e.position/d*100):0}%`;
const b=$('[data-a=play]',el), label=now&&!audio.paused?'Pause':'Resume';
if(b.title!==label){ b.title=label; b.setAttribute('aria-label',label); b.innerHTML=label==='Pause'?ICON.pause:ICON.play; }
}
/// Keeps the list in step with the player. Only a row that is, or was, the one in it changes.
function syncListening(){
for(const el of $$('#listening .childrow'))
if(el.entry&&(el.classList.contains('now')||player.guid===el.entry.guid)) paintListenRow(el);
}
/// Takes an episode off Currently Listening by forgetting where you got to: the list is every
/// episode with a saved position short of the end, so the position is what has to go.
async function forget(e){
// Closed without saving first, or the player's next save would put it straight back.
if(player.guid===e.guid){ player.guid=null; $('#pclose').click(); }
try{
await api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/position`,
{method:'POST',body:JSON.stringify({secs:0})});
}catch(err){ toast(err.message,true); }
if(S.feed===':listening') renderListed(VIEWS[':listening']);
}
// The toolbar acts on whatever is selected: the feed on the left, the item in the table.
@@ -1799,7 +2025,7 @@ async function prefsModal(){
put article images in enclosures, and those are not worth keeping. Empty takes everything.</span></div>
<div class="field"><label>Disk quota (GB, 0 = unlimited)</label>
<input type="number" id="gquota" min="0" step="0.5" value="${g.max_total_gb}">
<span class="hint">Over this, the oldest played items are deleted first. Kept
<span class="hint">Over this, the oldest played items are deleted first. Pinned
items are never touched.</span></div>
<div class="field"><label>Delete items older than (days, 0 = keep)</label>
<input type="number" id="gage" min="0" value="${g.max_age_days}"></div>`:''}
@@ -1917,9 +2143,19 @@ function settingsModal(f,newUrl){
<input type="text" id="sfolder" value="${esc(f.folder||'')}" placeholder="${esc(f.title||f.id)}">
<span class="hint">Where the files land. There is one copy however many people
subscribe, so this is the same for everyone.</span></div>`:''}
${S.me&&S.me.admin?`<div class="field"><label>Directory category (shared)</label>
<input type="text" id="scat" list="scats" value="${esc(f.category||'')}" placeholder="${esc(f.feed_category||'None')}">
<datalist id="scats"></datalist>
<span class="hint">${f.feed_category
? `The feed names its own, ${esc(f.feed_category)}, and the Directory uses that.`
: `The feed names none, so the Directory files it under this. Pick one already listed where it fits.`}</span></div>`:''}
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
<button class="btn ico primary" id="ssave" title="Save" aria-label="Save">${ICON.check}</button></div>`);
$('#scopy').onclick=()=>copyText($('#surl').value,$('#scopy'));
// Offer the categories the Directory already shows, so a blog about games joins Games rather
// than starting a second chip beside it.
if($('#scats')) api('/api/directory').then(rows=>{ $('#scats').innerHTML=[...new Set((rows||[])
.map(p=>p.category).filter(Boolean))].sort().map(c=>`<option value="${esc(c)}">`).join(''); }).catch(()=>{});
$('#ssave').onclick=async()=>{
const max=$('#smax').value;
try{
@@ -1931,6 +2167,7 @@ function settingsModal(f,newUrl){
if(S.me&&S.me.admin){
patch.url=$('#surl').value.trim();
patch.folder=$('#sfolder').value.trim()||null;
patch.category=$('#scat').value.trim()||null;
}
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify(patch)});
closeModal(); toast('Saved — applies on the next scan');

View File

@@ -1,6 +1,8 @@
<title>Sign in — iPodderX</title>
<link rel="icon" href="/icon.png">
<style>
/* Inter, from ipx itself; see the same rule in index.html. */
@font-face{font-family:Inter;src:url(/inter.woff2) format("woff2");font-weight:100 900;font-display:swap}
:root {
--bg:#0e131b; /* the screen's navy (#314B74), taken right down */
--panel:#151c27;
@@ -40,7 +42,7 @@
html,body{height:100%}
body{
margin:0;display:grid;place-items:center;background:var(--bg);color:var(--fg);
font:14.5px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;padding:20px;
font:14.5px/1.55 Inter,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;padding:20px;
}
form{
width:min(360px,100%);background:var(--panel);border:1px solid var(--line);