Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0443177471 | |||
| 0a83c716eb | |||
| b9e0d9f3cb | |||
| 2dee3b722c | |||
| 8daa7a1991 | |||
| f89ca2aceb | |||
| 57a6fb5daa | |||
| 49dafedbbc | |||
| b83523fc92 | |||
| 8ae5c332c3 | |||
| 420f9eb9a0 | |||
| 4314b11237 | |||
| eaea520020 | |||
| b8d22904f1 | |||
| cbd16ce3f6 | |||
| 017cfd7e28 | |||
| 954173cacf |
62
CHANGELOG.md
62
CHANGELOG.md
@@ -10,6 +10,64 @@ The long form, with what was wrong before and how it was found, is in
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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’s" reads "Meta’s". Titles already stored are corrected the next time their feed
|
||||||
|
changes.
|
||||||
|
|
||||||
## [0.5.5] - 2026-09-14
|
## [0.5.5] - 2026-09-14
|
||||||
|
|
||||||
### Changed
|
### 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.
|
- 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/`.
|
- `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.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.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
|
[0.5.3]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.2...v0.5.3
|
||||||
|
|||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -1605,7 +1605,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipx"
|
name = "ipx"
|
||||||
version = "0.5.5"
|
version = "0.6.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ammonia",
|
"ammonia",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ipx"
|
name = "ipx"
|
||||||
version = "0.5.5"
|
version = "0.6.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[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"] }
|
librqbit = { version = "9.0.1", default-features = false, features = ["rust-tls", "http-api-client"] }
|
||||||
opml = "1.1.6"
|
opml = "1.1.6"
|
||||||
percent-encoding = "2.3.2"
|
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"] }
|
reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "http2", "gzip", "stream", "json", "charset", "system-proxy"] }
|
||||||
rss = "2.1.1"
|
rss = "2.1.1"
|
||||||
rusqlite = { version = "0.40.2", features = ["bundled"] }
|
rusqlite = { version = "0.40.2", features = ["bundled"] }
|
||||||
|
|||||||
@@ -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
|
- **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
|
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.
|
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
|
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
|
sign in with a password or through a proxy (Cloudflare Zero Trust or Authentik), and admins
|
||||||
manage accounts and settings.
|
manage accounts and settings.
|
||||||
@@ -21,7 +21,7 @@ behind iPodderX (2004-2008, Ray Slakinski & August Trometer).
|
|||||||
on new downloads per scan.
|
on new downloads per scan.
|
||||||
- **Downloads.** Files come over HTTP or BitTorrent and are filed into a folder per feed.
|
- **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
|
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
|
- **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.
|
URL, which keeps a whole list in step as a folder.
|
||||||
|
|
||||||
|
|||||||
@@ -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/enclosures/{id}/download`, `DELETE /api/enclosures/{id}` | `?force=true` overrides the shared-file warning |
|
||||||
| `POST /api/fetch` | |
|
| `POST /api/fetch` | |
|
||||||
| `GET /api/opml`, `POST /api/opml` | export your subscriptions; subscribe to every feed in an OPML |
|
| `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/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/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 |
|
| `GET /api/events` | SSE, the same broadcast the socket carries |
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ url = "https://atp.fm/rss"
|
|||||||
folder = "Accidental Tech Podcast" # default: the feed title
|
folder = "Accidental Tech Podcast" # default: the feed title
|
||||||
schedule = "every 6h" # overrides [general] for this feed
|
schedule = "every 6h" # overrides [general] for this feed
|
||||||
media_types = ["audio"] # 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
|
username = "ray" # HTTP basic auth
|
||||||
password_env = "IPX_ATP_PASS" # preferred over a literal `password`
|
password_env = "IPX_ATP_PASS" # preferred over a literal `password`
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,6 +6,68 @@ reasoning lives. New write-ups go at the top.
|
|||||||
|
|
||||||
See [README.md](../README.md) for what the thing is.
|
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
|
## 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,
|
A user reported that Settings disappeared shortly after they signed in: it showed for a moment,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ fetch, one parse and one file.
|
|||||||
|
|
||||||
| Yours alone | The same for everyone |
|
| 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 |
|
| Which feeds you see at all | Its download folder |
|
||||||
| Keywords, auto-download, explicit, per-scan cap | When it is scanned |
|
| Keywords, auto-download, explicit, per-scan cap | When it is scanned |
|
||||||
| | The file on disk |
|
| | 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
|
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
|
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.
|
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.
|
every subscriber has read it.
|
||||||
|
|
||||||
## Signing in
|
## 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
|
**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
|
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
|
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
|
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,
|
want. Anything that looks private is left out: a login configured for the feed, credentials in its URL,
|
||||||
|
|||||||
@@ -115,6 +115,10 @@ pub struct Feed {
|
|||||||
/// Download folder name; defaults to the sanitized feed title.
|
/// Download folder name; defaults to the sanitized feed title.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub folder: Option<String>,
|
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
|
/// Every whitespace-separated word of a keyword must appear in the
|
||||||
/// url/title/description/categories for an enclosure to be taken.
|
/// url/title/description/categories for an enclosure to be taken.
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
@@ -469,7 +473,7 @@ mod tests {
|
|||||||
taken.insert("the-daily".to_string(), Feed {
|
taken.insert("the-daily".to_string(), Feed {
|
||||||
url: "u".into(), folder: None, group: None, media_types: None, schedule: None, keywords: vec![], allow_explicit: false,
|
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,
|
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");
|
assert_eq!(unique_slug("The Daily", &taken), "the-daily-2");
|
||||||
}
|
}
|
||||||
@@ -489,6 +493,7 @@ mod tests {
|
|||||||
username: Some("ray".into()),
|
username: Some("ray".into()),
|
||||||
password: Some("literal".into()),
|
password: Some("literal".into()),
|
||||||
password_env: None,
|
password_env: None,
|
||||||
|
category: None,
|
||||||
};
|
};
|
||||||
assert_eq!(f.password().as_deref(), Some("literal"));
|
assert_eq!(f.password().as_deref(), Some("literal"));
|
||||||
|
|
||||||
|
|||||||
240
src/db.rs
240
src/db.rs
@@ -17,6 +17,8 @@ CREATE TABLE IF NOT EXISTS feeds (
|
|||||||
url TEXT NOT NULL,
|
url TEXT NOT NULL,
|
||||||
title TEXT,
|
title TEXT,
|
||||||
image TEXT,
|
image TEXT,
|
||||||
|
-- The channel's first <itunes:category>, for the Directory.
|
||||||
|
category TEXT,
|
||||||
etag TEXT,
|
etag TEXT,
|
||||||
last_modified TEXT,
|
last_modified TEXT,
|
||||||
last_checked INTEGER,
|
last_checked INTEGER,
|
||||||
@@ -103,6 +105,8 @@ CREATE TABLE IF NOT EXISTS entry_state (
|
|||||||
read INTEGER NOT NULL DEFAULT 0,
|
read INTEGER NOT NULL DEFAULT 0,
|
||||||
flagged INTEGER NOT NULL DEFAULT 0,
|
flagged INTEGER NOT NULL DEFAULT 0,
|
||||||
position 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)
|
PRIMARY KEY (user_id, feed_id, guid)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -170,6 +174,8 @@ fn migrate(conn: &Connection) -> Result<()> {
|
|||||||
("users", "created", "INTEGER"),
|
("users", "created", "INTEGER"),
|
||||||
("users", "last_login", "INTEGER"),
|
("users", "last_login", "INTEGER"),
|
||||||
("feeds", "error_since", "INTEGER"),
|
("feeds", "error_since", "INTEGER"),
|
||||||
|
("feeds", "category", "TEXT"),
|
||||||
|
("entry_state", "duration", "INTEGER"),
|
||||||
];
|
];
|
||||||
let retired: &[(&str, &str)] = &[
|
let retired: &[(&str, &str)] = &[
|
||||||
// Read state from before accounts, long since moved to entry_state. Two bugs came from
|
// 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)? {
|
if !has(table, column)? {
|
||||||
tracing::info!(table, column, "adding column");
|
tracing::info!(table, column, "adding column");
|
||||||
conn.execute_batch(&format!("ALTER TABLE {table} ADD COLUMN {column} {ty}"))?;
|
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 {
|
for (table, column) in retired {
|
||||||
@@ -208,6 +221,7 @@ fn migrate(conn: &Connection) -> Result<()> {
|
|||||||
pub struct FeedSummary {
|
pub struct FeedSummary {
|
||||||
pub title: Option<String>,
|
pub title: Option<String>,
|
||||||
pub image: 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
|
/// Came from a subscribed OPML that no longer lists it, but it has downloads, so it
|
||||||
/// was kept rather than removed.
|
/// was kept rather than removed.
|
||||||
pub orphaned: bool,
|
pub orphaned: bool,
|
||||||
@@ -256,7 +270,8 @@ impl Db {
|
|||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let mut sum: FeedSummary = conn
|
let mut sum: FeedSummary = conn
|
||||||
.query_row(
|
.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",
|
FROM feeds WHERE id = ?1",
|
||||||
[feed_id],
|
[feed_id],
|
||||||
|r| {
|
|r| {
|
||||||
@@ -267,6 +282,7 @@ impl Db {
|
|||||||
last_error: r.get(3)?,
|
last_error: r.get(3)?,
|
||||||
orphaned: r.get::<_, i64>(4)? != 0,
|
orphaned: r.get::<_, i64>(4)? != 0,
|
||||||
error_since: r.get(5)?,
|
error_since: r.get(5)?,
|
||||||
|
category: r.get(6)?,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -328,11 +344,14 @@ impl Db {
|
|||||||
last_modified: Option<&str>,
|
last_modified: Option<&str>,
|
||||||
ttl_mins: Option<u64>,
|
ttl_mins: Option<u64>,
|
||||||
image: Option<&str>,
|
image: Option<&str>,
|
||||||
|
category: Option<&str>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let conn = self.conn.lock().unwrap();
|
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(
|
conn.execute(
|
||||||
"INSERT INTO feeds (id, url, title, etag, last_modified, last_checked, ttl_mins, last_error, image)
|
"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)
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8, ?9)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
url = excluded.url,
|
url = excluded.url,
|
||||||
title = coalesce(excluded.title, feeds.title),
|
title = coalesce(excluded.title, feeds.title),
|
||||||
@@ -341,9 +360,10 @@ impl Db {
|
|||||||
last_checked = excluded.last_checked,
|
last_checked = excluded.last_checked,
|
||||||
ttl_mins = excluded.ttl_mins,
|
ttl_mins = excluded.ttl_mins,
|
||||||
image = coalesce(excluded.image, feeds.image),
|
image = coalesce(excluded.image, feeds.image),
|
||||||
|
category = excluded.category,
|
||||||
last_error = NULL,
|
last_error = NULL,
|
||||||
error_since = 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -685,9 +705,12 @@ pub enum Filter {
|
|||||||
Unread,
|
Unread,
|
||||||
Downloaded,
|
Downloaded,
|
||||||
Flagged,
|
Flagged,
|
||||||
/// Started (a saved playback position past the first few seconds) but not finished
|
/// Started (a saved playback position past the first few seconds) and short of the 90%
|
||||||
/// (`markPlayed` in the UI marks an item read at 90% played, so unread is "not finished"
|
/// where `markPlayed` in the UI calls it finished. Not `read`: opening an item marks it
|
||||||
/// here too). Currently Listening, below Popular, is this filter on every feed at once.
|
/// 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,
|
InProgress,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -713,7 +736,11 @@ impl Filter {
|
|||||||
"EXISTS (SELECT 1 FROM enclosures x
|
"EXISTS (SELECT 1 FROM enclosures x
|
||||||
WHERE x.feed_id = e.feed_id AND x.guid = e.guid AND x.path IS NOT NULL)"
|
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();
|
.unwrap_or_default();
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT e.guid, e.feed_id, e.title, e.link, e.published, e.description,
|
"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)
|
e.episode, e.season, coalesce(s.position, 0)
|
||||||
FROM entries e
|
FROM entries e
|
||||||
LEFT JOIN entry_state s
|
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.
|
/// 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();
|
let conn = self.conn.lock().unwrap();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO entry_state (user_id, feed_id, guid, position) VALUES (?1, ?2, ?3, ?4)
|
"INSERT INTO entry_state (user_id, feed_id, guid, position, duration)
|
||||||
ON CONFLICT(user_id, feed_id, guid) DO UPDATE SET position = excluded.position",
|
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||||
rusqlite::params![user_id, feed_id, guid, secs.max(0)],
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -972,6 +1014,17 @@ impl Db {
|
|||||||
Ok(out)
|
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
|
/// 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.
|
/// 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)> {
|
pub fn others_wanting(&self, enclosure_id: i64, user_id: i64) -> Result<(i64, i64)> {
|
||||||
@@ -1317,6 +1370,58 @@ impl Db {
|
|||||||
Ok(())
|
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.
|
/// Stops treating a feed as derived, because it now has its own config entry.
|
||||||
pub fn unmanage(&self, id: &str) -> Result<()> {
|
pub fn unmanage(&self, id: &str) -> Result<()> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
@@ -1441,6 +1546,64 @@ pub fn now() -> i64 {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn every_sort_column_runs_and_orders_both_ways() {
|
fn every_sort_column_runs_and_orders_both_ways() {
|
||||||
let db = Db::memory().unwrap();
|
let db = Db::memory().unwrap();
|
||||||
@@ -1520,7 +1683,7 @@ mod tests {
|
|||||||
|
|
||||||
// Starring and position are just as private.
|
// Starring and position are just as private.
|
||||||
db.set_entry_flag(1, "f", "b", EntryFlag::Flagged, true).unwrap();
|
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 order = order_sql("published", "desc");
|
||||||
let page = |user| db.entries_in(user, Some("f"), Filter::All, None, 0, 50, &order).unwrap();
|
let page = |user| db.entries_in(user, Some("f"), Filter::All, None, 0, 50, &order).unwrap();
|
||||||
let (ray, sam) = (page(1), page(2));
|
let (ray, sam) = (page(1), page(2));
|
||||||
@@ -1528,6 +1691,9 @@ mod tests {
|
|||||||
let sam_b = sam.iter().find(|e| e.guid == "b").unwrap();
|
let sam_b = sam.iter().find(|e| e.guid == "b").unwrap();
|
||||||
assert!(ray_b.flagged && ray_b.position == 0);
|
assert!(ray_b.flagged && ray_b.position == 0);
|
||||||
assert!(!sam_b.flagged && sam_b.position == 42);
|
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.
|
// Marking a whole feed read is likewise one person's business.
|
||||||
assert_eq!(db.mark_all_read(2, &["f".to_string()]).unwrap(), 2);
|
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".
|
// so plain filtering failed with "Wrong number of parameters passed to query".
|
||||||
let db = Db::memory().unwrap();
|
let db = Db::memory().unwrap();
|
||||||
db.exec_for_test(
|
db.exec_for_test(
|
||||||
"INSERT INTO entries (feed_id, guid, title, description, first_seen) VALUES
|
"INSERT INTO entries (feed_id, guid, title, description, first_seen, duration) VALUES
|
||||||
('f','a','Alpha dive','notes one',100),
|
('f','a','Alpha dive','notes one', 100,NULL),
|
||||||
('f','b','Beta', 'notes two',200),
|
('f','b','Beta', 'notes two', 200,NULL),
|
||||||
('f','c','Gamma dive','notes three',300),
|
('f','c','Gamma dive','notes three',300,NULL),
|
||||||
('f','d','Delta', 'notes four',400),
|
('f','d','Delta', 'notes four', 400,NULL),
|
||||||
('f','e','Epsilon', 'notes five',500),
|
('f','e','Epsilon', 'notes five', 500,45),
|
||||||
('f','g','Gimel', 'notes six',600);
|
('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
|
INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
|
||||||
(1,'f','b','u1','/tmp/b','done');
|
(1,'f','b','u1','/tmp/b','done');
|
||||||
-- Read and starred belong to a person now, so say which one.
|
-- 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
|
INSERT INTO entry_state (user_id, feed_id, guid, read, flagged, position) VALUES
|
||||||
(7,'f','b',1,0,0),
|
(7,'f','b',1,0,0),
|
||||||
(7,'f','c',1,1,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),
|
(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),
|
(7,'f','e',1,0,42),
|
||||||
-- Barely touched (opened, closed within seconds): not Currently Listening.
|
-- 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();
|
.unwrap();
|
||||||
|
|
||||||
@@ -1694,7 +1864,7 @@ mod tests {
|
|||||||
assert_eq!(rows.len() as i64, n, "{f:?} with search disagrees");
|
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::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::Downloaded, None).unwrap(), 1);
|
||||||
assert_eq!(db.count_in(7, Some("f"), Filter::Flagged, 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");
|
"search is case-insensitive and covers the description");
|
||||||
|
|
||||||
// Currently Listening: started, not finished, and not just an accidental tap.
|
// 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();
|
let order = order_sql("published", "desc");
|
||||||
assert_eq!(listening.iter().map(|e| e.guid.as_str()).collect::<Vec<_>>(), ["d"]);
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -387,7 +387,7 @@ mod tests {
|
|||||||
let mut f = crate::config::Feed {
|
let mut f = crate::config::Feed {
|
||||||
url: "u".into(), folder: Some("Subscriptions/Some | Show".into()), group: None, media_types: None,
|
url: "u".into(), folder: Some("Subscriptions/Some | Show".into()), group: None, media_types: None,
|
||||||
schedule: None, keywords: vec![], allow_explicit: false, auto_download: true,
|
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");
|
assert_eq!(folder_for(&cfg, "id", &f, None), "Subscriptions/Some - Show");
|
||||||
|
|
||||||
|
|||||||
115
src/feed.rs
115
src/feed.rs
@@ -11,6 +11,8 @@ pub struct ParsedFeed {
|
|||||||
pub title: Option<String>,
|
pub title: Option<String>,
|
||||||
pub ttl_mins: Option<u64>,
|
pub ttl_mins: Option<u64>,
|
||||||
pub image: Option<String>,
|
pub image: Option<String>,
|
||||||
|
/// The channel's first `<itunes:category>`, for the Directory's chips.
|
||||||
|
pub category: Option<String>,
|
||||||
pub entries: Vec<Entry>,
|
pub entries: Vec<Entry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,7 +480,7 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
|
|||||||
.filter_map(|(idx, item)| {
|
.filter_map(|(idx, item)| {
|
||||||
// Straight from the XML, so an item with several keeps all of them. Falls
|
// 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.
|
// 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()
|
item.enclosure()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|e| Enclosure {
|
.map(|e| Enclosure {
|
||||||
@@ -489,6 +491,7 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
|
|||||||
.filter(|e| !e.url.is_empty())
|
.filter(|e| !e.url.is_empty())
|
||||||
.collect()
|
.collect()
|
||||||
});
|
});
|
||||||
|
drop_player_repeats(&mut enclosures);
|
||||||
|
|
||||||
let guid = pick_guid(
|
let guid = pick_guid(
|
||||||
item.guid().map(|g| g.value()),
|
item.guid().map(|g| g.value()),
|
||||||
@@ -505,7 +508,7 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
|
|||||||
|
|
||||||
Some(Entry {
|
Some(Entry {
|
||||||
guid,
|
guid,
|
||||||
title: non_empty(item.title()),
|
title: title_text(item.title()),
|
||||||
link: non_empty(item.link()),
|
link: non_empty(item.link()),
|
||||||
published: item.pub_date().and_then(parse_date),
|
published: item.pub_date().and_then(parse_date),
|
||||||
// Content wins over description, as __getEntries preferred entry.content.
|
// Content wins over description, as __getEntries preferred entry.content.
|
||||||
@@ -527,7 +530,7 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
ParsedFeed {
|
ParsedFeed {
|
||||||
title: non_empty(Some(ch.title())),
|
title: title_text(Some(ch.title())),
|
||||||
ttl_mins: ch.ttl().and_then(|t| t.trim().parse().ok()),
|
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.
|
// itunes:image is the square artwork; <image><url> is the older, often smaller one.
|
||||||
image: ch
|
image: ch
|
||||||
@@ -535,6 +538,14 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
|
|||||||
.and_then(|i| i.image())
|
.and_then(|i| i.image())
|
||||||
.map(str::to_owned)
|
.map(str::to_owned)
|
||||||
.or_else(|| ch.image().map(|i| i.url().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,
|
entries,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -545,7 +556,7 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
|
|||||||
.iter()
|
.iter()
|
||||||
.filter_map(|e| {
|
.filter_map(|e| {
|
||||||
// Atom carries enclosures as <link rel="enclosure">.
|
// Atom carries enclosures as <link rel="enclosure">.
|
||||||
let enclosures: Vec<Enclosure> = e
|
let mut enclosures: Vec<Enclosure> = e
|
||||||
.links()
|
.links()
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|l| l.rel() == "enclosure")
|
.filter(|l| l.rel() == "enclosure")
|
||||||
@@ -556,6 +567,7 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
|
|||||||
})
|
})
|
||||||
.filter(|e| !e.url.is_empty())
|
.filter(|e| !e.url.is_empty())
|
||||||
.collect();
|
.collect();
|
||||||
|
drop_player_repeats(&mut enclosures);
|
||||||
|
|
||||||
let alt = e
|
let alt = e
|
||||||
.links()
|
.links()
|
||||||
@@ -572,7 +584,7 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
|
|||||||
|
|
||||||
Some(Entry {
|
Some(Entry {
|
||||||
guid,
|
guid,
|
||||||
title: non_empty(Some(e.title().as_str())),
|
title: title_text(Some(e.title().as_str())),
|
||||||
link: alt.map(str::to_owned),
|
link: alt.map(str::to_owned),
|
||||||
published: e.published().or(Some(e.updated())).map(|d| d.timestamp()),
|
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())),
|
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();
|
.collect();
|
||||||
|
|
||||||
ParsedFeed {
|
ParsedFeed {
|
||||||
title: non_empty(Some(feed.title().as_str())),
|
title: title_text(Some(feed.title().as_str())),
|
||||||
ttl_mins: None,
|
ttl_mins: None,
|
||||||
image: feed.logo().or_else(|| feed.icon()).map(str::to_owned),
|
image: feed.logo().or_else(|| feed.icon()).map(str::to_owned),
|
||||||
|
category: None,
|
||||||
entries,
|
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)
|
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’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.
|
/// 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
|
/// 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.title.as_deref(), Some("Test Cast"));
|
||||||
assert_eq!(feed.ttl_mins, Some(45));
|
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);
|
assert_eq!(feed.entries.len(), 3);
|
||||||
|
|
||||||
let ep = &feed.entries[0];
|
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&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’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ña & “Q&A” &bogus; AT&T;")).as_deref(),
|
||||||
|
Some("Pe\u{f1}a & \u{201c}Q&A\u{201d} &bogus; AT&T;")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_body_cut_off_mid_tag_gives_way_to_the_description() {
|
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.
|
// How libsyn served Daily Meditation Podcast #3477: content:encoded began inside a tag.
|
||||||
|
|||||||
74
src/main.rs
74
src/main.rs
@@ -392,6 +392,27 @@ async fn daemon(
|
|||||||
Err(e) => tracing::warn!(error = ?e, "could not requeue interrupted downloads"),
|
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 (tx_cmd, mut rx_cmd) = mpsc::channel::<Cmd>(64);
|
||||||
|
|
||||||
let web = start_web(&ctx, &config_path, web_addr, &tx_cmd, &events).await?;
|
let web = start_web(&ctx, &config_path, web_addr, &tx_cmd, &events).await?;
|
||||||
@@ -581,6 +602,7 @@ pub async fn add_one(
|
|||||||
username: None,
|
username: None,
|
||||||
password: None,
|
password: None,
|
||||||
password_env: None,
|
password_env: None,
|
||||||
|
category: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let title = match feed::fetch(&ctx.client, &probe, None, None).await {
|
let title = match feed::fetch(&ctx.client, &probe, None, None).await {
|
||||||
@@ -707,6 +729,7 @@ pub fn subscribe_opml(
|
|||||||
username: None,
|
username: None,
|
||||||
password: None,
|
password: None,
|
||||||
password_env: None,
|
password_env: None,
|
||||||
|
category: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
grew = true;
|
grew = true;
|
||||||
@@ -950,6 +973,7 @@ pub fn subscriptions(ctx: &Ctx) -> Result<Vec<Sub>> {
|
|||||||
username: parent.and_then(|p| p.username.clone()),
|
username: parent.and_then(|p| p.username.clone()),
|
||||||
password: parent.and_then(|p| p.password.clone()),
|
password: parent.and_then(|p| p.password.clone()),
|
||||||
password_env: parent.and_then(|p| p.password_env.clone()),
|
password_env: parent.and_then(|p| p.password_env.clone()),
|
||||||
|
category: None,
|
||||||
},
|
},
|
||||||
managed: true,
|
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 --
|
/// 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
|
/// 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
|
/// 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<()> {
|
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) {
|
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)?;
|
ctx.db.set_orphaned(&m.id, true)?;
|
||||||
} else {
|
} else {
|
||||||
ctx.db.drop_managed(&m.id)?;
|
ctx.db.drop_managed(&m.id)?;
|
||||||
@@ -974,6 +1004,24 @@ pub fn retire_group(ctx: &Ctx, parent_id: &str) -> Result<()> {
|
|||||||
Ok(())
|
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.
|
/// Seconds to wait before re-checking a feed.
|
||||||
///
|
///
|
||||||
/// A per-feed schedule is an explicit instruction and wins outright. Without one, the
|
/// 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(),
|
last_modified.as_deref(),
|
||||||
parsed.ttl_mins,
|
parsed.ttl_mins,
|
||||||
parsed.image.as_deref(),
|
parsed.image.as_deref(),
|
||||||
|
parsed.category.as_deref(),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let policy = policy_for(ctx, id, feed_cfg)?;
|
let policy = policy_for(ctx, id, feed_cfg)?;
|
||||||
@@ -1627,6 +1676,7 @@ mod tests {
|
|||||||
username: None,
|
username: None,
|
||||||
password: None,
|
password: None,
|
||||||
password_env: 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!(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");
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
57
src/web.rs
57
src/web.rs
@@ -65,6 +65,7 @@ pub fn router(state: WebState) -> Router {
|
|||||||
.route("/login", get(login_page))
|
.route("/login", get(login_page))
|
||||||
.route("/api/login", post(login))
|
.route("/api/login", post(login))
|
||||||
.route("/icon.png", get(icon))
|
.route("/icon.png", get(icon))
|
||||||
|
.route("/inter.woff2", get(inter))
|
||||||
.layer(middleware::from_fn(access_log))
|
.layer(middleware::from_fn(access_log))
|
||||||
.with_state(state)
|
.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 {
|
fn constant_time_eq(a: &str, b: &str) -> bool {
|
||||||
let (a, b) = (a.as_bytes(), b.as_bytes());
|
let (a, b) = (a.as_bytes(), b.as_bytes());
|
||||||
if a.len() != b.len() {
|
if a.len() != b.len() {
|
||||||
@@ -471,6 +482,9 @@ struct FeedRow {
|
|||||||
title: Option<String>,
|
title: Option<String>,
|
||||||
image: Option<String>,
|
image: Option<String>,
|
||||||
folder: 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>,
|
keywords: Vec<String>,
|
||||||
allow_explicit: bool,
|
allow_explicit: bool,
|
||||||
auto_download: bool,
|
auto_download: bool,
|
||||||
@@ -538,6 +552,8 @@ async fn feeds(
|
|||||||
title: s.title,
|
title: s.title,
|
||||||
image: s.image,
|
image: s.image,
|
||||||
folder: feed.folder.clone(),
|
folder: feed.folder.clone(),
|
||||||
|
category: feed.category.clone(),
|
||||||
|
feed_category: s.category,
|
||||||
keywords: mine
|
keywords: mine
|
||||||
.keywords
|
.keywords
|
||||||
.clone()
|
.clone()
|
||||||
@@ -627,6 +643,11 @@ struct PopularRow {
|
|||||||
subscribers: i64,
|
subscribers: i64,
|
||||||
/// Yours already. Everyone counts, you included, so your own feeds are listed too.
|
/// Yours already. Everyone counts, you included, so your own feeds are listed too.
|
||||||
subscribed: bool,
|
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
|
/// 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> =
|
let mine: std::collections::HashSet<String> =
|
||||||
db.subscriptions_for(user_id)?.into_iter().map(|s| s.feed_id).collect();
|
db.subscriptions_for(user_id)?.into_iter().map(|s| s.feed_id).collect();
|
||||||
let counts = db.subscriber_counts()?;
|
let counts = db.subscriber_counts()?;
|
||||||
|
let media = db.media_feeds()?;
|
||||||
let catalogue = crate::subscriptions(&state.ctx)?;
|
let catalogue = crate::subscriptions(&state.ctx)?;
|
||||||
let by_id: std::collections::HashMap<&str, &crate::config::Feed> =
|
let by_id: std::collections::HashMap<&str, &crate::config::Feed> =
|
||||||
catalogue.iter().map(|s| (s.id.as_str(), &s.cfg)).collect();
|
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 sum = db.feed_summary(&s.id)?;
|
||||||
let subscribed = mine.contains(&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))));
|
out.sort_by(|a, b| b.subscribers.cmp(&a.subscribers).then_with(|| sort_name(a).cmp(&sort_name(b))));
|
||||||
Ok(out)
|
Ok(out)
|
||||||
@@ -779,6 +810,7 @@ mod tests {
|
|||||||
username: None,
|
username: None,
|
||||||
password: None,
|
password: None,
|
||||||
password_env: None,
|
password_env: None,
|
||||||
|
category: None,
|
||||||
};
|
};
|
||||||
assert!(!looks_private(&f("https://feeds.twit.tv/twit.xml")));
|
assert!(!looks_private(&f("https://feeds.twit.tv/twit.xml")));
|
||||||
assert!(!looks_private(&f("https://example.com/rss?format=mp3")));
|
assert!(!looks_private(&f("https://example.com/rss?format=mp3")));
|
||||||
@@ -825,7 +857,7 @@ mod tests {
|
|||||||
crate::config::Feed {
|
crate::config::Feed {
|
||||||
url: url.into(), folder: None, group: None, media_types: None, schedule: None, keywords: vec![], allow_explicit: false,
|
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,
|
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());
|
assert!(absent.schedule.is_none() && absent.folder.is_none());
|
||||||
|
|
||||||
let cleared: FeedPatch =
|
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.schedule, Some(None), "null must mean clear");
|
||||||
assert_eq!(cleared.folder, Some(None));
|
assert_eq!(cleared.folder, Some(None));
|
||||||
|
assert_eq!(cleared.category, Some(None));
|
||||||
assert_eq!(cleared.max_new_per_check, Some(None));
|
assert_eq!(cleared.max_new_per_check, Some(None));
|
||||||
|
|
||||||
let set: FeedPatch = serde_json::from_str(r#"{"schedule":"every 6h"}"#).unwrap();
|
let set: FeedPatch = serde_json::from_str(r#"{"schedule":"every 6h"}"#).unwrap();
|
||||||
@@ -1024,6 +1057,8 @@ struct FeedPatch {
|
|||||||
schedule: Option<Option<String>>,
|
schedule: Option<Option<String>>,
|
||||||
#[serde(default, deserialize_with = "double_option")]
|
#[serde(default, deserialize_with = "double_option")]
|
||||||
folder: Option<Option<String>>,
|
folder: Option<Option<String>>,
|
||||||
|
#[serde(default, deserialize_with = "double_option")]
|
||||||
|
category: Option<Option<String>>,
|
||||||
keywords: Option<Vec<String>>,
|
keywords: Option<Vec<String>>,
|
||||||
allow_explicit: Option<bool>,
|
allow_explicit: Option<bool>,
|
||||||
auto_download: 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
|
// 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.
|
// 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 {
|
if !feed_level {
|
||||||
return Ok(StatusCode::NO_CONTENT);
|
return Ok(StatusCode::NO_CONTENT);
|
||||||
}
|
}
|
||||||
if !user.is_admin {
|
if !user.is_admin {
|
||||||
return Err(ApiError::forbidden(
|
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();
|
let mut cfg = (*state.ctx.cfg()).clone();
|
||||||
@@ -1130,6 +1166,9 @@ async fn patch_feed(
|
|||||||
if let Some(v) = body.folder {
|
if let Some(v) = body.folder {
|
||||||
feed.folder = v.filter(|s| !s.trim().is_empty());
|
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)?;
|
cfg.save(&state.config_path)?;
|
||||||
state.ctx.reload_cfg(&state.config_path)?;
|
state.ctx.reload_cfg(&state.config_path)?;
|
||||||
if url_changed {
|
if url_changed {
|
||||||
@@ -1245,9 +1284,9 @@ async fn delete_file(
|
|||||||
let complaint = match (starred, unread) {
|
let complaint = match (starred, unread) {
|
||||||
(0, 0) => None,
|
(0, 0) => None,
|
||||||
(0, u) => Some(format!("{} subscribed to this feed {} not played it yet", people(u), if u == 1 { "has" } else { "have" })),
|
(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!(
|
(st, u) => Some(format!(
|
||||||
"another {} kept it, and {} not played it yet",
|
"another {} pinned it, and {} not played it yet",
|
||||||
people(st),
|
people(st),
|
||||||
if u == 1 { "one person has".to_string() } else { format!("{u} have") }
|
if u == 1 { "one person has".to_string() } else { format!("{u} have") }
|
||||||
)),
|
)),
|
||||||
@@ -1335,6 +1374,8 @@ async fn media(
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct Position {
|
struct Position {
|
||||||
secs: i64,
|
secs: i64,
|
||||||
|
/// The length the player measured, for an episode whose feed gives none.
|
||||||
|
duration: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn set_position(
|
async fn set_position(
|
||||||
@@ -1343,7 +1384,7 @@ async fn set_position(
|
|||||||
user: crate::db::User,
|
user: crate::db::User,
|
||||||
Json(body): Json<Position>,
|
Json(body): Json<Position>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> 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)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
<description>A synthetic feed used by the parser tests.</description>
|
<description>A synthetic feed used by the parser tests.</description>
|
||||||
<ttl>45</ttl>
|
<ttl>45</ttl>
|
||||||
<itunes:explicit>no</itunes:explicit>
|
<itunes:explicit>no</itunes:explicit>
|
||||||
|
<itunes:category text="Technology"><itunes:category text="Podcasting"/></itunes:category>
|
||||||
|
<itunes:category text="News"/>
|
||||||
|
|
||||||
<item>
|
<item>
|
||||||
<title>Episode One</title>
|
<title>Episode One</title>
|
||||||
|
|||||||
@@ -88,8 +88,10 @@ const drive = [
|
|||||||
['opmlModal', () => ctx.opmlModal()],
|
['opmlModal', () => ctx.opmlModal()],
|
||||||
['selectFeed (directory)', () => ctx.selectFeed(':directory')],
|
['selectFeed (directory)', () => ctx.selectFeed(':directory')],
|
||||||
['selectFeed (popular)', () => ctx.selectFeed(':popular')],
|
['selectFeed (popular)', () => ctx.selectFeed(':popular')],
|
||||||
|
['selectFeed (currently listening)', () => ctx.selectFeed(':listening')],
|
||||||
['selectFeed (all subscriptions)', () => ctx.selectFeed(':all')],
|
['selectFeed (all subscriptions)', () => ctx.selectFeed(':all')],
|
||||||
['logsModal', () => ctx.logsModal()],
|
['logsModal', () => ctx.logsModal()],
|
||||||
|
['keysModal', () => ctx.keysModal()],
|
||||||
// `const S` is not reachable from here: top-level const/let do not become properties
|
// `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.
|
// of a vm context the way var and function declarations do.
|
||||||
['renderGroup', () => ctx.renderGroup(feed, [{ ...feed, id: 'child', group: 'f', orphaned: true }])],
|
['renderGroup', () => ctx.renderGroup(feed, [{ ...feed, id: 'child', group: 'f', orphaned: true }])],
|
||||||
|
|||||||
@@ -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();
|
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 }) => {
|
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. Which of Test Show's two episodes
|
// Second Episode (900s) is 42 seconds in and unfinished. An earlier test may have opened it,
|
||||||
// the daemon auto-downloaded is not fixed (see the three-panes test above), so an earlier
|
// and opening marks it read; it is listed all the same, because read is not finished. This
|
||||||
// test may have opened -- and so read -- this one already; reset it before relying on it.
|
// test used to set it unread first, which hid exactly the bug in issue #14.
|
||||||
await page.evaluate(() =>
|
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(() =>
|
await page.evaluate(() =>
|
||||||
api('/api/entries/test-show/ui-2/position', { method: 'POST', body: JSON.stringify({ secs: 42 }) }));
|
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 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' });
|
const row = page.locator('#listening .childrow', { hasText: 'Second Episode' });
|
||||||
await expect(row).toBeVisible({ timeout: 20_000 });
|
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 row.click();
|
||||||
await expect(page.locator('#player')).toBeVisible();
|
await expect(page.locator('#player')).toBeVisible();
|
||||||
await expect(page.locator('#ptitle')).toHaveText('Second Episode');
|
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 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.
|
test('a player nobody has played since it last saved does not save again', async ({ page }) => {
|
||||||
await page.evaluate(() =>
|
// A tab left paused at 41:15 saved that as it reloaded, over the 32:48 another had reached,
|
||||||
api('/api/entries/test-show/ui-2/flags', { method: 'POST', body: JSON.stringify({ read: true }) }));
|
// and the episode dropped out of Currently Listening. The fixture audio does not decode, so
|
||||||
await page.locator('#feedlist .place', { hasText: 'Popular' }).click();
|
// this stands in for a loaded file paused at 2 seconds and counts what savePos sends.
|
||||||
await expect(page.locator('#listening')).not.toContainText('Second Episode', { timeout: 20_000 });
|
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 }) => {
|
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();
|
await page.locator('.tabs button', { hasText: 'Unread' }).first().click();
|
||||||
expect(await page.locator('.ep').count()).toBeLessThanOrEqual(all);
|
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');
|
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(top.every(t => ids.includes(t.id))).toBe(true);
|
||||||
expect(ids).not.toContain('paid-show');
|
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 piper.locator('#feedlist .place', { hasText: 'Directory' }).click();
|
||||||
await expect(piper.locator('#count')).toContainText(`Directory: ${dir.length} feed`);
|
await expect(piper.locator('#count')).toContainText(`Directory: ${dir.length} feed`);
|
||||||
await expect(offered.filter({ hasText: 'Test Show' })).toBeVisible();
|
await expect(tiles.filter({ hasText: 'Test Show' })).toBeVisible();
|
||||||
await expect(offered.filter({ hasText: /Grouped Show|grouped-show/ })).toBeVisible();
|
await expect(tiles.filter({ hasText: /Grouped Show|grouped-show/ })).toBeVisible();
|
||||||
await expect(offered.filter({ hasText: /Test Subscriptions/ })).toHaveCount(0);
|
await expect(tiles.filter({ hasText: /Test Subscriptions/ })).toHaveCount(0);
|
||||||
await expect(offered.filter({ hasText: /Paid Show|paid-show/ })).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.
|
// Add a feed opened over Directory fills its own list, not the pane behind it.
|
||||||
await piper.locator('#addFeed').click();
|
await piper.locator('#addFeed').click();
|
||||||
await expect(piper.locator('#modalCard .childrow', { hasText: 'Test Show' })).toBeVisible();
|
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();
|
await piper.locator('#modalCard button[title="Cancel"]').click();
|
||||||
|
|
||||||
const row = async () =>
|
const row = async () =>
|
||||||
(await (await piper.request.get('/api/popular')).json()).find(p => p.id === 'test-show');
|
(await (await piper.request.get('/api/popular')).json()).find(p => p.id === 'test-show');
|
||||||
const before = await row();
|
const before = await row();
|
||||||
expect(before.subscribed).toBe(false);
|
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 });
|
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.
|
// 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 }) => {
|
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();
|
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.
|
// 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')));
|
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.
|
// a change made from the toolbar.
|
||||||
await page.locator('.ep').first().click();
|
await page.locator('.ep').first().click();
|
||||||
const pair = async a => [await icon(page.locator(a === 'read' ? '#tbRead' : '#tbFlag')),
|
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 expect(proxied).toHaveURL(/\/signed-out-by-the-proxy$/);
|
||||||
await ctx.close();
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
|
<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>
|
<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:image href="http://127.0.0.1:8792/art.png"/>
|
||||||
|
<itunes:category text="Technology"/>
|
||||||
<item><title>First Episode</title><guid>ui-1</guid>
|
<item><title>First Episode</title><guid>ui-1</guid>
|
||||||
<pubDate>Mon, 01 Sep 2026 10:00:00 +0000</pubDate>
|
<pubDate>Mon, 01 Sep 2026 10:00:00 +0000</pubDate>
|
||||||
<description><p>Show notes for the first one.</p></description>
|
<description><p>Show notes for the first one.</p></description>
|
||||||
|
|||||||
92
web/Inter-LICENSE.txt
Normal file
92
web/Inter-LICENSE.txt
Normal 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
BIN
web/InterVariable.woff2
Normal file
Binary file not shown.
331
web/index.html
331
web/index.html
@@ -7,6 +7,10 @@
|
|||||||
<title>iPodderX</title>
|
<title>iPodderX</title>
|
||||||
<link rel="icon" href="/icon.png">
|
<link rel="icon" href="/icon.png">
|
||||||
<style>
|
<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
|
/* 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. */
|
screen, and the amber EQ bars. Hex values in comments are sampled straight from it. */
|
||||||
:root {
|
:root {
|
||||||
@@ -91,7 +95,7 @@
|
|||||||
html,body{height:100%}
|
html,body{height:100%}
|
||||||
body{
|
body{
|
||||||
margin:0;background:var(--bg);color:var(--fg);
|
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;
|
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
|
/* 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
|
measured against whichever state happened to be current -- sized too tall while the bar
|
||||||
@@ -115,6 +119,11 @@ a{color:var(--accent)}
|
|||||||
|
|
||||||
/* ---------- shell ---------- */
|
/* ---------- shell ---------- */
|
||||||
#shell{display:grid;grid-template-columns:290px 1fr;min-height:0;min-width:0;overflow:hidden}
|
#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{
|
#sidebar{
|
||||||
background:var(--panel);border-right:1px solid var(--line);
|
background:var(--panel);border-right:1px solid var(--line);
|
||||||
display:flex;flex-direction:column;min-height:0;
|
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 .art{width:32px;height:32px;font-size:12px}
|
||||||
.childrow .txt{flex:1;min-width:0}
|
.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}
|
.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{
|
.tag{
|
||||||
font-size:11px;font-weight:600;
|
font-size:11px;font-weight:600;
|
||||||
padding:1px 5px;border-radius:4px;background:var(--raise);color:var(--warn);flex:none;
|
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)}
|
.badge.zero{background:var(--raise);color:var(--faint)}
|
||||||
/* Counts, sizes and times that change in place should not jiggle the text around them. */
|
/* 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 ---------- */
|
||||||
#main{overflow:hidden;min-height:0;min-width:0;display:flex;flex-direction:column}
|
#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}
|
.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. */
|
/* 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}
|
.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. */
|
/* 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}
|
.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
|
/* 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;
|
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;
|
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{
|
.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;
|
border-bottom:1px solid var(--line);margin-bottom:3px;
|
||||||
font-size:12px;color:var(--faint);
|
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 .i{width:8px;height:8px}
|
||||||
.ephead .hs .arr.asc{transform:rotate(-90deg)}
|
.ephead .hs .arr.asc{transform:rotate(-90deg)}
|
||||||
.ephead .hs .arr.desc{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{padding:4px 10px;border-radius:7px;border:1px solid transparent;cursor:pointer;margin-bottom:1px}
|
||||||
.ep>*{min-width:0}
|
.ep>*{min-width:0}
|
||||||
.ep.sel{background:var(--raise);border-color:var(--line)}
|
.ep.sel{background:var(--raise);border-color:var(--line)}
|
||||||
.ep:hover{background:var(--panel)}
|
.ep:hover{background:var(--panel)}
|
||||||
/* Amber means new: the unread dot, the badges and a download under way. Blue is for the one
|
/* 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. */
|
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{color:var(--faint);font-size:13px}
|
||||||
.ep .fl.on{color:var(--fg)}
|
.ep .fl.on{color:var(--fg)}
|
||||||
/* The icon's EQ bars mark what is playing: standing still, and moving only while it plays. A
|
/* 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}
|
#pnow .txt small,#pright #vol{display:none}
|
||||||
.wrap.plain{padding:14px}
|
.wrap.plain{padding:14px}
|
||||||
.card{padding:16px}
|
.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. */
|
/* A log line has no room for four columns: keep time and message, wrap as prose. */
|
||||||
#logbox{font-size:11.5px;padding:8px}
|
#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">
|
<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="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="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>
|
</div>
|
||||||
<span class="grow"></span>
|
<span class="grow"></span>
|
||||||
<input type="search" id="epSearch" placeholder="Search items…">
|
<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
|
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
|
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
|
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
|
// Pinned is the solid thumbtack; not pinned, the same shape outlined, as the flag had its regular
|
||||||
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
|
// 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
|
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
|
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
|
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.'},
|
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',
|
':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.'},
|
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},
|
':all':{title:'All Subscriptions',icon:ICON.all},
|
||||||
};
|
};
|
||||||
function renderFeeds(){
|
function renderFeeds(){
|
||||||
@@ -976,7 +1045,7 @@ function renderFeed(){
|
|||||||
</div>`) + `
|
</div>`) + `
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div class="tabs">
|
<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('')}
|
`<button data-f="${t}" class="${S.filter===t?'on':''}">${label}</button>`).join('')}
|
||||||
</div>
|
</div>
|
||||||
</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
|
/// 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.
|
/// 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(){
|
function sortHead(){
|
||||||
return '<div class="ephead"><span></span>'+COLS.map(([k,label,icon])=>{
|
return '<div class="ephead"><span></span>'+COLS.map(([k,label,icon])=>{
|
||||||
const on=S.sort.col===k;
|
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}`+
|
` title="Sort by ${label.toLowerCase()}" aria-label="Sort by ${label.toLowerCase()}">${icon||label}`+
|
||||||
`${on?`<span class="arr ${S.sort.dir}">${ICON.caret}</span>`:''}</button>`;
|
`${on?`<span class="arr ${S.sort.dir}">${ICON.caret}</span>`:''}</button>`;
|
||||||
}).join('')+'<span></span></div>';
|
}).join('')+'<span></span></div>';
|
||||||
@@ -1159,7 +1228,7 @@ function epEl(e){
|
|||||||
<button class="st" data-a="read" title="Mark ${e.read?'unread':'read'}">${
|
<button class="st" data-a="read" title="Mark ${e.read?'unread':'read'}">${
|
||||||
player.guid===e.guid?EQ:(e.read?'':'●')}</button>
|
player.guid===e.guid?EQ:(e.read?'':'●')}</button>
|
||||||
<button class="fl${e.flagged?' on':''}" data-a="flag" title="${
|
<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">
|
<div class="body">
|
||||||
<span class="t">${esc(e.title||'(untitled)')}</span>
|
<span class="t">${esc(e.title||'(untitled)')}</span>
|
||||||
<div class="line">${[
|
<div class="line">${[
|
||||||
@@ -1284,10 +1353,10 @@ function syncTools(e){
|
|||||||
$('#tbRead').disabled=$('#tbFlag').disabled=!e;
|
$('#tbRead').disabled=$('#tbFlag').disabled=!e;
|
||||||
// The same icons as the item's own buttons beside its title, so the two never disagree.
|
// 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;
|
$('#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){
|
if(e){
|
||||||
$('#tbRead').title=`Mark ${e.read?'unread':'read'}`;
|
$('#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>')}
|
.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'}"
|
<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>
|
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'}"
|
<button class="btn ico" data-a="flag" title="${e.flagged?'Pinned: never deleted. Unpin':'Pin, so it is never deleted'}"
|
||||||
aria-label="${e.flagged?'Stop keeping':'Keep'}">${e.flagged?ICON.flagOn:ICON.flag}</button>
|
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"
|
${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>`:''}
|
title="Open the original" aria-label="Open the original">${ICON.open}</a>`:''}
|
||||||
</div>
|
</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.
|
// 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;
|
const resuming = player.guid===e.guid && player.enc===enc.id;
|
||||||
if(!resuming){
|
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');
|
document.body.classList.toggle('has-video', kindOf(enc)==='video');
|
||||||
audio.src=`/media/${enc.id}`;
|
audio.src=`/media/${enc.id}`;
|
||||||
audio.currentTime=0;
|
audio.currentTime=0;
|
||||||
@@ -1470,23 +1539,36 @@ audio.addEventListener('timeupdate',()=>{
|
|||||||
$('#pcur').textContent=clock(audio.currentTime);
|
$('#pcur').textContent=clock(audio.currentTime);
|
||||||
$('#pdur').textContent=clock(d);
|
$('#pdur').textContent=clock(d);
|
||||||
if(d) $('#seek').value=String(Math.round(audio.currentTime/d*1000));
|
if(d) $('#seek').value=String(Math.round(audio.currentTime/d*1000));
|
||||||
// Persist roughly every 10s so a reload resumes where you were.
|
// Only playing counts as moving: the seek to where you left off happens paused, and saving
|
||||||
if(player.guid && audio.currentTime-player.saveAt>10){ savePos(); }
|
// 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();
|
if(d && audio.currentTime/d >= 0.9) markPlayed();
|
||||||
});
|
});
|
||||||
function savePos(){
|
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;
|
player.saveAt=audio.currentTime;
|
||||||
if(player.entry) player.entry.position=Math.floor(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?.(
|
navigator.sendBeacon?.(
|
||||||
`/api/entries/${encodeURIComponent(player.feed)}/${encodeURIComponent(player.guid)}/position`,
|
`/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('pause',savePos);
|
||||||
audio.addEventListener('ended',()=>{savePos();markPlayed();$('#pplay').innerHTML=ICON.play});
|
audio.addEventListener('ended',()=>{savePos();markPlayed();$('#pplay').innerHTML=ICON.play});
|
||||||
// body.playing is what sets the EQ bars moving.
|
// body.playing is what sets the EQ bars moving.
|
||||||
audio.addEventListener('play',()=>{ $('#pplay').innerHTML=ICON.pause; document.body.classList.add('playing'); });
|
audio.addEventListener('play',()=>{ $('#pplay').innerHTML=ICON.pause; document.body.classList.add('playing'); });
|
||||||
audio.addEventListener('pause',()=>{ $('#pplay').innerHTML=ICON.play; document.body.classList.remove('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);
|
window.addEventListener('beforeunload',savePos);
|
||||||
$('#pplay').onclick=()=>audio.paused?audio.play():audio.pause();
|
$('#pplay').onclick=()=>audio.paused?audio.play():audio.pause();
|
||||||
$('#pback').onclick=()=>audio.currentTime-=15;
|
$('#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};
|
$('#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)};
|
$('#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)};
|
$('#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(){
|
(function restore(){
|
||||||
const r=localStorage.getItem('ipx.rate'), v=localStorage.getItem('ipx.vol');
|
const r=localStorage.getItem('ipx.rate'), v=localStorage.getItem('ipx.vol');
|
||||||
if(r){$('#rate').value=r;audio.playbackRate=+r}
|
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==='ArrowLeft'&&player.guid){audio.currentTime-=15}
|
||||||
else if(ev.key==='ArrowRight'&&player.guid){audio.currentTime+=30}
|
else if(ev.key==='ArrowRight'&&player.guid){audio.currentTime+=30}
|
||||||
else if(ev.key==='/'){ev.preventDefault();$('#epSearch')?.focus()}
|
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 ---------------- */
|
/* ---------------- modals ---------------- */
|
||||||
function openModal(html,wide){
|
function openModal(html,wide){
|
||||||
$('#modalCard').innerHTML=html;
|
$('#modalCard').innerHTML=html;
|
||||||
@@ -1646,13 +1795,20 @@ $('#addFeed').onclick=()=>{
|
|||||||
// What everyone here reads, you included, as a place to start. The rows carry an id, never a
|
// 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
|
// 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.
|
// 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){
|
async function listFeeds(url,box){
|
||||||
let rows=[];
|
let rows=[];
|
||||||
try{ rows=await api(url)||[]; }catch{}
|
try{ rows=await api(url)||[]; }catch{}
|
||||||
box.innerHTML=rows.length?'':'<p class="hint">Nothing yet. Feeds people here subscribe to show up here.</p>';
|
box.innerHTML=rows.length?'':NONE_LISTED;
|
||||||
for(const p of rows){
|
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');
|
const el=document.createElement('div');
|
||||||
el.className='childrow';
|
el.className=cls;
|
||||||
el.innerHTML=artHTML(p.image,p.title||p.id)+
|
el.innerHTML=artHTML(p.image,p.title||p.id)+
|
||||||
`<div class="txt"><b>${esc(p.title||p.id)}</b>`+
|
`<div class="txt"><b>${esc(p.title||p.id)}</b>`+
|
||||||
`<small class="meta">${p.subscribers} subscriber${p.subscribers===1?'':'s'}</small></div>`+
|
`<small class="meta">${p.subscribers} subscriber${p.subscribers===1?'':'s'}</small></div>`+
|
||||||
@@ -1660,7 +1816,7 @@ async function listFeeds(url,box){
|
|||||||
(p.subscribed?`<span class="subbed" title="Subscribed: click to open it" aria-label="Subscribed">${ICON.subbed}</span>`
|
(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>`);
|
:`<button class="btn ico" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.subbed}</button>`);
|
||||||
// Yours already: the row opens it instead.
|
// Yours already: the row opens it instead.
|
||||||
if(p.subscribed){ el.onclick=()=>{ closeModal(); selectFeed(p.id); }; box.appendChild(el); continue; }
|
if(p.subscribed){ el.onclick=()=>{ closeModal(); selectFeed(p.id); }; return el; }
|
||||||
$('[data-a="sub"]',el).onclick=async()=>{
|
$('[data-a="sub"]',el).onclick=async()=>{
|
||||||
try{
|
try{
|
||||||
await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'});
|
await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'});
|
||||||
@@ -1668,8 +1824,44 @@ async function listFeeds(url,box){
|
|||||||
await loadFeeds(true); selectFeed(p.id);
|
await loadFeeds(true); selectFeed(p.id);
|
||||||
}catch(e){ toast(e.message,true); }
|
}catch(e){ toast(e.message,true); }
|
||||||
};
|
};
|
||||||
box.appendChild(el);
|
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.innerHTML='';
|
||||||
|
for(const p of ofKind.filter(p=>!dirCat||p.category===dirCat)) box.appendChild(listedFeed(p,'tile'));
|
||||||
|
};
|
||||||
|
draw();
|
||||||
return rows.length;
|
return rows.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1680,42 +1872,76 @@ async function renderListed(v){
|
|||||||
$('#tbRemove').disabled=true;
|
$('#tbRemove').disabled=true;
|
||||||
syncTools(null);
|
syncTools(null);
|
||||||
$('#epSearch').placeholder='Search items…';
|
$('#epSearch').placeholder='Search items…';
|
||||||
const listening=v===VIEWS[':popular'];
|
const listening=v===VIEWS[':listening'], grid=v===VIEWS[':directory'];
|
||||||
box.innerHTML=`
|
box.innerHTML=`
|
||||||
<div class="fhead slim">
|
<div class="fhead slim">
|
||||||
<div class="art">${v.icon}</div>
|
<div class="art">${v.icon}</div>
|
||||||
<div class="meta"><h2>${v.title}</h2>
|
<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>
|
||||||
<div class="childlist" id="popular"><p class="hint">Loading…</p></div>
|
${grid?'<div class="dirbar" id="dirbar"></div>':''}
|
||||||
${listening?`<div class="sub" style="margin:18px 0 8px;font-weight:600;color:var(--fg)">Currently Listening</div>
|
<div class="${grid?'tiles':'childlist'}" id="${listening?'listening':'popular'}"><p class="hint">Loading…</p></div>`;
|
||||||
<div class="childlist" id="listening"><p class="hint">Loading…</p></div>`:''}`;
|
|
||||||
$('#count').textContent=v.title;
|
$('#count').textContent=v.title;
|
||||||
const n=await listFeeds(v.url,$('#popular',box));
|
const n=await (listening?renderListening:grid?renderDirectory:listFeeds)(v.url,$(listening?'#listening':'#popular',box));
|
||||||
if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${n} feed${n===1?'':'s'}`;
|
if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${plural(n,listening?'episode':'feed')}`;
|
||||||
if(listening) renderListening();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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
|
/// 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.
|
/// where you left off, not another way to browse. The one in the player pauses instead.
|
||||||
async function renderListening(){
|
async function renderListening(url,box){
|
||||||
const box=$('#listening');
|
|
||||||
let rows=[];
|
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>';
|
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){
|
for(const e of rows){
|
||||||
const el=document.createElement('div');
|
const el=document.createElement('div');
|
||||||
el.className='childrow';
|
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||'')+
|
el.innerHTML=artHTML(e.image||feedArt(e.feed_id),e.title||'')+
|
||||||
`<div class="txt"><b>${esc(e.title||'(untitled)')}</b>`+
|
`<div class="txt"><b>${EQ}<span>${esc(e.title||'(untitled)')}</span></b>`+
|
||||||
`<small class="meta">${esc(feedName(e.feed_id))} · ${clock(e.position)} of ${e.duration?clock(e.duration):'?'}</small>`+
|
`<small><span class="fd">${esc(feedName(e.feed_id))}</span><span class="left"></span></small></div>`+
|
||||||
`<div class="dlbar live"><i style="width:${pct}%"></i></div></div>`+
|
`<button class="iconbtn" data-a="play"></button>`+
|
||||||
`<button class="btn ico primary" title="Resume" aria-label="Resume">${ICON.play}</button>`;
|
`<button class="iconbtn" data-a="remove" title="Remove from Currently Listening" aria-label="Remove from Currently Listening">${ICON.close}</button>`+
|
||||||
el.onclick=()=>play(e);
|
`<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);
|
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.
|
// 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>
|
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>
|
<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}">
|
<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>
|
items are never touched.</span></div>
|
||||||
<div class="field"><label>Delete items older than (days, 0 = keep)</label>
|
<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>`:''}
|
<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)}">
|
<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
|
<span class="hint">Where the files land. There is one copy however many people
|
||||||
subscribe, so this is the same for everyone.</span></div>`:''}
|
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>
|
<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>`);
|
<button class="btn ico primary" id="ssave" title="Save" aria-label="Save">${ICON.check}</button></div>`);
|
||||||
$('#scopy').onclick=()=>copyText($('#surl').value,$('#scopy'));
|
$('#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()=>{
|
$('#ssave').onclick=async()=>{
|
||||||
const max=$('#smax').value;
|
const max=$('#smax').value;
|
||||||
try{
|
try{
|
||||||
@@ -1931,6 +2167,7 @@ function settingsModal(f,newUrl){
|
|||||||
if(S.me&&S.me.admin){
|
if(S.me&&S.me.admin){
|
||||||
patch.url=$('#surl').value.trim();
|
patch.url=$('#surl').value.trim();
|
||||||
patch.folder=$('#sfolder').value.trim()||null;
|
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)});
|
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify(patch)});
|
||||||
closeModal(); toast('Saved — applies on the next scan');
|
closeModal(); toast('Saved — applies on the next scan');
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<title>Sign in — iPodderX</title>
|
<title>Sign in — iPodderX</title>
|
||||||
<link rel="icon" href="/icon.png">
|
<link rel="icon" href="/icon.png">
|
||||||
<style>
|
<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 {
|
:root {
|
||||||
--bg:#0e131b; /* the screen's navy (#314B74), taken right down */
|
--bg:#0e131b; /* the screen's navy (#314B74), taken right down */
|
||||||
--panel:#151c27;
|
--panel:#151c27;
|
||||||
@@ -40,7 +42,7 @@
|
|||||||
html,body{height:100%}
|
html,body{height:100%}
|
||||||
body{
|
body{
|
||||||
margin:0;display:grid;place-items:center;background:var(--bg);color:var(--fg);
|
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{
|
form{
|
||||||
width:min(360px,100%);background:var(--panel);border:1px solid var(--line);
|
width:min(360px,100%);background:var(--panel);border:1px solid var(--line);
|
||||||
|
|||||||
Reference in New Issue
Block a user