Compare commits
46 Commits
v0.10.1
...
deadair-ui
| Author | SHA1 | Date | |
|---|---|---|---|
| 00bc039964 | |||
| 111a0e29a4 | |||
| 827cc7489f | |||
| 16b0686aab | |||
| 63e192767d | |||
| 8687e4ff1b | |||
| 1e7a858e9c | |||
| c37bcf816b | |||
| 4ac1e6dbbc | |||
| 22a99110e8 | |||
| cb92d5e355 | |||
| e3f0267814 | |||
| f4a8ba4ea8 | |||
| 4c1d230044 | |||
| ecbc253894 | |||
| b4c115d79a | |||
| 4e417389fe | |||
| 19f77a21d9 | |||
| 65ca481525 | |||
| 92cc0cf4ec | |||
| 6a69818ef0 | |||
| 2de5f81202 | |||
| 4db5ee5c44 | |||
| 6ddd6444f9 | |||
| e710103ba6 | |||
| dc340dd4e9 | |||
| ebecc12193 | |||
| 33e031dd13 | |||
| 60cbb7aff6 | |||
| ec55682c13 | |||
| 15291298aa | |||
| c426ab6314 | |||
| d5edead011 | |||
| c7eef3e516 | |||
| a12aa59970 | |||
| a553931c76 | |||
| 46f9370018 | |||
| 70c49921ab | |||
| 0f93d1bd0d | |||
| 4f4f2e8d95 | |||
| c1b73b3dbe | |||
| fc0d6bf98b | |||
| a44e2b4235 | |||
| 2098343621 | |||
| 5684a02e79 | |||
| 50c3b9db64 |
56
CHANGELOG.md
56
CHANGELOG.md
@@ -7,6 +7,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- DeadAir: turn it on for a show in its settings, or with the button beside the speed while it plays, and its episodes play with long silences shortened, so podcasts finish sooner without sounding sped up. Your place carries over when you turn it on or off, each episode says how much shorter it is, and Settings shows the time it has saved you. It is off for every show until you turn it on (#171, #172).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Cover art up to 20 MB is shown, instead of nothing for anything over 5 MB, as for five of the Glass Cannon Network's feeds.
|
||||||
|
- Seeking in an episode while many people listen answers in about 10 ms instead of 40, and files stream about four times as fast: each connection now sends what is ready without waiting for the browser to acknowledge the last part.
|
||||||
|
- Artwork that could not be fetched is not fetched again for an hour, instead of on every page load, and the server's log says which image it was and why.
|
||||||
|
|
||||||
|
## [0.10.3] - 2026-10-09
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Swiping past the last item shows the theme's accent colour, without the words "No more items".
|
||||||
|
- An empty list and the reader with nothing picked say nothing, instead of "Nothing here yet — try Scan now." and "Pick an item to read it."; a search that finds nothing still says so.
|
||||||
|
- In Settings, the API tokens sit in a box of their own.
|
||||||
|
- A feed's settings are redesigned: downloading on its own is a switch with what depends on it beneath, how many is Default, Newest or All shown on a strip of the show's own episodes, words to pick or hide are chips, the feed's address is at the top with a copy button, and Save is always in reach. Its folder and Directory category are no longer in it.
|
||||||
|
- The feed list's traces show each of its database queries, so it can be told where its time goes.
|
||||||
|
- Saving a feed's settings asks the database for your subscription once, not twice.
|
||||||
|
- `ipx add` and Add feed in the page add a feed the same way, through one piece of code.
|
||||||
|
- Glass's top bar is solid on a phone, in the colour its glass showed, so the strip above it on an iPhone can match it.
|
||||||
|
- A pinned item or feed's pin button is filled with the theme's accent, in the reader, on the feed's page and in the toolbar, so pinned no longer looks like not pinned.
|
||||||
|
- A feed's page in the Directory says how many people here subscribe only when someone does, without "Nobody here subscribes yet".
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The unread counts catch up when you come back to the page: an item read on another device, or while the phone app was in the background, no longer stays counted as unread until a reload.
|
||||||
|
- A feed set to download 0 of its newest items downloads all of them, the whole back catalogue, as the server's 0 does; it downloaded nothing.
|
||||||
|
- The feed list loads faster: it counts the items and files of your own feeds, not of every feed on the server.
|
||||||
|
- Downloading or deleting a file the server no longer has answers "not found" instead of a server error.
|
||||||
|
- A feed's page in the Directory, and subscribing from it, no longer wait while the whole Directory is put together to check that the one feed is in it.
|
||||||
|
- The feed list, and each live update of it, no longer asks the database for a group's title once for every feed in the group.
|
||||||
|
- An episode that came as a torrent of several files plays its largest file, and can be deleted, by hand or to free space, instead of staying on disk for good.
|
||||||
|
- Removing an account takes the feeds only that person read off the server, as unsubscribing does, instead of leaving them downloading every new episode for no one.
|
||||||
|
- Freeing disk space counts only the people who subscribe to a feed: an episode is no longer taken as heard by everyone because someone who has since unsubscribed heard it, and their pins no longer keep files for good.
|
||||||
|
- An episode whose file turns out to be a torrent shows as downloaded once it is, instead of being fetched again on every check of its feed, and no longer holds up the checking of other feeds while it downloads and seeds.
|
||||||
|
- Downloading the latest 65 to 100 items of a feed queues them at once, instead of hanging until most had downloaded and holding up adding feeds and Refresh meanwhile.
|
||||||
|
- Changes to the feed list made at the same moment, such as two people adding feeds, or a feed added while the server settings change, no longer undo one another.
|
||||||
|
- A download that stops partway is given up after two minutes without data, instead of holding up every scan and download until a restart.
|
||||||
|
- On a phone, the closed feed list no longer shades the left edge of the page with its shadow.
|
||||||
|
- On an iPhone, the top bar's colour runs all the way up behind the status bar and the Dynamic Island, with no band of another colour above it.
|
||||||
|
- A feed's buttons stay on one row on a narrow phone, and the reader's buttons stay together instead of breaking across lines under a long feed name.
|
||||||
|
- A moment's DNS failure no longer marks a feed as failing for a day or calls it gone: iPX tries it again in ten minutes. One failed lookup one morning left 205 podcasts unread, uncategorised and labelled gone until the next day. A name that does not resolve is said to be maybe gone, and only once it has been failing a day, on its Directory page as on its own.
|
||||||
|
|
||||||
|
## [0.10.2] - 2026-10-05
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- A publisher's correction to an item already in iPX, a retitled episode, mended text, new artwork or a fixed length, arrives on the next read. Since 0.9.1 an item kept what it first said: Ain't It Cool News's items each showed the text of the one before, long after the feed was fixed.
|
||||||
|
- A feed's page in the Directory says when the feed is failing, and why, and no longer says nobody here subscribes to a feed someone does.
|
||||||
|
|
||||||
## [0.10.1] - 2026-10-05
|
## [0.10.1] - 2026-10-05
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -751,7 +803,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- 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/ipx/compare/v0.10.1...main
|
[unreleased]: https://git.sdf1.net/rays/ipx/compare/v0.10.3...main
|
||||||
|
[0.10.3]: https://git.sdf1.net/rays/ipx/compare/v0.10.2...v0.10.3
|
||||||
|
[0.10.2]: https://git.sdf1.net/rays/ipx/compare/v0.10.1...v0.10.2
|
||||||
[0.10.1]: https://git.sdf1.net/rays/ipx/compare/v0.10.0...v0.10.1
|
[0.10.1]: https://git.sdf1.net/rays/ipx/compare/v0.10.0...v0.10.1
|
||||||
[0.10.0]: https://git.sdf1.net/rays/ipx/compare/v0.9.1...v0.10.0
|
[0.10.0]: https://git.sdf1.net/rays/ipx/compare/v0.9.1...v0.10.0
|
||||||
[0.9.1]: https://git.sdf1.net/rays/ipx/compare/v0.9.0...v0.9.1
|
[0.9.1]: https://git.sdf1.net/rays/ipx/compare/v0.9.0...v0.9.1
|
||||||
|
|||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -1820,7 +1820,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipx"
|
name = "ipx"
|
||||||
version = "0.10.1"
|
version = "0.10.4-dev"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ammonia",
|
"ammonia",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ipx"
|
name = "ipx"
|
||||||
version = "0.10.1"
|
version = "0.10.4-dev"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -26,7 +26,7 @@ sea-orm = { version = "2.0.3", default-features = false, features = ["sqlx-sqlit
|
|||||||
serde = { version = "1.0.229", features = ["derive"] }
|
serde = { version = "1.0.229", features = ["derive"] }
|
||||||
serde_json = "1.0.151"
|
serde_json = "1.0.151"
|
||||||
sha2 = "0.10.9"
|
sha2 = "0.10.9"
|
||||||
tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "fs", "io-util", "net", "sync", "time", "signal"] }
|
tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "fs", "io-util", "net", "sync", "time", "signal", "process"] }
|
||||||
toml = "1.1.5"
|
toml = "1.1.5"
|
||||||
tower = { version = "0.5.3", features = ["util"] }
|
tower = { version = "0.5.3", features = ["util"] }
|
||||||
tower-http = { version = "0.7.1", features = ["fs"] }
|
tower-http = { version = "0.7.1", features = ["fs"] }
|
||||||
|
|||||||
@@ -24,8 +24,10 @@ COPY web ./web
|
|||||||
RUN touch src/main.rs && cargo build --release --locked
|
RUN touch src/main.rs && cargo build --release --locked
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
|
# ffmpeg is DeadAir's (#171): it finds the silences and copies the frames kept. Debian's adds
|
||||||
|
# about 480 MB, once: the layer stays cached until the base image changes.
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
ca-certificates gosu \
|
ca-certificates gosu ffmpeg \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
COPY --from=build /src/target/release/ipx /usr/local/bin/ipx
|
COPY --from=build /src/target/release/ipx /usr/local/bin/ipx
|
||||||
COPY docker-entrypoint.sh /usr/local/bin/
|
COPY docker-entrypoint.sh /usr/local/bin/
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ build needs node and `npm ci` run once.
|
|||||||
re-derived into the database each scan, never written to config.toml. A Patreon creator link
|
re-derived into the database each scan, never written to config.toml. A Patreon creator link
|
||||||
(a token, no `show=`) with more than one show is treated the same way, before any fetch: its
|
(a token, no `show=`) with more than one show is treated the same way, before any fetch: its
|
||||||
shows come from Patreon's web API and each becomes a derived feed.
|
shows come from Patreon's web API and each becomes a derived feed.
|
||||||
4. Record entries. A changed title or description flips the item back to unread.
|
4. Record entries: the new ones, and those the feed now gives a different title, text, artwork,
|
||||||
|
length or number, compared with what is stored for the items it lists (#141). A corrected item
|
||||||
|
keeps its read state.
|
||||||
5. Record enclosures. `enclosures.url` is `UNIQUE`, which is the dedupe key and subsumes the
|
5. Record enclosures. `enclosures.url` is `UNIQUE`, which is the dedupe key and subsumes the
|
||||||
original's `history.dat` pickle: a reaped file keeps its row so it is never fetched twice.
|
original's `history.dat` pickle: a reaped file keeps its row so it is never fetched twice.
|
||||||
6. Apply the merged policy (see [users.md](users.md)) and mark anything rejected as `skipped` with
|
6. Apply the merged policy (see [users.md](users.md)) and mark anything rejected as `skipped` with
|
||||||
@@ -90,13 +92,15 @@ printf '{"cmd":"fetch","force":true}\n' | socat - UNIX-CONNECT:$XDG_RUNTIME_DIR/
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Commands** — `fetch` (optional `feed`, `force`), `reap` (optional `dry_run`), `download`
|
**Commands** — `fetch` (optional `feed`, `force`), `reap` (optional `dry_run`), `download`
|
||||||
(`enclosure`), `status`.
|
(`enclosure`), `status`, `reload` (read the catalogue and settings again from the database, which
|
||||||
|
`ipx add`, `rm` and `import` send after changing them in a process of their own; answered with
|
||||||
|
`status`).
|
||||||
|
|
||||||
**Events** — `feed_start`, `feed_skip`, `feed_done`, `feed_error`, `progress`, `download_done`,
|
**Events** — `feed_start`, `feed_skip`, `feed_done`, `feed_error`, `progress`, `download_done`,
|
||||||
`download_error`, `torrent_deferred`, `reaped`, `reap_done`, `scan_done`, `status`, `error`.
|
`download_error`, `torrent_deferred`, `reaped`, `reap_done`, `scan_done`, `status`, `error`.
|
||||||
`scan_done`, `reap_done` and `status` are terminal: a client that asked for work stops reading
|
`scan_done`, `reap_done` and `status` are terminal: a client that asked for work stops reading
|
||||||
there. Commands run one at a time, in the order they arrive, except `status`: the socket answers it
|
there. Commands run one at a time, in the order they arrive, except `status` and `reload`: the
|
||||||
straight away, so the Docker healthcheck is never left waiting behind a scan or a download, and
|
socket answers them straight away, so the Docker healthcheck is never left waiting behind a scan or a download, and
|
||||||
answers only the client that asked, since `status` would end any other client's session.
|
answers only the client that asked, since `status` would end any other client's session.
|
||||||
|
|
||||||
Progress carries the enclosure id, without which a UI cannot tell one download from another and
|
Progress carries the enclosure id, without which a UI cannot tell one download from another and
|
||||||
@@ -128,7 +132,7 @@ else a `401`. A feed's items and files (its entries, `download-latest`, `/api/en
|
|||||||
| `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/directory/{id}` | a listed feed's description and latest twenty items, for its page before you subscribe: title, link, date, length and text, never a file or its address, and only for a feed the directory lists |
|
| `GET /api/directory/{id}` | a listed feed's description, why its last check failed when it did (in words, never its error, which can name its address), and latest twenty items, for its page before you subscribe: title, link, date, length and text, never a file or its address, and only for a feed the directory lists |
|
||||||
| `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/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 |
|
||||||
|
|||||||
@@ -13,13 +13,17 @@ processes must never download the same thing. `--local` forces the work to happe
|
|||||||
| `ipx list` | Subscriptions and their state |
|
| `ipx list` | Subscriptions and their state |
|
||||||
| `ipx status` | Counts: feeds, pending, downloaded |
|
| `ipx status` | Counts: feeds, pending, downloaded |
|
||||||
| `ipx fetch [FEED] [--force]` | Scan everything, or one feed. `--force` ignores the TTL |
|
| `ipx fetch [FEED] [--force]` | Scan everything, or one feed. `--force` ignores the TTL |
|
||||||
| `ipx add <url> [--folder X] [--keywords a,b]` | Subscribe; the id comes from the feed title |
|
| `ipx add <url> [--folder X] [--keywords a,b] [--list] [--category C]` | Subscribe; the id comes from the feed title. `--list` puts it in the Directory for anyone to subscribe to instead, and keeps it there when its last subscriber leaves; `--category` files it under one of the Directory's categories. Run for a feed already in the catalogue, these list it or set its category |
|
||||||
| `ipx rm <feed>` | Unsubscribe; downloads and history are kept |
|
| `ipx rm <feed>` | Unsubscribe; downloads and history are kept |
|
||||||
| `ipx import <file.opml>` / `ipx export <file.opml>` | Move subscriptions in or out. Import subscribes the first admin, as the shared web token does; in the web UI it subscribes whoever is signed in |
|
| `ipx import <file.opml>` / `ipx export <file.opml>` | Move subscriptions in or out. Import subscribes the first admin, as the shared web token does; in the web UI it subscribes whoever is signed in |
|
||||||
| `ipx reap [--dry-run]` | Run retention now |
|
| `ipx reap [--dry-run]` | Run retention now |
|
||||||
| `ipx user <add\|list\|passwd\|rm>` | Accounts for the web UI |
|
| `ipx user <add\|list\|passwd\|rm>` | Accounts for the web UI |
|
||||||
| `ipx daemon [--web ADDR]` | Scheduler, control socket and web UI |
|
| `ipx daemon [--web ADDR]` | Scheduler, control socket and web UI |
|
||||||
|
|
||||||
|
`add`, `rm` and `import` change the catalogue in a process of their own. With a daemon running they
|
||||||
|
then tell it to read the catalogue again; without that it kept its own copy and wrote it back at
|
||||||
|
its next change, undoing them. If it does not answer they say so: restart it.
|
||||||
|
|
||||||
## Accounts
|
## Accounts
|
||||||
|
|
||||||
Passwords are read from **stdin**, so they miss the shell history and any `ps` listing.
|
Passwords are read from **stdin**, so they miss the shell history and any `ps` listing.
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ config.toml's default location is `$XDG_CONFIG_HOME/ipx/config.toml`
|
|||||||
Postgres database instead. Back SQLite up by copying `state.db` while the daemon is stopped, or
|
Postgres database instead. Back SQLite up by copying `state.db` while the daemon is stopped, or
|
||||||
with `sqlite3 state.db .backup`; back Postgres up with `pg_dump`.
|
with `sqlite3 state.db .backup`; back Postgres up with `pg_dump`.
|
||||||
|
|
||||||
|
ipx does not wait for the disk to confirm each write: SQLite syncs at checkpoints
|
||||||
|
(`synchronous=NORMAL`), and ipx's own Postgres sessions set `synchronous_commit=off`, which leaves
|
||||||
|
other databases on the server as they are. Waiting made a scan take most of a second a feed. A
|
||||||
|
crash of ipx loses nothing; a power cut, or a crash of the Postgres server, can lose the last moment
|
||||||
|
of changes, a read mark or a saved position, and never corrupts the database.
|
||||||
|
|
||||||
## `[general]`
|
## `[general]`
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
@@ -48,7 +54,8 @@ database as described above; `download_dir`, `socket` and `organize` stay in con
|
|||||||
* **`schedule`** — how often feeds are re-checked. A feed's own `<ttl>` still wins when it asks to
|
* **`schedule`** — how often feeds are re-checked. A feed's own `<ttl>` still wins when it asks to
|
||||||
be polled *less* often, and a per-feed `schedule` overrides both. A feed that keeps failing
|
be polled *less* often, and a per-feed `schedule` overrides both. A feed that keeps failing
|
||||||
waits as long as it has been failing before the next try, up to a day, and is back on schedule
|
waits as long as it has been failing before the next try, up to a day, and is back on schedule
|
||||||
after its first success. Admin-only from the UI.
|
after its first success. A moment's failure to look its host up does not count: the network, not
|
||||||
|
the feed, failed, and it is tried again in ten minutes. Admin-only from the UI.
|
||||||
* **`organize`** — `feed` files downloads under the feed's folder; `date` under `YYYY-MM-DD`.
|
* **`organize`** — `feed` files downloads under the feed's folder; `date` under `YYYY-MM-DD`.
|
||||||
* **`max_total_gb`** — the reaper deletes to get back under this, oldest first, keeping a 50 MB
|
* **`max_total_gb`** — the reaper deletes to get back under this, oldest first, keeping a 50 MB
|
||||||
pad. Kept items are never deleted, and a file only counts as read once every subscriber has
|
pad. Kept items are never deleted, and a file only counts as read once every subscriber has
|
||||||
|
|||||||
@@ -87,6 +87,10 @@ Patreon or Supercast, which put the key in the path, and any feed inside an OPML
|
|||||||
itself. Those are someone's paid subscriptions, and listing them would let anyone here read what
|
itself. Those are someone's paid subscriptions, and listing them would let anyone here read what
|
||||||
they pay for.
|
they pay for.
|
||||||
|
|
||||||
|
**Currently Listening**, under the Directory, is every episode you started and have not finished,
|
||||||
|
across all your feeds, with how much is left. A click picks one up where you left off. The search
|
||||||
|
box looks through it, as it does a feed's items, and through the Directory by name.
|
||||||
|
|
||||||
An admin can do the same from **Settings → Manage users…**: add someone (with a password, or none
|
An admin can do the same from **Settings → Manage users…**: add someone (with a password, or none
|
||||||
for someone the proxy signs in), tick or untick Admin, or remove an account. Removing one takes its
|
for someone the proxy signs in), tick or untick Admin, or remove an account. Removing one takes its
|
||||||
subscriptions and read state with it; downloaded files stay. The only admin cannot be demoted or
|
subscriptions and read state with it; downloaded files stay. The only admin cannot be demoted or
|
||||||
|
|||||||
44
src/art.rs
44
src/art.rs
@@ -2,6 +2,9 @@
|
|||||||
//! of each publisher's server for every visit, and still there when a publisher is not.
|
//! of each publisher's server for every visit, and still there when a publisher is not.
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{LazyLock, Mutex};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
pub fn dir() -> PathBuf {
|
pub fn dir() -> PathBuf {
|
||||||
crate::config::data_dir().join("art")
|
crate::config::data_dir().join("art")
|
||||||
@@ -38,6 +41,36 @@ pub fn write(file: &Path, kind: &str, body: &[u8]) -> std::io::Result<()> {
|
|||||||
std::fs::rename(&tmp, file)
|
std::fs::rename(&tmp, file)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How long an address that could not be fetched is answered from memory before it is tried again.
|
||||||
|
const RETRY: Duration = Duration::from_secs(60 * 60);
|
||||||
|
|
||||||
|
/// Addresses that could not be fetched lately, and why. Without it a page shown again fetched
|
||||||
|
/// them again: five covers too big to keep were downloaded from Patreon on every load (#169).
|
||||||
|
// ponytail: in memory, and an entry goes only when it is asked for after RETRY. It holds at most
|
||||||
|
// one per artwork address a feed or item names; move it to disk or prune it on trim if that grows.
|
||||||
|
static FAILED: LazyLock<Mutex<HashMap<String, (Instant, String)>>> = LazyLock::new(Default::default);
|
||||||
|
|
||||||
|
/// Why `url` could not be fetched, if it failed less than an hour ago.
|
||||||
|
pub fn failed(url: &str) -> Option<String> {
|
||||||
|
failed_as_of(url, Instant::now())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn failed_as_of(url: &str, now: Instant) -> Option<String> {
|
||||||
|
let mut map = FAILED.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
match map.get(url) {
|
||||||
|
Some((at, why)) if now.duration_since(*at) < RETRY => Some(why.clone()),
|
||||||
|
Some(_) => {
|
||||||
|
map.remove(url);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fail(url: &str, why: &str) {
|
||||||
|
FAILED.lock().unwrap_or_else(|e| e.into_inner()).insert(url.to_owned(), (Instant::now(), why.to_owned()));
|
||||||
|
}
|
||||||
|
|
||||||
/// Removes the least recently used images until what is kept fits in `limit` bytes; 0 empties it.
|
/// Removes the least recently used images until what is kept fits in `limit` bytes; 0 empties it.
|
||||||
/// Returns how many went.
|
/// Returns how many went.
|
||||||
pub fn trim(dir: &Path, limit: u64) -> usize {
|
pub fn trim(dir: &Path, limit: u64) -> usize {
|
||||||
@@ -89,4 +122,15 @@ mod tests {
|
|||||||
assert_eq!(trim(&d, 0), 2);
|
assert_eq!(trim(&d, 0), 2);
|
||||||
let _ = std::fs::remove_dir_all(&d);
|
let _ = std::fs::remove_dir_all(&d);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_failure_is_remembered_for_an_hour() {
|
||||||
|
let u = "https://example.com/a-failure-is-remembered.png";
|
||||||
|
assert_eq!(failed(u), None);
|
||||||
|
fail(u, "larger than 5 MB");
|
||||||
|
assert_eq!(failed(u).as_deref(), Some("larger than 5 MB"));
|
||||||
|
assert_eq!(failed_as_of(u, Instant::now() + RETRY), None);
|
||||||
|
// Asked for after the hour, it is forgotten, not just ignored.
|
||||||
|
assert_eq!(failed(u), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
433
src/db.rs
433
src/db.rs
@@ -2,7 +2,7 @@
|
|||||||
//! per-feed .ipxd plists, history.dat and qmcache.dat.
|
//! per-feed .ipxd plists, history.dat and qmcache.dat.
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use crate::entity::{api_tokens, blocklists, catalogue, enclosures, entries, feeds, hidden, sessions, settings, subscriptions, users};
|
use crate::entity::{api_tokens, blocklists, catalogue, deadair_prefs, enclosures, entries, feeds, hidden, sessions, settings, subscriptions, users};
|
||||||
use sea_orm::sea_query::{Expr, Func};
|
use sea_orm::sea_query::{Expr, Func};
|
||||||
use sea_orm::{
|
use sea_orm::{
|
||||||
ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set,
|
ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set,
|
||||||
@@ -60,11 +60,18 @@ async fn create_missing(orm: &sea_orm::DatabaseConnection) -> Result<()> {
|
|||||||
schema.create_table_from_entity(blocklists::Entity),
|
schema.create_table_from_entity(blocklists::Entity),
|
||||||
schema.create_table_from_entity(hidden::Entity),
|
schema.create_table_from_entity(hidden::Entity),
|
||||||
schema.create_table_from_entity(api_tokens::Entity),
|
schema.create_table_from_entity(api_tokens::Entity),
|
||||||
|
schema.create_table_from_entity(deadair::Entity),
|
||||||
|
schema.create_table_from_entity(deadair_prefs::Entity),
|
||||||
|
schema.create_table_from_entity(deadair_saved::Entity),
|
||||||
] {
|
] {
|
||||||
orm.execute(table.if_not_exists()).await.context("creating the schema")?;
|
orm.execute(table.if_not_exists()).await.context("creating the schema")?;
|
||||||
}
|
}
|
||||||
for sql in [
|
for sql in [
|
||||||
"CREATE INDEX IF NOT EXISTS enclosures_entry ON enclosures (feed_id, guid)",
|
"CREATE INDEX IF NOT EXISTS enclosures_entry ON enclosures (feed_id, guid)",
|
||||||
|
// The few rows with a file on disk, 15 of the server's many thousand: counting a person's
|
||||||
|
// files through enclosures_entry read every file row of their feeds, 58 ms a feed list
|
||||||
|
// (#165). Also what the healthcheck's count and the Downloaded filter look for.
|
||||||
|
"CREATE INDEX IF NOT EXISTS enclosures_on_disk ON enclosures (feed_id, guid) WHERE path IS NOT NULL",
|
||||||
"CREATE UNIQUE INDEX IF NOT EXISTS users_name_lower ON users (lower(name))",
|
"CREATE UNIQUE INDEX IF NOT EXISTS users_name_lower ON users (lower(name))",
|
||||||
] {
|
] {
|
||||||
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
|
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
|
||||||
@@ -194,6 +201,7 @@ impl From<enclosures::Model> for EncRow {
|
|||||||
fn from(e: enclosures::Model) -> Self {
|
fn from(e: enclosures::Model) -> Self {
|
||||||
EncRow {
|
EncRow {
|
||||||
id: e.id,
|
id: e.id,
|
||||||
|
deadair: None,
|
||||||
feed_id: e.feed_id,
|
feed_id: e.feed_id,
|
||||||
guid: e.guid,
|
guid: e.guid,
|
||||||
url: e.url,
|
url: e.url,
|
||||||
@@ -295,6 +303,13 @@ impl Db {
|
|||||||
/// A SQLite file of its own (two connections to one ":memory:" would be two databases); or,
|
/// A SQLite file of its own (two connections to one ":memory:" would be two databases); or,
|
||||||
/// with IPX_TEST_DATABASE_URL set, a Postgres schema of its own on that database, so the
|
/// with IPX_TEST_DATABASE_URL set, a Postgres schema of its own on that database, so the
|
||||||
/// same tests prove the SQL on both.
|
/// same tests prove the SQL on both.
|
||||||
|
/// Runs a statement as it is, for a test outside this module setting up what it needs.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub async fn sql(&self, sql: &str) -> Result<()> {
|
||||||
|
self.orm.execute_unprepared(sql).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub async fn memory() -> Result<Self> {
|
pub async fn memory() -> Result<Self> {
|
||||||
static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||||
@@ -390,26 +405,35 @@ impl Db {
|
|||||||
) -> Result<std::collections::HashMap<String, FeedListing>> {
|
) -> Result<std::collections::HashMap<String, FeedListing>> {
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
let one = || sea_orm::Value::from(only.map(str::to_owned));
|
let one = || sea_orm::Value::from(only.map(str::to_owned));
|
||||||
let counts = |sql: &'static str, args: Vec<sea_orm::Value>| async move {
|
// A step a query: the feed list's trace was 290 ms with nothing under it.
|
||||||
self.rows(sql, args)
|
let counts = |of: &'static str, sql: &'static str, args: Vec<sea_orm::Value>| async move {
|
||||||
|
tracing::Instrument::instrument(self.rows(sql, args), step(of))
|
||||||
.await?
|
.await?
|
||||||
.iter()
|
.iter()
|
||||||
.map(|r| Ok((r.try_get::<String>("", "feed_id")?, r.try_get::<i64>("", "n")?)))
|
.map(|r| Ok((r.try_get::<String>("", "feed_id")?, r.try_get::<i64>("", "n")?)))
|
||||||
.collect::<Result<HashMap<_, _>>>()
|
.collect::<Result<HashMap<_, _>>>()
|
||||||
};
|
};
|
||||||
|
// Of this person's feeds only, as the unread count is: counting every item of every feed on
|
||||||
|
// the server, 1534 feeds for someone reading 146, was 184-316 ms, 70% of the feed list's
|
||||||
|
// time (#165). The Directory reads this for every feed's title and artwork, not a count.
|
||||||
let entries = counts(
|
let entries = counts(
|
||||||
"SELECT feed_id, count(*) AS n FROM entries
|
"entries",
|
||||||
WHERE (CAST($1 AS TEXT) IS NULL OR feed_id = $1) GROUP BY feed_id",
|
"SELECT e.feed_id, count(*) AS n FROM entries e
|
||||||
vec![one()],
|
JOIN subscriptions sub ON sub.user_id = $1 AND sub.feed_id = e.feed_id
|
||||||
|
WHERE (CAST($2 AS TEXT) IS NULL OR e.feed_id = $2) GROUP BY e.feed_id",
|
||||||
|
vec![user_id.into(), one()],
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let downloaded = counts(
|
let downloaded = counts(
|
||||||
"SELECT feed_id, count(*) AS n FROM enclosures
|
"downloaded",
|
||||||
WHERE path IS NOT NULL AND (CAST($1 AS TEXT) IS NULL OR feed_id = $1) GROUP BY feed_id",
|
"SELECT x.feed_id, count(*) AS n FROM enclosures x
|
||||||
vec![one()],
|
JOIN subscriptions sub ON sub.user_id = $1 AND sub.feed_id = x.feed_id
|
||||||
|
WHERE x.path IS NOT NULL AND (CAST($2 AS TEXT) IS NULL OR x.feed_id = $2) GROUP BY x.feed_id",
|
||||||
|
vec![user_id.into(), one()],
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let unread = counts(
|
let unread = counts(
|
||||||
|
"unread",
|
||||||
"SELECT e.feed_id, count(*) AS n FROM entries e
|
"SELECT e.feed_id, count(*) AS n FROM entries e
|
||||||
JOIN subscriptions sub ON sub.user_id = $1 AND sub.feed_id = e.feed_id
|
JOIN subscriptions sub ON sub.user_id = $1 AND sub.feed_id = e.feed_id
|
||||||
LEFT JOIN entry_state s
|
LEFT JOIN entry_state s
|
||||||
@@ -422,9 +446,10 @@ impl Db {
|
|||||||
vec![user_id.into(), one()],
|
vec![user_id.into(), one()],
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let mut blocked: HashMap<String, Vec<String>> = blocklists::Entity::find()
|
let mut blocked: HashMap<String, Vec<String>> = tracing::Instrument::instrument(
|
||||||
.filter(blocklists::Column::UserId.eq(user_id))
|
blocklists::Entity::find().filter(blocklists::Column::UserId.eq(user_id)).all(&self.orm),
|
||||||
.all(&self.orm)
|
step("blocked"),
|
||||||
|
)
|
||||||
.await?
|
.await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|b| (b.feed_id, keywords(Some(b.words)).unwrap_or_default()))
|
.map(|b| (b.feed_id, keywords(Some(b.words)).unwrap_or_default()))
|
||||||
@@ -433,8 +458,7 @@ impl Db {
|
|||||||
if let Some(id) = only {
|
if let Some(id) = only {
|
||||||
rows = rows.filter(feeds::Column::Id.eq(id));
|
rows = rows.filter(feeds::Column::Id.eq(id));
|
||||||
}
|
}
|
||||||
Ok(rows
|
Ok(tracing::Instrument::instrument(rows.all(&self.orm), step("feeds"))
|
||||||
.all(&self.orm)
|
|
||||||
.await?
|
.await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|f| {
|
.map(|f| {
|
||||||
@@ -782,7 +806,9 @@ impl Db {
|
|||||||
/// One file serves every subscriber, so both tests are about all of them: **anyone**
|
/// One file serves every subscriber, so both tests are about all of them: **anyone**
|
||||||
/// starring it keeps it, and it only counts as read when **everyone** subscribed has
|
/// starring it keeps it, and it only counts as read when **everyone** subscribed has
|
||||||
/// read it. A file whose feed nobody subscribes to has no one left to keep it, so it
|
/// read it. A file whose feed nobody subscribes to has no one left to keep it, so it
|
||||||
/// sorts with the read ones.
|
/// sorts with the read ones. Only the marks of people who subscribe count: unsubscribing
|
||||||
|
/// leaves yours behind, and counted, a reader who had left made an episode someone had not
|
||||||
|
/// played look finished, and a pin of theirs kept a file for good (#156).
|
||||||
///
|
///
|
||||||
/// (The Python intended `read = 1 AND flagged = 0` but never achieved it -- a missing
|
/// (The Python intended `read = 1 AND flagged = 0` but never achieved it -- a missing
|
||||||
/// plistlib import and an `EntreiesData` typo meant the filter always threw.)
|
/// plistlib import and an `EntreiesData` typo meant the filter always threw.)
|
||||||
@@ -796,13 +822,15 @@ impl Db {
|
|||||||
LEFT JOIN (SELECT feed_id, count(*) AS n FROM subscriptions GROUP BY feed_id) subs
|
LEFT JOIN (SELECT feed_id, count(*) AS n FROM subscriptions GROUP BY feed_id) subs
|
||||||
ON subs.feed_id = e.feed_id
|
ON subs.feed_id = e.feed_id
|
||||||
-- Someone an item is hidden from is as done with it as someone who read it.
|
-- Someone an item is hidden from is as done with it as someone who read it.
|
||||||
LEFT JOIN (SELECT feed_id, guid, count(*) AS n
|
LEFT JOIN (SELECT done.feed_id, done.guid, count(*) AS n
|
||||||
FROM (SELECT user_id, feed_id, guid FROM entry_state WHERE read
|
FROM (SELECT user_id, feed_id, guid FROM entry_state WHERE read
|
||||||
UNION SELECT user_id, feed_id, guid FROM hidden) done
|
UNION SELECT user_id, feed_id, guid FROM hidden) done
|
||||||
GROUP BY feed_id, guid) readers
|
JOIN subscriptions u ON u.user_id = done.user_id AND u.feed_id = done.feed_id
|
||||||
|
GROUP BY done.feed_id, done.guid) readers
|
||||||
ON readers.feed_id = e.feed_id AND readers.guid = e.guid
|
ON readers.feed_id = e.feed_id AND readers.guid = e.guid
|
||||||
WHERE e.path IS NOT NULL
|
WHERE e.path IS NOT NULL
|
||||||
AND NOT EXISTS (SELECT 1 FROM entry_state s
|
AND NOT EXISTS (SELECT 1 FROM entry_state s
|
||||||
|
JOIN subscriptions u ON u.user_id = s.user_id AND u.feed_id = s.feed_id
|
||||||
WHERE s.feed_id = e.feed_id AND s.guid = e.guid AND s.flagged)
|
WHERE s.feed_id = e.feed_id AND s.guid = e.guid AND s.flagged)
|
||||||
ORDER BY 6 DESC, coalesce(e.downloaded_at, 0) ASC, e.id ASC",
|
ORDER BY 6 DESC, coalesce(e.downloaded_at, 0) ASC, e.id ASC",
|
||||||
vec![],
|
vec![],
|
||||||
@@ -941,9 +969,10 @@ impl Db {
|
|||||||
SELECT 1 FROM enclosures e
|
SELECT 1 FROM enclosures e
|
||||||
WHERE e.feed_id = entries.feed_id AND e.guid = entries.guid
|
WHERE e.feed_id = entries.feed_id AND e.guid = entries.guid
|
||||||
AND e.path IS NOT NULL)
|
AND e.path IS NOT NULL)
|
||||||
-- Starred by anyone keeps it, the same rule the reaper follows.
|
-- Starred by anyone subscribed keeps it, the same rule the reaper follows.
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM entry_state s
|
SELECT 1 FROM entry_state s
|
||||||
|
JOIN subscriptions u ON u.user_id = s.user_id AND u.feed_id = s.feed_id
|
||||||
WHERE s.feed_id = entries.feed_id AND s.guid = entries.guid
|
WHERE s.feed_id = entries.feed_id AND s.guid = entries.guid
|
||||||
AND s.flagged)",
|
AND s.flagged)",
|
||||||
vec![older_than.into()],
|
vec![older_than.into()],
|
||||||
@@ -1032,12 +1061,22 @@ impl Args {
|
|||||||
/// The FROM and WHERE that `entries_in` and `count_in` share: whose read state, which feeds
|
/// The FROM and WHERE that `entries_in` and `count_in` share: whose read state, which feeds
|
||||||
/// (one, or every feed the person subscribes to), the filter, and a case-insensitive search of
|
/// (one, or every feed the person subscribes to), the filter, and a case-insensitive search of
|
||||||
/// title and description.
|
/// title and description.
|
||||||
fn entries_from(a: &mut Args, user_id: i64, feed_id: Option<&str>, filter: Filter, search: Option<&str>) -> String {
|
fn entries_from(
|
||||||
|
a: &mut Args,
|
||||||
|
user_id: i64,
|
||||||
|
feed_id: Option<&str>,
|
||||||
|
filter: Filter,
|
||||||
|
search: Option<&str>,
|
||||||
|
guid: Option<&str>,
|
||||||
|
) -> String {
|
||||||
let user = a.p(user_id);
|
let user = a.p(user_id);
|
||||||
let scope = match feed_id {
|
let mut scope = match feed_id {
|
||||||
Some(f) => format!("e.feed_id = {}", a.p(f)),
|
Some(f) => format!("e.feed_id = {}", a.p(f)),
|
||||||
None => format!("e.feed_id IN (SELECT feed_id FROM subscriptions WHERE user_id = {user})"),
|
None => format!("e.feed_id IN (SELECT feed_id FROM subscriptions WHERE user_id = {user})"),
|
||||||
};
|
};
|
||||||
|
if let Some(g) = guid {
|
||||||
|
scope = format!("{scope} AND e.guid = {}", a.p(g));
|
||||||
|
}
|
||||||
let search = match search.map(|q| q.trim().to_lowercase()).filter(|q| !q.is_empty()) {
|
let search = match search.map(|q| q.trim().to_lowercase()).filter(|q| !q.is_empty()) {
|
||||||
Some(q) => {
|
Some(q) => {
|
||||||
let like = a.p(format!("%{q}%"));
|
let like = a.p(format!("%{q}%"));
|
||||||
@@ -1141,6 +1180,10 @@ impl Filter {
|
|||||||
#[derive(Debug, Clone, serde::Serialize)]
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
pub struct EncRow {
|
pub struct EncRow {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
|
/// Seconds DeadAir's copy of this file saves, when the person asking plays it from one
|
||||||
|
/// (`deadair::for_reader`). None from the database.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub deadair: Option<i64>,
|
||||||
pub feed_id: String,
|
pub feed_id: String,
|
||||||
pub guid: String,
|
pub guid: String,
|
||||||
pub url: String,
|
pub url: String,
|
||||||
@@ -1165,9 +1208,31 @@ impl Db {
|
|||||||
offset: i64,
|
offset: i64,
|
||||||
limit: i64,
|
limit: i64,
|
||||||
order: &str,
|
order: &str,
|
||||||
|
) -> Result<Vec<EntryRow>> {
|
||||||
|
self.entries_where(user_id, feed_id, filter, search, None, offset, limit, order).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One entry as `entries_in` gives it: what the player asks for again when the clock it
|
||||||
|
/// plays in changes (DeadAir, #171).
|
||||||
|
pub async fn entry(&self, user_id: i64, feed_id: &str, guid: &str) -> Result<Option<EntryRow>> {
|
||||||
|
let order = order_sql("published", "desc");
|
||||||
|
Ok(self.entries_where(user_id, Some(feed_id), Filter::All, None, Some(guid), 0, 1, &order).await?.pop())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
async fn entries_where(
|
||||||
|
&self,
|
||||||
|
user_id: i64,
|
||||||
|
feed_id: Option<&str>,
|
||||||
|
filter: Filter,
|
||||||
|
search: Option<&str>,
|
||||||
|
guid: Option<&str>,
|
||||||
|
offset: i64,
|
||||||
|
limit: i64,
|
||||||
|
order: &str,
|
||||||
) -> Result<Vec<EntryRow>> {
|
) -> Result<Vec<EntryRow>> {
|
||||||
let mut a = Args::default();
|
let mut a = Args::default();
|
||||||
let from = entries_from(&mut a, user_id, feed_id, filter, search);
|
let from = entries_from(&mut a, user_id, feed_id, filter, search, guid);
|
||||||
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, false) AS read, coalesce(s.flagged, false) AS flagged, e.image,
|
coalesce(s.read, false) AS read, coalesce(s.flagged, false) AS flagged, e.image,
|
||||||
@@ -1230,7 +1295,7 @@ impl Db {
|
|||||||
search: Option<&str>,
|
search: Option<&str>,
|
||||||
) -> Result<i64> {
|
) -> Result<i64> {
|
||||||
let mut a = Args::default();
|
let mut a = Args::default();
|
||||||
let sql = format!("SELECT count(*) AS n {}", entries_from(&mut a, user_id, feed_id, filter, search));
|
let sql = format!("SELECT count(*) AS n {}", entries_from(&mut a, user_id, feed_id, filter, search, None));
|
||||||
let r = self.rows(&sql, a.0).await?;
|
let r = self.rows(&sql, a.0).await?;
|
||||||
Ok(r.first().context("count(*) always returns a row")?.try_get("", "n")?)
|
Ok(r.first().context("count(*) always returns a row")?.try_get("", "n")?)
|
||||||
}
|
}
|
||||||
@@ -2059,6 +2124,64 @@ impl Db {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Of these items, the stored ones the feed now tells differently: a title, text, artwork,
|
||||||
|
/// length or number that is not what is stored, a field the feed leaves out counting as
|
||||||
|
/// unchanged, as record_entry writes them. A scan inserted only items it had not stored
|
||||||
|
/// (#96), and with that alone a publisher's correction never arrived: Ain't It Cool News's
|
||||||
|
/// items kept a broken copy, each with the text of the one before it, after the feed was
|
||||||
|
/// fixed (#141). Only what the feed lists is read back, so a scan reads as much as it was
|
||||||
|
/// sent, not the archive.
|
||||||
|
#[tracing::instrument(skip_all)]
|
||||||
|
pub async fn changed_items(
|
||||||
|
&self,
|
||||||
|
feed_id: &str,
|
||||||
|
entries: &[crate::feed::Entry],
|
||||||
|
) -> Result<std::collections::HashSet<String>> {
|
||||||
|
if entries.is_empty() {
|
||||||
|
return Ok(Default::default());
|
||||||
|
}
|
||||||
|
let mut a = Args::default();
|
||||||
|
let feed = a.p(feed_id);
|
||||||
|
let guids: Vec<String> = entries.iter().map(|e| a.p(e.guid.clone())).collect();
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT guid, title, description, image, duration, episode, season FROM entries
|
||||||
|
WHERE feed_id = {feed} AND guid IN ({})",
|
||||||
|
guids.join(", ")
|
||||||
|
);
|
||||||
|
type Stored = (Option<String>, Option<String>, Option<String>, Option<i64>, Option<i64>, Option<i64>);
|
||||||
|
let mut stored = std::collections::HashMap::<String, Stored>::new();
|
||||||
|
for r in self.rows(&sql, a.0).await? {
|
||||||
|
stored.insert(
|
||||||
|
r.try_get("", "guid")?,
|
||||||
|
(
|
||||||
|
r.try_get("", "title")?,
|
||||||
|
r.try_get("", "description")?,
|
||||||
|
r.try_get("", "image")?,
|
||||||
|
r.try_get("", "duration")?,
|
||||||
|
r.try_get("", "episode")?,
|
||||||
|
r.try_get("", "season")?,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
fn differs<T: PartialEq>(new: &Option<T>, old: &Option<T>) -> bool {
|
||||||
|
new.is_some() && new != old
|
||||||
|
}
|
||||||
|
Ok(entries
|
||||||
|
.iter()
|
||||||
|
.filter(|e| {
|
||||||
|
stored.get(&e.guid).is_some_and(|s| {
|
||||||
|
differs(&e.title, &s.0)
|
||||||
|
|| differs(&e.description, &s.1)
|
||||||
|
|| differs(&e.image, &s.2)
|
||||||
|
|| differs(&e.duration, &s.3)
|
||||||
|
|| differs(&e.episode, &s.4)
|
||||||
|
|| differs(&e.season, &s.5)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map(|e| e.guid.clone())
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
#[tracing::instrument(skip_all)]
|
#[tracing::instrument(skip_all)]
|
||||||
pub async fn skipped_by_filter(&self, feed_id: &str) -> Result<std::collections::HashMap<String, String>> {
|
pub async fn skipped_by_filter(&self, feed_id: &str) -> Result<std::collections::HashMap<String, String>> {
|
||||||
self.rows(
|
self.rows(
|
||||||
@@ -2110,6 +2233,194 @@ pub enum EntryFlag {
|
|||||||
Flagged,
|
Flagged,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A span for one step of a request or a scan, and none outside one: the healthcheck and the page's
|
||||||
|
/// live updates run the same code, and every call there would be a trace of its own.
|
||||||
|
pub fn step(of: &'static str) -> tracing::Span {
|
||||||
|
if tracing::Span::current().is_none() { tracing::Span::none() } else { tracing::info_span!("step", of) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A file on disk with no DeadAir copy made since it was downloaded (issue #171).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DeadAirTodo {
|
||||||
|
pub id: i64,
|
||||||
|
pub feed_id: String,
|
||||||
|
pub guid: String,
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An enclosure's DeadAir copy: where it is, what it left out, and how much that came to.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Copy {
|
||||||
|
pub enclosure_id: i64,
|
||||||
|
pub path: String,
|
||||||
|
pub cuts: Vec<crate::deadair::Cut>,
|
||||||
|
pub saved_ms: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Db {
|
||||||
|
/// DeadAir settings as (person, feed, wanted), everyone's or one person's. An empty feed is
|
||||||
|
/// the switch beside the speed.
|
||||||
|
pub async fn deadair_prefs(&self, user_id: Option<i64>) -> Result<Vec<(i64, String, bool)>> {
|
||||||
|
let mut a = Args::default();
|
||||||
|
let only = match user_id {
|
||||||
|
Some(u) => format!("WHERE user_id = {}", a.p(u)),
|
||||||
|
None => String::new(),
|
||||||
|
};
|
||||||
|
self.rows(&format!("SELECT user_id, feed_id, wanted FROM deadair_prefs {only}"), a.0)
|
||||||
|
.await?
|
||||||
|
.iter()
|
||||||
|
.map(|r| Ok((r.try_get("", "user_id")?, r.try_get("", "feed_id")?, r.try_get("", "wanted")?)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets one DeadAir setting, or with None takes it away: for a feed, that is following the
|
||||||
|
/// switch (or the feed's group) again.
|
||||||
|
pub async fn set_deadair(&self, user_id: i64, feed_id: &str, wanted: Option<bool>) -> Result<()> {
|
||||||
|
match wanted {
|
||||||
|
None => {
|
||||||
|
deadair_prefs::Entity::delete_by_id((user_id, feed_id.to_owned())).exec(&self.orm).await?;
|
||||||
|
}
|
||||||
|
Some(w) => {
|
||||||
|
self.exec(
|
||||||
|
"INSERT INTO deadair_prefs (user_id, feed_id, wanted) VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (user_id, feed_id) DO UPDATE SET wanted = excluded.wanted",
|
||||||
|
vec![user_id.into(), feed_id.into(), w.into()],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Files on disk with no copy made since they were downloaded, oldest first.
|
||||||
|
pub async fn deadair_todo(&self) -> Result<Vec<DeadAirTodo>> {
|
||||||
|
self.rows(
|
||||||
|
"SELECT e.id, e.feed_id, e.guid, e.path FROM enclosures e
|
||||||
|
LEFT JOIN deadair d ON d.enclosure_id = e.id
|
||||||
|
WHERE e.path IS NOT NULL AND e.state = 'done'
|
||||||
|
AND (d.enclosure_id IS NULL OR d.made_at < coalesce(e.downloaded_at, 0))
|
||||||
|
ORDER BY e.id",
|
||||||
|
vec![],
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.iter()
|
||||||
|
.map(|r| {
|
||||||
|
Ok(DeadAirTodo {
|
||||||
|
id: r.try_get("", "id")?,
|
||||||
|
feed_id: r.try_get("", "feed_id")?,
|
||||||
|
guid: r.try_get("", "guid")?,
|
||||||
|
path: r.try_get("", "path")?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether any of these people subscribes to the feed.
|
||||||
|
pub async fn subscribed_any(&self, users: &[i64], feed_id: &str) -> Result<bool> {
|
||||||
|
if users.is_empty() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let mut a = Args::default();
|
||||||
|
let feed = a.p(feed_id);
|
||||||
|
let ids: Vec<String> = users.iter().map(|&u| a.p(u)).collect();
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT 1 AS one FROM subscriptions WHERE feed_id = {feed} AND user_id IN ({}) LIMIT 1",
|
||||||
|
ids.join(", ")
|
||||||
|
);
|
||||||
|
Ok(!self.rows(&sql, a.0).await?.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Who has an item marked read, which playing it to the end or past 90% does.
|
||||||
|
pub async fn readers(&self, feed_id: &str, guid: &str) -> Result<Vec<i64>> {
|
||||||
|
self.rows(
|
||||||
|
"SELECT user_id FROM entry_state WHERE feed_id = $1 AND guid = $2 AND read",
|
||||||
|
vec![feed_id.into(), guid.into()],
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.iter()
|
||||||
|
.map(|r| Ok(r.try_get("", "user_id")?))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records the copy made of an enclosure, or with no path that it has none.
|
||||||
|
pub async fn store_deadair(&self, enclosure_id: i64, path: Option<&str>, cuts: &str, saved_ms: i64) -> Result<()> {
|
||||||
|
self.exec(
|
||||||
|
"INSERT INTO deadair (enclosure_id, path, cuts, saved_ms, made_at) VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (enclosure_id) DO UPDATE SET path = excluded.path, cuts = excluded.cuts,
|
||||||
|
saved_ms = excluded.saved_ms, made_at = excluded.made_at",
|
||||||
|
vec![enclosure_id.into(), path.map(str::to_owned).into(), cuts.into(), saved_ms.into(), now().into()],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The copies there are of these enclosures, made since each was last downloaded.
|
||||||
|
pub async fn copies(&self, enclosure_ids: &[i64]) -> Result<Vec<Copy>> {
|
||||||
|
if enclosure_ids.is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
let mut a = Args::default();
|
||||||
|
let ids: Vec<String> = enclosure_ids.iter().map(|&i| a.p(i)).collect();
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT d.enclosure_id, d.path, d.cuts, d.saved_ms FROM deadair d
|
||||||
|
JOIN enclosures e ON e.id = d.enclosure_id
|
||||||
|
WHERE d.enclosure_id IN ({}) AND d.path IS NOT NULL AND e.path IS NOT NULL
|
||||||
|
AND d.made_at >= coalesce(e.downloaded_at, 0)",
|
||||||
|
ids.join(", ")
|
||||||
|
);
|
||||||
|
self.rows(&sql, a.0)
|
||||||
|
.await?
|
||||||
|
.iter()
|
||||||
|
.map(|r| {
|
||||||
|
let cuts: String = r.try_get("", "cuts")?;
|
||||||
|
Ok(Copy {
|
||||||
|
enclosure_id: r.try_get("", "enclosure_id")?,
|
||||||
|
path: r.try_get("", "path")?,
|
||||||
|
cuts: serde_json::from_str(&cuts).context("reading DeadAir's cuts")?,
|
||||||
|
saved_ms: r.try_get("", "saved_ms")?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Counts an episode towards the time DeadAir has saved someone, once however often it is
|
||||||
|
/// played.
|
||||||
|
pub async fn count_deadair(&self, user_id: i64, feed_id: &str, guid: &str, saved_ms: i64) -> Result<()> {
|
||||||
|
self.exec(
|
||||||
|
"INSERT INTO deadair_saved (user_id, feed_id, guid, saved_ms, at) VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (user_id, feed_id, guid) DO NOTHING",
|
||||||
|
vec![user_id.into(), feed_id.into(), guid.into(), saved_ms.into(), now().into()],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An episode's length as someone's player measured it, else as its feed gives it.
|
||||||
|
pub async fn entry_duration(&self, user_id: i64, feed_id: &str, guid: &str) -> Result<Option<i64>> {
|
||||||
|
let rows = self
|
||||||
|
.rows(
|
||||||
|
"SELECT coalesce(s.duration, e.duration) AS duration FROM entries e
|
||||||
|
LEFT JOIN entry_state s ON s.user_id = $1 AND s.feed_id = e.feed_id AND s.guid = e.guid
|
||||||
|
WHERE e.feed_id = $2 AND e.guid = $3",
|
||||||
|
vec![user_id.into(), feed_id.into(), guid.into()],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.first().map(|r| r.try_get::<Option<i64>>("", "duration")).transpose()?.flatten())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The time DeadAir has saved someone, in ms.
|
||||||
|
pub async fn deadair_total(&self, user_id: i64) -> Result<i64> {
|
||||||
|
let rows = self
|
||||||
|
.rows(
|
||||||
|
"SELECT CAST(coalesce(sum(saved_ms), 0) AS BIGINT) AS total FROM deadair_saved WHERE user_id = $1",
|
||||||
|
vec![user_id.into()],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
// Cast: Postgres sums bigints as NUMERIC, which does not read as an i64.
|
||||||
|
Ok(rows.first().map(|r| r.try_get::<i64>("", "total")).transpose()?.unwrap_or(0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Unix seconds. Everything time-shaped in the DB is stored this way.
|
/// Unix seconds. Everything time-shaped in the DB is stored this way.
|
||||||
pub fn now() -> i64 {
|
pub fn now() -> i64 {
|
||||||
std::time::SystemTime::now()
|
std::time::SystemTime::now()
|
||||||
@@ -2122,6 +2433,51 @@ pub fn now() -> i64 {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn deadair_copies_are_made_once_per_download_and_counted_once_per_episode() {
|
||||||
|
let db = Db::memory().await.unwrap();
|
||||||
|
let u = db.create_user("dee", None, false).await.unwrap();
|
||||||
|
db.set_deadair(u, "f", Some(true)).await.unwrap();
|
||||||
|
db.set_deadair(u, "g", Some(false)).await.unwrap();
|
||||||
|
db.set_deadair(u, "g", None).await.unwrap();
|
||||||
|
assert_eq!(db.deadair_prefs(Some(u)).await.unwrap(), vec![(u, "f".to_string(), true)]);
|
||||||
|
for sql in [
|
||||||
|
"INSERT INTO enclosures (id, feed_id, guid, url, path, state, downloaded_at)
|
||||||
|
VALUES (1, 'f', 'g', 'http://x/a.mp3', '/x/a.mp3', 'done', 100)",
|
||||||
|
"INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES (2, 'f', 'h', 'http://x/b.mp3', NULL, 'reaped')",
|
||||||
|
"INSERT INTO entries (feed_id, guid, first_seen, duration) VALUES ('f', 'g', 1, 600)",
|
||||||
|
] {
|
||||||
|
db.orm.execute_unprepared(sql).await.unwrap();
|
||||||
|
}
|
||||||
|
let todo = db.deadair_todo().await.unwrap();
|
||||||
|
assert_eq!(todo.iter().map(|t| t.id).collect::<Vec<_>>(), [1], "only a file on disk");
|
||||||
|
|
||||||
|
db.store_deadair(1, Some("/x/a.deadair.mp3"), "[[1000,3000]]", 2000).await.unwrap();
|
||||||
|
assert!(db.deadair_todo().await.unwrap().is_empty());
|
||||||
|
let c = db.copies(&[1, 2]).await.unwrap();
|
||||||
|
assert_eq!((c.len(), c[0].cuts.as_slice(), c[0].saved_ms), (1, &[crate::deadair::Cut(1000, 3000)][..], 2000));
|
||||||
|
// Downloaded again since: the copy is stale until made again.
|
||||||
|
db.orm.execute_unprepared(&format!("UPDATE enclosures SET downloaded_at = {} WHERE id = 1", now() + 60)).await.unwrap();
|
||||||
|
assert_eq!(db.deadair_todo().await.unwrap().len(), 1);
|
||||||
|
assert!(db.copies(&[1]).await.unwrap().is_empty());
|
||||||
|
|
||||||
|
assert!(!db.subscribed_any(&[u], "f").await.unwrap());
|
||||||
|
db.subscribe(u, "f").await.unwrap();
|
||||||
|
assert!(db.subscribed_any(&[u], "f").await.unwrap());
|
||||||
|
assert!(db.readers("f", "g").await.unwrap().is_empty());
|
||||||
|
db.set_entry_flag(u, "f", "g", EntryFlag::Read, true).await.unwrap();
|
||||||
|
assert_eq!(db.readers("f", "g").await.unwrap(), [u]);
|
||||||
|
|
||||||
|
assert_eq!(db.entry_duration(u, "f", "g").await.unwrap(), Some(600));
|
||||||
|
db.set_position(u, "f", "g", 10, Some(590)).await.unwrap();
|
||||||
|
assert_eq!(db.entry_duration(u, "f", "g").await.unwrap(), Some(590), "what the player measured");
|
||||||
|
|
||||||
|
assert_eq!(db.deadair_total(u).await.unwrap(), 0);
|
||||||
|
db.count_deadair(u, "f", "g", 2000).await.unwrap();
|
||||||
|
db.count_deadair(u, "f", "g", 2000).await.unwrap();
|
||||||
|
assert_eq!(db.deadair_total(u).await.unwrap(), 2000, "a replay counts once");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a_read_does_not_wait_for_a_write_in_progress() {
|
async fn a_read_does_not_wait_for_a_write_in_progress() {
|
||||||
use sea_orm::TransactionTrait;
|
use sea_orm::TransactionTrait;
|
||||||
@@ -2228,9 +2584,9 @@ mod tests {
|
|||||||
let db = Db::memory().await.unwrap();
|
let db = Db::memory().await.unwrap();
|
||||||
db.exec_for_test(
|
db.exec_for_test(
|
||||||
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',true),(2,'sam',false);
|
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',true),(2,'sam',false);
|
||||||
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'),(1,'g'),(2,'f');
|
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'),(1,'g'),(2,'f'),(2,'h');
|
||||||
INSERT INTO feeds (id, url, title, ttl_mins) VALUES ('f','u','F',30),('g','v','G',null);
|
INSERT INTO feeds (id, url, title, ttl_mins) VALUES ('f','u','F',30),('g','v','G',null),('h','w','H',null);
|
||||||
INSERT INTO entries (feed_id, guid, first_seen) VALUES ('f','a',0),('f','b',0),('f','c',0),('g','d',0);
|
INSERT INTO entries (feed_id, guid, first_seen) VALUES ('f','a',0),('f','b',0),('f','c',0),('g','d',0),('h','e',0);
|
||||||
INSERT INTO entry_state (user_id, feed_id, guid, read) VALUES (1,'f','a',true),(2,'f','b',true);
|
INSERT INTO entry_state (user_id, feed_id, guid, read) VALUES (1,'f','a',true),(2,'f','b',true);
|
||||||
INSERT INTO hidden (user_id, feed_id, guid) VALUES (1,'f','c');
|
INSERT INTO hidden (user_id, feed_id, guid) VALUES (1,'f','c');
|
||||||
INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
|
INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
|
||||||
@@ -2251,6 +2607,8 @@ mod tests {
|
|||||||
assert_eq!(one.ttl_mins, db.http_state(id).await.unwrap().ttl_mins, "{id}");
|
assert_eq!(one.ttl_mins, db.http_state(id).await.unwrap().ttl_mins, "{id}");
|
||||||
}
|
}
|
||||||
assert_eq!((list["f"].unread, list["g"].unread), (1, 1)); // a read, c hidden; sam's read is sam's
|
assert_eq!((list["f"].unread, list["g"].unread), (1, 1)); // a read, c hidden; sam's read is sam's
|
||||||
|
// Only sam reads h: still listed, for its title, but its items are not counted for ray (#165).
|
||||||
|
assert_eq!((list["h"].summary.title.as_deref(), list["h"].summary.entries), (Some("H"), 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -2396,6 +2754,31 @@ mod tests {
|
|||||||
assert_eq!(files, ["u1"].map(String::from).into()); // u2 is g's: left to the insert to find
|
assert_eq!(files, ["u1"].map(String::from).into()); // u2 is g's: left to the insert to find
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_scan_finds_the_stored_items_the_feed_now_tells_differently() {
|
||||||
|
let db = Db::memory().await.unwrap();
|
||||||
|
let item = |guid: &str, title: &str, text: Option<&str>| crate::feed::Entry {
|
||||||
|
guid: guid.into(),
|
||||||
|
title: Some(title.into()),
|
||||||
|
description: text.map(str::to_owned),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
db.record_entry("f", &item("a", "A", Some("the review"))).await.unwrap();
|
||||||
|
db.record_entry("f", &item("b", "B", Some("the next review"))).await.unwrap();
|
||||||
|
db.record_entry("f", &item("c", "C", Some("as it was"))).await.unwrap();
|
||||||
|
let changed = db
|
||||||
|
.changed_items("f", &[
|
||||||
|
item("a", "A", Some("the review, fixed")), // the text corrected
|
||||||
|
item("b", "B renamed", Some("the next review")), // retitled
|
||||||
|
item("c", "C", None), // says nothing of its text: what is stored stands
|
||||||
|
item("d", "D", Some("new")), // not stored: the insert's, not this
|
||||||
|
])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(changed, ["a", "b"].map(String::from).into());
|
||||||
|
assert!(db.changed_items("f", &[]).await.unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn only_artwork_a_feed_names_is_fetched_for_the_page() {
|
async fn only_artwork_a_feed_names_is_fetched_for_the_page() {
|
||||||
let db = Db::memory().await.unwrap();
|
let db = Db::memory().await.unwrap();
|
||||||
|
|||||||
520
src/deadair.rs
Normal file
520
src/deadair.rs
Normal file
@@ -0,0 +1,520 @@
|
|||||||
|
//! DeadAir (issue #171): podcasts play from a copy with their long silences shortened, so they
|
||||||
|
//! finish sooner without sounding sped up.
|
||||||
|
//!
|
||||||
|
//! The copy is the original's own frames with the silent ones left out, copied by ffmpeg with
|
||||||
|
//! nothing re-encoded: about 10 s an hour of audio on one core, finding the silences included.
|
||||||
|
//! It is made in the background, one file at a time, for files in feeds someone has DeadAir on
|
||||||
|
//! for, and kept beside the original (`copy_path`) until `download::remove` takes both.
|
||||||
|
//!
|
||||||
|
//! Positions stay in the original's time. What the copy left out is stored as `Cut`s of the
|
||||||
|
//! original's time, and the server converts both ways (`to_copy`, `to_original`): the iOS and
|
||||||
|
//! Android apps save positions themselves, from their own player, so converting in the page
|
||||||
|
//! would miss them.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::Ctx;
|
||||||
|
|
||||||
|
/// Quieter than this for longer than `MIN_SILENCE` is a silence: what the measurements on the
|
||||||
|
/// issue used. A podcast's room tone sits well below it and speech well above.
|
||||||
|
const THRESHOLD: &str = "-38dB";
|
||||||
|
const MIN_SILENCE: f64 = 0.35;
|
||||||
|
/// Kept at each end of a silence, so the frames on both sides of a cut are silent. Decoding
|
||||||
|
/// blends a frame with the one before it, and an MP3 frame can begin in the bytes of the frame
|
||||||
|
/// before (the bit reservoir): the first frame after a cut decodes against the wrong neighbour,
|
||||||
|
/// which nobody hears between two silent frames and everyone hears at the start of a word.
|
||||||
|
const KEEP: f64 = 0.1;
|
||||||
|
/// ffmpeg takes the frames to drop as one argument, and Linux refuses an argument over 128 KiB.
|
||||||
|
// shortcut: a file with more silences than this keeps only its longest; about 9 hours of
|
||||||
|
// conversation at the rate measured. Pass the list some other way if a feed ever gets there.
|
||||||
|
const MAX_CUTS: usize = 4000;
|
||||||
|
/// How long the maker waits when there is nothing to do: a download or a feed turned on is
|
||||||
|
/// picked up this long after, at most.
|
||||||
|
const IDLE: std::time::Duration = std::time::Duration::from_secs(120);
|
||||||
|
|
||||||
|
/// A span of the original, in milliseconds, that the copy leaves out.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Cut(pub i64, pub i64);
|
||||||
|
|
||||||
|
/// Where the copy of `original` goes: beside it, `name.deadair.mp3` for `name.mp3`.
|
||||||
|
pub fn copy_path(original: &Path) -> PathBuf {
|
||||||
|
let stem = original.file_stem().map(|s| s.to_string_lossy()).unwrap_or_default();
|
||||||
|
let name = match original.extension() {
|
||||||
|
Some(ext) => format!("{stem}.deadair.{}", ext.to_string_lossy()),
|
||||||
|
None => format!("{stem}.deadair"),
|
||||||
|
};
|
||||||
|
original.with_file_name(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The silences silencedetect reported, as (start, end) seconds. One still open at the end of
|
||||||
|
/// the file has no end and is left alone.
|
||||||
|
pub fn parse_silences(log: &str) -> Vec<(f64, f64)> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut start = None;
|
||||||
|
for line in log.lines() {
|
||||||
|
if let Some(v) = after(line, "silence_start: ") {
|
||||||
|
start = Some(v);
|
||||||
|
} else if let Some(v) = after(line, "silence_end: ")
|
||||||
|
&& let Some(s) = start.take()
|
||||||
|
{
|
||||||
|
out.push((s, v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn after(line: &str, key: &str) -> Option<f64> {
|
||||||
|
let rest = &line[line.find(key)? + key.len()..];
|
||||||
|
rest.split_whitespace().next()?.parse().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which frames to drop for these silences, as inclusive ranges of frame numbers, and the same
|
||||||
|
/// as `Cut`s. Frame `n` plays from `first + n * frame` seconds. Only frames wholly inside a
|
||||||
|
/// silence, `KEEP` in from each end, go.
|
||||||
|
pub fn plan(silences: &[(f64, f64)], first: f64, frame: f64) -> (Vec<(u64, u64)>, Vec<Cut>) {
|
||||||
|
let mut spans: Vec<(u64, u64)> = silences
|
||||||
|
.iter()
|
||||||
|
.filter(|(s, e)| e - s >= MIN_SILENCE)
|
||||||
|
.filter_map(|&(s, e)| {
|
||||||
|
let a = ((s + KEEP - first) / frame).ceil().max(0.0) as u64;
|
||||||
|
let b = ((e - KEEP - first) / frame).floor() as i64 - 1;
|
||||||
|
(b >= a as i64).then_some((a, b as u64))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if spans.len() > MAX_CUTS {
|
||||||
|
spans.sort_by_key(|&(a, b)| std::cmp::Reverse(b - a));
|
||||||
|
spans.truncate(MAX_CUTS);
|
||||||
|
spans.sort();
|
||||||
|
}
|
||||||
|
let ms = |n: u64| ((first + n as f64 * frame) * 1000.0).round() as i64;
|
||||||
|
let cuts = spans.iter().map(|&(a, b)| Cut(ms(a), ms(b + 1))).collect();
|
||||||
|
(spans, cuts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A time in the original, in ms, as the same moment in the copy. A time inside a cut is where
|
||||||
|
/// the cut was made.
|
||||||
|
pub fn to_copy(cuts: &[Cut], ms: i64) -> i64 {
|
||||||
|
let mut out = ms;
|
||||||
|
for &Cut(s, e) in cuts {
|
||||||
|
if ms >= e {
|
||||||
|
out -= e - s;
|
||||||
|
} else {
|
||||||
|
if ms > s {
|
||||||
|
out -= ms - s;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A time in the copy, in ms, as the same moment in the original.
|
||||||
|
pub fn to_original(cuts: &[Cut], ms: i64) -> i64 {
|
||||||
|
let mut out = ms;
|
||||||
|
for &Cut(s, e) in cuts {
|
||||||
|
if out >= s {
|
||||||
|
out += e - s;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn saved(cuts: &[Cut]) -> i64 {
|
||||||
|
cuts.iter().map(|Cut(s, e)| e - s).sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether someone with these settings plays a feed from its copies. Off for every feed until
|
||||||
|
/// they turn it on for that one, or for the group it came in (an OPML or a Patreon creator),
|
||||||
|
/// which a feed of the group can still say otherwise to.
|
||||||
|
pub fn wants(prefs: &HashMap<String, bool>, feed: &str, group: Option<&str>) -> bool {
|
||||||
|
prefs.get(feed).or_else(|| group.and_then(|g| prefs.get(g))).copied().unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The settings of each person with DeadAir on for something, and each managed feed's group,
|
||||||
|
/// which is what `wants` needs.
|
||||||
|
pub struct Wanting {
|
||||||
|
pub people: HashMap<i64, HashMap<String, bool>>,
|
||||||
|
pub groups: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Wanting {
|
||||||
|
pub async fn load(ctx: &Ctx, only: Option<i64>) -> Result<Self> {
|
||||||
|
let mut people: HashMap<i64, HashMap<String, bool>> = HashMap::new();
|
||||||
|
for (user, feed, wanted) in ctx.db.deadair_prefs(only).await? {
|
||||||
|
people.entry(user).or_default().insert(feed, wanted);
|
||||||
|
}
|
||||||
|
people.retain(|_, p| p.values().any(|&w| w));
|
||||||
|
let groups = if people.is_empty() {
|
||||||
|
HashMap::new()
|
||||||
|
} else {
|
||||||
|
ctx.db.managed_feeds().await?.into_iter().map(|m| (m.id, m.group_id)).collect()
|
||||||
|
};
|
||||||
|
Ok(Self { people, groups })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn wants(&self, user: i64, feed: &str) -> bool {
|
||||||
|
self.people
|
||||||
|
.get(&user)
|
||||||
|
.is_some_and(|p| wants(p, feed, self.groups.get(feed).map(String::as_str)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What each person is playing from a copy, by (person, feed, guid): set when `/media` serves
|
||||||
|
/// them a copy and cleared when it serves the original, so a saved position is converted from
|
||||||
|
/// the clock it was measured in, whether the page saved it or a native app did.
|
||||||
|
// shortcut: in memory, so a restart forgets it. A position saved before the player next asks
|
||||||
|
// for more of the copy is then taken as the original's, which resumes a little early, never late.
|
||||||
|
pub type Playing = Arc<std::sync::Mutex<HashMap<(i64, String, String), Arc<crate::db::Copy>>>>;
|
||||||
|
|
||||||
|
/// Puts a page of entries in the clock of what this person will play: for each file with a
|
||||||
|
/// copy they play, `deadair` is the seconds it saves, and the entry's position and length are
|
||||||
|
/// the copy's. The page plays `/media/{id}/deadair` for a file that has it.
|
||||||
|
pub async fn for_reader(ctx: &Ctx, user: i64, rows: &mut [crate::db::EntryRow]) -> Result<()> {
|
||||||
|
let wanting = Wanting::load(ctx, Some(user)).await?;
|
||||||
|
if wanting.people.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let ids: Vec<i64> = rows
|
||||||
|
.iter()
|
||||||
|
.filter(|r| wanting.wants(user, &r.feed_id))
|
||||||
|
.flat_map(|r| r.enclosures.iter().filter(|e| e.path.is_some()).map(|e| e.id))
|
||||||
|
.collect();
|
||||||
|
let copies: HashMap<i64, crate::db::Copy> =
|
||||||
|
ctx.db.copies(&ids).await?.into_iter().map(|c| (c.enclosure_id, c)).collect();
|
||||||
|
for r in rows.iter_mut() {
|
||||||
|
let mut converted = false;
|
||||||
|
for e in &mut r.enclosures {
|
||||||
|
let Some(c) = copies.get(&e.id) else { continue };
|
||||||
|
e.deadair = Some(c.saved_ms / 1000);
|
||||||
|
// One position an item, however many files: the first with a copy sets the clock.
|
||||||
|
if !std::mem::replace(&mut converted, true) {
|
||||||
|
r.position = to_copy(&c.cuts, r.position * 1000) / 1000;
|
||||||
|
r.duration = r.duration.map(|d| to_copy(&c.cuts, d * 1000) / 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A position and length a player saved, in the clock of what it was playing, as the
|
||||||
|
/// original's. Past 75% of a copy, the episode also counts towards the time DeadAir has saved
|
||||||
|
/// this person.
|
||||||
|
pub async fn on_save(
|
||||||
|
ctx: &Ctx,
|
||||||
|
playing: &Playing,
|
||||||
|
user: i64,
|
||||||
|
feed: &str,
|
||||||
|
guid: &str,
|
||||||
|
secs: i64,
|
||||||
|
duration: Option<i64>,
|
||||||
|
) -> Result<(i64, Option<i64>)> {
|
||||||
|
let copy = playing.lock().unwrap().get(&(user, feed.to_owned(), guid.to_owned())).cloned();
|
||||||
|
let Some(c) = copy else { return Ok((secs, duration)) };
|
||||||
|
let original = |s: i64| to_original(&c.cuts, s * 1000) / 1000;
|
||||||
|
let (secs, duration) = (original(secs), duration.map(original));
|
||||||
|
let len = match duration {
|
||||||
|
Some(d) => Some(d),
|
||||||
|
None => ctx.db.entry_duration(user, feed, guid).await?,
|
||||||
|
};
|
||||||
|
if let Some(len) = len
|
||||||
|
&& len > 0
|
||||||
|
&& secs * 4 >= len * 3
|
||||||
|
{
|
||||||
|
ctx.db.count_deadair(user, feed, guid, c.saved_ms).await?;
|
||||||
|
}
|
||||||
|
Ok((secs, duration))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Makes copies until stopped: the next one anyone wants, or a wait of `IDLE` when there is
|
||||||
|
/// none.
|
||||||
|
pub async fn run(ctx: Arc<Ctx>, mut stop: tokio::sync::watch::Receiver<bool>) {
|
||||||
|
if !have_ffmpeg().await {
|
||||||
|
tracing::warn!("ffmpeg is not installed, so DeadAir makes no copies");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loop {
|
||||||
|
// Dropping the pass on a signal kills the ffmpeg it is running (`kill_on_drop`).
|
||||||
|
let made = tokio::select! {
|
||||||
|
_ = stop.changed() => return,
|
||||||
|
made = next(&ctx) => made,
|
||||||
|
};
|
||||||
|
match made {
|
||||||
|
Ok(true) => continue,
|
||||||
|
Ok(false) => {}
|
||||||
|
Err(e) => tracing::warn!(error = %format!("{e:#}"), "DeadAir could not make a copy"),
|
||||||
|
}
|
||||||
|
tokio::select! {
|
||||||
|
_ = stop.changed() => return,
|
||||||
|
_ = ctx.deadair.notified() => {}
|
||||||
|
_ = tokio::time::sleep(IDLE) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn have_ffmpeg() -> bool {
|
||||||
|
tokio::process::Command::new("ffmpeg")
|
||||||
|
.arg("-version")
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.status()
|
||||||
|
.await
|
||||||
|
.is_ok_and(|s| s.success())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Makes one copy someone wants, if there is one to make. A file everyone who would play the
|
||||||
|
/// copy has already finished gets none: finished episodes are rarely played again.
|
||||||
|
// shortcut: those stay on the list and are looked at again each pass, one query each; record
|
||||||
|
// them as passed over if a large library makes the pass slow.
|
||||||
|
async fn next(ctx: &Ctx) -> Result<bool> {
|
||||||
|
// Nobody with DeadAir on for anything, which is most passes: nothing more to ask the database.
|
||||||
|
let wanting = Wanting::load(ctx, None).await?;
|
||||||
|
if wanting.people.is_empty() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
for t in ctx.db.deadair_todo().await? {
|
||||||
|
let users: Vec<i64> =
|
||||||
|
wanting.people.keys().copied().filter(|&u| wanting.wants(u, &t.feed_id)).collect();
|
||||||
|
if users.is_empty() || !ctx.db.subscribed_any(&users, &t.feed_id).await? {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let readers = ctx.db.readers(&t.feed_id, &t.guid).await?;
|
||||||
|
if users.iter().all(|u| readers.contains(u)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let src = crate::download::playable(Path::new(&t.path));
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
let made = make(&src).await;
|
||||||
|
let (path, cuts) = match made {
|
||||||
|
Ok(Some((path, cuts))) => (Some(path.display().to_string()), cuts),
|
||||||
|
Ok(None) => (None, vec![]),
|
||||||
|
Err(e) => {
|
||||||
|
// Recorded as having none, so a file ffmpeg cannot read is not tried every pass.
|
||||||
|
tracing::warn!(path = %src.display(), error = %format!("{e:#}"), "DeadAir could not make a copy");
|
||||||
|
(None, vec![])
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let saved_ms = saved(&cuts);
|
||||||
|
ctx.db.store_deadair(t.id, path.as_deref(), &serde_json::to_string(&cuts)?, saved_ms).await?;
|
||||||
|
tracing::info!(
|
||||||
|
ev = "deadair",
|
||||||
|
feed = t.feed_id,
|
||||||
|
path = %src.display(),
|
||||||
|
cuts = cuts.len(),
|
||||||
|
saved_s = saved_ms / 1000,
|
||||||
|
took_ms = started.elapsed().as_millis() as u64,
|
||||||
|
"DeadAir copy made"
|
||||||
|
);
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Makes the copy of one file. None for a file it does not apply to: video, anything but MP3
|
||||||
|
/// and AAC, or one with no silence long enough to cut.
|
||||||
|
pub async fn make(src: &Path) -> Result<Option<(PathBuf, Vec<Cut>)>> {
|
||||||
|
let Some((first, frame)) = probe(src).await? else { return Ok(None) };
|
||||||
|
let log = ffmpeg(&[
|
||||||
|
"-hide_banner", "-nostats", "-i", &src.display().to_string(), "-map", "0:a:0",
|
||||||
|
"-af", &format!("silencedetect=n={THRESHOLD}:d={MIN_SILENCE}"), "-f", "null", "-",
|
||||||
|
])
|
||||||
|
.await
|
||||||
|
.context("finding the silences")?;
|
||||||
|
let (frames, cuts) = plan(&parse_silences(&log), first, frame);
|
||||||
|
if cuts.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let drop: Vec<String> = frames.iter().map(|(a, b)| format!("between(n\\,{a}\\,{b})")).collect();
|
||||||
|
let dst = copy_path(src);
|
||||||
|
// Written under another name and moved into place, so a copy cut short by a restart is
|
||||||
|
// never served.
|
||||||
|
let tmp = dst.with_file_name(format!(
|
||||||
|
"{}.part.{}",
|
||||||
|
dst.file_stem().unwrap_or_default().to_string_lossy(),
|
||||||
|
dst.extension().unwrap_or_default().to_string_lossy()
|
||||||
|
));
|
||||||
|
ffmpeg(&[
|
||||||
|
"-v", "error", "-y", "-i", &src.display().to_string(), "-map", "0:a:0", "-c", "copy",
|
||||||
|
"-bsf:a", &format!("noise=drop='{}',setts=ts=N*DURATION", drop.join("+")),
|
||||||
|
&tmp.display().to_string(),
|
||||||
|
])
|
||||||
|
.await
|
||||||
|
.context("copying the frames kept")?;
|
||||||
|
tokio::fs::rename(&tmp, &dst).await.with_context(|| format!("moving into {}", dst.display()))?;
|
||||||
|
Ok(Some((dst, cuts)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When the first audio frame starts and how long each lasts, in seconds, for a file DeadAir
|
||||||
|
/// can copy; None for one it cannot. A cover picture in an MP3 is not video.
|
||||||
|
async fn probe(src: &Path) -> Result<Option<(f64, f64)>> {
|
||||||
|
let path = src.display().to_string();
|
||||||
|
let out = run_out("ffprobe", &[
|
||||||
|
"-v", "error", "-show_entries", "stream=codec_type,codec_name:stream_disposition=attached_pic",
|
||||||
|
"-of", "json", &path,
|
||||||
|
])
|
||||||
|
.await?;
|
||||||
|
let v: serde_json::Value = serde_json::from_str(&out).context("reading ffprobe")?;
|
||||||
|
let streams = v["streams"].as_array().cloned().unwrap_or_default();
|
||||||
|
let video = streams
|
||||||
|
.iter()
|
||||||
|
.any(|s| s["codec_type"] == "video" && s["disposition"]["attached_pic"] != 1);
|
||||||
|
let audio = streams.iter().find(|s| s["codec_type"] == "audio");
|
||||||
|
if video || !audio.is_some_and(|a| a["codec_name"] == "mp3" || a["codec_name"] == "aac") {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let out = run_out("ffprobe", &[
|
||||||
|
"-v", "error", "-select_streams", "a:0", "-show_entries", "packet=pts_time,duration_time",
|
||||||
|
"-read_intervals", "%+#1", "-of", "csv=p=0", &path,
|
||||||
|
])
|
||||||
|
.await?;
|
||||||
|
let mut f = out.trim().split(',').map(|x| x.trim().parse::<f64>());
|
||||||
|
match (f.next(), f.next()) {
|
||||||
|
(Some(Ok(first)), Some(Ok(frame))) if frame > 0.0 => Ok(Some((first, frame))),
|
||||||
|
_ => bail!("no audio frame in {path}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs ffmpeg and gives back what it wrote to stderr, where silencedetect reports.
|
||||||
|
async fn ffmpeg(args: &[&str]) -> Result<String> {
|
||||||
|
let out = command("ffmpeg").args(["-nostdin", "-threads", "1"]).args(args).output().await?;
|
||||||
|
let log = String::from_utf8_lossy(&out.stderr).into_owned();
|
||||||
|
if !out.status.success() {
|
||||||
|
bail!("ffmpeg failed: {}", log.lines().rev().take(3).collect::<Vec<_>>().join(" / "));
|
||||||
|
}
|
||||||
|
Ok(log)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_out(prog: &str, args: &[&str]) -> Result<String> {
|
||||||
|
let out = command(prog).args(args).output().await.with_context(|| format!("running {prog}"))?;
|
||||||
|
if !out.status.success() {
|
||||||
|
bail!("{prog} failed: {}", String::from_utf8_lossy(&out.stderr).trim());
|
||||||
|
}
|
||||||
|
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command(prog: &str) -> tokio::process::Command {
|
||||||
|
let mut c = tokio::process::Command::new(prog);
|
||||||
|
c.kill_on_drop(true).stdin(std::process::Stdio::null());
|
||||||
|
c
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn silences_are_read_from_the_log_and_an_open_one_is_left() {
|
||||||
|
let log = "[silencedetect @ 0x1] silence_start: 1\n\
|
||||||
|
[silencedetect @ 0x1] silence_end: 3.00002 | silence_duration: 2.00002\n\
|
||||||
|
[silencedetect @ 0x1] silence_start: 10.5\n";
|
||||||
|
assert_eq!(parse_silences(log), vec![(1.0, 3.00002)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cuts_leave_silent_frames_either_side_and_skip_short_silences() {
|
||||||
|
// MP3 frames, 26.122 ms; one silence of 2 s, one too short to touch.
|
||||||
|
let frame = 1152.0 / 44100.0;
|
||||||
|
let (frames, cuts) = plan(&[(1.0, 3.0), (5.0, 5.3)], 0.0, frame);
|
||||||
|
assert_eq!(frames, vec![(43, 110)]);
|
||||||
|
let Cut(s, e) = cuts[0];
|
||||||
|
assert!(s >= 1100 && e <= 2900, "{cuts:?}");
|
||||||
|
assert!(s - 1100 < 27 && 2900 - e < 27, "no more than a frame kept beyond KEEP: {cuts:?}");
|
||||||
|
// AAC in MP4 starts a frame early, for the encoder's priming.
|
||||||
|
let (frames, _) = plan(&[(1.0, 3.0)], -0.02322, 1024.0 / 44100.0);
|
||||||
|
assert_eq!(frames, vec![(49, 124)]);
|
||||||
|
// A silence the margins use up entirely is left alone.
|
||||||
|
assert!(plan(&[(1.0, 1.21)], 0.0, frame).0.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn positions_convert_both_ways() {
|
||||||
|
let cuts = [Cut(10_000, 20_000), Cut(30_000, 35_000)];
|
||||||
|
assert_eq!(to_copy(&cuts, 5_000), 5_000);
|
||||||
|
assert_eq!(to_copy(&cuts, 15_000), 10_000, "inside a cut is where it was made");
|
||||||
|
assert_eq!(to_copy(&cuts, 25_000), 15_000);
|
||||||
|
assert_eq!(to_copy(&cuts, 40_000), 25_000);
|
||||||
|
for t in [0, 5_000, 10_000, 15_000, 19_999, 20_000, 25_000, 40_000] {
|
||||||
|
let back = to_original(&cuts, to_copy(&cuts, t));
|
||||||
|
// A time inside a cut comes back as the cut's end: the copy has nothing between.
|
||||||
|
let expect = if (10_000..20_000).contains(&t) { 20_000 } else { t };
|
||||||
|
assert_eq!(back, expect, "{t}");
|
||||||
|
}
|
||||||
|
assert_eq!(saved(&cuts), 15_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn off_for_every_feed_until_turned_on_for_it_or_its_group() {
|
||||||
|
let p = |v: &[(&str, bool)]| v.iter().map(|(k, w)| (k.to_string(), *w)).collect::<HashMap<_, _>>();
|
||||||
|
assert!(!wants(&p(&[]), "a", None), "off until turned on for the feed");
|
||||||
|
assert!(!wants(&p(&[("b", true)]), "a", None), "another feed's says nothing of this one");
|
||||||
|
assert!(wants(&p(&[("a", true)]), "a", None));
|
||||||
|
assert!(wants(&p(&[("g", true)]), "a", Some("g")), "on for its group");
|
||||||
|
assert!(!wants(&p(&[("g", true), ("a", false)]), "a", Some("g")), "kept out of its group's");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_copy_sits_beside_the_original() {
|
||||||
|
assert_eq!(copy_path(Path::new("/d/show/ep 1.mp3")), Path::new("/d/show/ep 1.deadair.mp3"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole of it, on a clip ffmpeg makes: a second of tone, two of silence, and so on.
|
||||||
|
/// Skipped where ffmpeg is not installed.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_copy_is_shorter_by_what_was_cut_and_plays() {
|
||||||
|
if !have_ffmpeg().await {
|
||||||
|
eprintln!("no ffmpeg; skipped");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let dir = std::env::temp_dir().join(format!("ipx-deadair-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
for (ext, codec) in [("mp3", "libmp3lame"), ("m4a", "aac")] {
|
||||||
|
let src = dir.join(format!("t.{ext}"));
|
||||||
|
ffmpeg(&[
|
||||||
|
"-v", "error", "-y", "-f", "lavfi", "-i",
|
||||||
|
"aevalsrc='if(lt(mod(t,3),1),0.5*sin(2*PI*440*t),0)':d=12:s=44100",
|
||||||
|
"-c:a", codec, &src.display().to_string(),
|
||||||
|
])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let (dst, cuts) = make(&src).await.unwrap().expect("a copy");
|
||||||
|
assert_eq!(cuts.len(), 4, "{ext}: {cuts:?}");
|
||||||
|
// About 1.8 s from each of the four 2 s silences.
|
||||||
|
let cut = saved(&cuts);
|
||||||
|
assert!((7_000..7_300).contains(&cut), "{ext}: {cut}");
|
||||||
|
let len = |p: &Path| {
|
||||||
|
let p = p.display().to_string();
|
||||||
|
async move {
|
||||||
|
run_out("ffprobe", &["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", &p])
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.trim()
|
||||||
|
.parse::<f64>()
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let short = len(&src).await - len(&dst).await;
|
||||||
|
assert!((short * 1000.0 - cut as f64).abs() < 100.0, "{ext}: {short} s shorter, {cut} ms cut");
|
||||||
|
// Decodes without a complaint.
|
||||||
|
assert!(ffmpeg(&["-v", "error", "-i", &dst.display().to_string(), "-f", "null", "-"]).await.unwrap().is_empty());
|
||||||
|
}
|
||||||
|
std::fs::remove_dir_all(&dir).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A real episode, for trying the cut on something other than a test tone:
|
||||||
|
/// `IPX_DEADAIR_FILE=/path/ep.mp3 cargo test real_file -- --ignored --nocapture`. The copy is left
|
||||||
|
/// beside it to listen to.
|
||||||
|
#[cfg(test)]
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn real_file() {
|
||||||
|
let src = PathBuf::from(std::env::var("IPX_DEADAIR_FILE").expect("IPX_DEADAIR_FILE"));
|
||||||
|
let t = std::time::Instant::now();
|
||||||
|
let (dst, cuts) = make(&src).await.unwrap().expect("a copy");
|
||||||
|
eprintln!("{} cuts, {:.1} min saved, {:.1?} -> {}", cuts.len(), saved(&cuts) as f64 / 60_000.0, t.elapsed(), dst.display());
|
||||||
|
}
|
||||||
@@ -207,6 +207,38 @@ pub async fn place(d: &Downloaded, dir: &Path) -> Result<PathBuf> {
|
|||||||
Ok(dest)
|
Ok(dest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deletes what an enclosure's path names: a file, or the folder a torrent of several files came
|
||||||
|
/// down as. `remove_file` refused the folder, so the reaper warned on every pass and freed nothing,
|
||||||
|
/// and Delete failed (#158).
|
||||||
|
pub fn remove(path: &Path) -> std::io::Result<()> {
|
||||||
|
if path.is_dir() {
|
||||||
|
return std::fs::remove_dir_all(path);
|
||||||
|
}
|
||||||
|
// Its DeadAir copy goes with it, if it has one; it is no use without the original (#171).
|
||||||
|
let _ = std::fs::remove_file(crate::deadair::copy_path(path));
|
||||||
|
std::fs::remove_file(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file to play for an enclosure's path: the path itself, or for a torrent of several files,
|
||||||
|
/// the largest file in its folder, which is the episode (#158).
|
||||||
|
pub fn playable(path: &Path) -> PathBuf {
|
||||||
|
fn largest(dir: &Path, best: &mut Option<(u64, PathBuf)>) {
|
||||||
|
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||||
|
for e in entries.flatten() {
|
||||||
|
match e.metadata() {
|
||||||
|
Ok(m) if m.is_dir() => largest(&e.path(), best),
|
||||||
|
Ok(m) if best.as_ref().is_none_or(|(n, _)| m.len() > *n) => *best = Some((m.len(), e.path())),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut best = None;
|
||||||
|
if path.is_dir() {
|
||||||
|
largest(path, &mut best);
|
||||||
|
}
|
||||||
|
best.map_or_else(|| path.to_owned(), |(_, p)| p)
|
||||||
|
}
|
||||||
|
|
||||||
/// Advertised as a torrent, before spending any bandwidth on it.
|
/// Advertised as a torrent, before spending any bandwidth on it.
|
||||||
pub fn looks_like_torrent(url: &str, mime: Option<&str>) -> bool {
|
pub fn looks_like_torrent(url: &str, mime: Option<&str>) -> bool {
|
||||||
mime.is_some_and(|m| m.to_ascii_lowercase().contains("torrent"))
|
mime.is_some_and(|m| m.to_ascii_lowercase().contains("torrent"))
|
||||||
@@ -415,6 +447,20 @@ mod tests {
|
|||||||
assert_eq!(folder_for(&cfg, "id", &f, None), "etc/Show");
|
assert_eq!(folder_for(&cfg, "id", &f, None), "etc/Show");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_torrent_folder_plays_its_largest_file_and_deletes_whole() {
|
||||||
|
let d = std::env::temp_dir().join(format!("ipx-torrent-test-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&d);
|
||||||
|
std::fs::create_dir_all(d.join("show/extras")).unwrap();
|
||||||
|
std::fs::write(d.join("show/cover.jpg"), [0; 10]).unwrap();
|
||||||
|
std::fs::write(d.join("show/extras/episode.mp3"), [0; 100]).unwrap();
|
||||||
|
assert_eq!(playable(&d.join("show")), d.join("show/extras/episode.mp3"));
|
||||||
|
assert_eq!(playable(&d.join("show/cover.jpg")), d.join("show/cover.jpg"), "a file is itself");
|
||||||
|
remove(&d.join("show")).unwrap();
|
||||||
|
assert!(!d.join("show").exists());
|
||||||
|
let _ = std::fs::remove_dir_all(&d);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn keyword_matching_is_or_across_keywords_and_and_within_one() {
|
fn keyword_matching_is_or_across_keywords_and_and_within_one() {
|
||||||
let kws = vec!["deep dive".to_string(), "interview".to_string()];
|
let kws = vec!["deep dive".to_string(), "interview".to_string()];
|
||||||
|
|||||||
@@ -354,3 +354,69 @@ pub mod settings {
|
|||||||
|
|
||||||
impl ActiveModelBehavior for ActiveModel {}
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// DeadAir's copy of an enclosure: the same file with long silences shortened (issue #171).
|
||||||
|
/// `cuts` is what the copy leaves out, as `[[start_ms, end_ms], ...]` of the original's time,
|
||||||
|
/// which is what converts positions between the two. `path` is NULL for a file that cannot have
|
||||||
|
/// one (video, or not MP3 or AAC), so it is looked at once and not every pass.
|
||||||
|
pub mod deadair {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "deadair")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub enclosure_id: i64,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub path: Option<String>,
|
||||||
|
#[sea_orm(column_type = "Text")]
|
||||||
|
pub cuts: String,
|
||||||
|
pub saved_ms: i64,
|
||||||
|
/// Against the enclosure's `downloaded_at`: a file downloaded again needs its copy again.
|
||||||
|
pub made_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Who has DeadAir on, for which feeds: it is off for every feed until someone turns it on for
|
||||||
|
/// that one or its group, and `wanted` false keeps one feed of a group out of the group's.
|
||||||
|
pub mod deadair_prefs {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "deadair_prefs")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub user_id: i64,
|
||||||
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||||
|
pub feed_id: String,
|
||||||
|
pub wanted: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
owned_by_user!();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The time DeadAir has saved someone: one row per episode they got 75% through on a copy, so
|
||||||
|
/// replaying it never counts twice. Their total is the sum.
|
||||||
|
pub mod deadair_saved {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "deadair_saved")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub user_id: i64,
|
||||||
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||||
|
pub feed_id: String,
|
||||||
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||||
|
pub guid: String,
|
||||||
|
pub saved_ms: i64,
|
||||||
|
pub at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
owned_by_user!();
|
||||||
|
}
|
||||||
|
|||||||
27
src/feed.rs
27
src/feed.rs
@@ -196,15 +196,25 @@ pub fn explain_failure(msg: &str) -> Option<Failure> {
|
|||||||
if low.contains("http 402") {
|
if low.contains("http 402") {
|
||||||
return Some(Failure { reason: "The feed now needs a paid plan.", new_url: None });
|
return Some(Failure { reason: "The feed now needs a paid plan.", new_url: None });
|
||||||
}
|
}
|
||||||
if low.contains("dns error")
|
// Only a name that does not exist. A lookup that failed for the moment says nothing about the
|
||||||
|| low.contains("failed to lookup address")
|
// site, and NPR's and Megaphone's feeds were labelled gone after a DNS outage (#143).
|
||||||
|| low.contains("no address associated")
|
if !is_temporary_lookup_failure(msg)
|
||||||
|
&& (low.contains("dns error") || low.contains("failed to lookup address") || low.contains("no address associated"))
|
||||||
{
|
{
|
||||||
return Some(Failure { reason: "This address no longer resolves; the site is gone.", new_url: None });
|
return Some(Failure { reason: "This address no longer resolves; the site may be gone.", new_url: None });
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A host that could not be looked up for the moment -- the resolver's EAI_AGAIN, glibc's
|
||||||
|
/// "Temporary failure in name resolution", musl's "Try again" -- as against a name that does
|
||||||
|
/// not exist ("Name or service not known"). It says the network was not answering, not that the
|
||||||
|
/// feed is gone (#143).
|
||||||
|
pub fn is_temporary_lookup_failure(msg: &str) -> bool {
|
||||||
|
let low = msg.to_ascii_lowercase();
|
||||||
|
low.contains("dns error") && (low.contains("temporary failure in name resolution") || low.contains("try again"))
|
||||||
|
}
|
||||||
|
|
||||||
/// True when a body is an OPML document rather than a feed.
|
/// True when a body is an OPML document rather than a feed.
|
||||||
///
|
///
|
||||||
/// The original matched on the URL ending in ".opml" (iPXClass.py:34), which misses an
|
/// The original matched on the URL ending in ".opml" (iPXClass.py:34), which misses an
|
||||||
@@ -1317,8 +1327,13 @@ mod tests {
|
|||||||
explain_failure("the feed's address answered with nothing at all").unwrap().reason,
|
explain_failure("the feed's address answered with nothing at all").unwrap().reason,
|
||||||
"This address answers with nothing; the site may be gone."
|
"This address answers with nothing; the site may be gone."
|
||||||
);
|
);
|
||||||
let dns = explain_failure("connecting: dns error: failed to lookup address information").unwrap();
|
let dns = explain_failure("connecting: dns error: failed to lookup address information: Name or service not known").unwrap();
|
||||||
assert_eq!(dns.reason, "This address no longer resolves; the site is gone.");
|
assert_eq!(dns.reason, "This address no longer resolves; the site may be gone.");
|
||||||
|
// A lookup that failed for the moment is not a site gone (#143).
|
||||||
|
let blip = "connecting: dns error: failed to lookup address information: Temporary failure in name resolution";
|
||||||
|
assert!(is_temporary_lookup_failure(blip));
|
||||||
|
assert!(explain_failure(blip).is_none());
|
||||||
|
assert!(!is_temporary_lookup_failure("connecting: dns error: failed to lookup address information: Name or service not known"));
|
||||||
let moved = explain_failure("got a web page, not a feed; it links https://x/feed as its feed").unwrap();
|
let moved = explain_failure("got a web page, not a feed; it links https://x/feed as its feed").unwrap();
|
||||||
assert_eq!(moved.new_url.as_deref(), Some("https://x/feed"));
|
assert_eq!(moved.new_url.as_deref(), Some("https://x/feed"));
|
||||||
assert!(explain_failure("got a web page, not a feed").unwrap().new_url.is_none());
|
assert!(explain_failure("got a web page, not a feed").unwrap().new_url.is_none());
|
||||||
|
|||||||
412
src/main.rs
412
src/main.rs
@@ -3,6 +3,7 @@ mod art;
|
|||||||
mod auth;
|
mod auth;
|
||||||
mod config;
|
mod config;
|
||||||
mod db;
|
mod db;
|
||||||
|
mod deadair;
|
||||||
mod entity;
|
mod entity;
|
||||||
mod download;
|
mod download;
|
||||||
mod feed;
|
mod feed;
|
||||||
@@ -141,19 +142,83 @@ pub struct Ctx {
|
|||||||
/// stops every feed scan, every HTTP download and every status command behind it.
|
/// stops every feed scan, every HTTP download and every status command behind it.
|
||||||
/// A one-shot CLI run keeps them inline, or the process would exit mid-download.
|
/// A one-shot CLI run keeps them inline, or the process would exit mid-download.
|
||||||
pub detach_torrents: bool,
|
pub detach_torrents: bool,
|
||||||
|
/// Feeds whose host could not be looked up for the moment, and when to try them again: a
|
||||||
|
/// failure that says nothing about the feed, kept off it and out of the database (#143).
|
||||||
|
/// Forgotten on a restart, which only makes the retry sooner.
|
||||||
|
pub retry: std::sync::Mutex<std::collections::HashMap<String, i64>>,
|
||||||
|
/// Held from taking a copy of the config to storing it (`edit_cfg`). Each change stores the
|
||||||
|
/// whole catalogue from its copy, and two at once stored over each other: a feed added while
|
||||||
|
/// another add spent a minute fetching was gone once that one stored (#153).
|
||||||
|
// ponytail: this process only. `ipx add`, `rm` and `import` write from a process of their
|
||||||
|
// own, and a change in flight here can still store over theirs; take the copy from the
|
||||||
|
// database rather than memory if that ever happens.
|
||||||
|
pub cfg_write: tokio::sync::Mutex<()>,
|
||||||
|
/// Wakes DeadAir's maker when a show is turned on, so its copies start at once instead of
|
||||||
|
/// at the next pass (#171).
|
||||||
|
pub deadair: tokio::sync::Notify,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A copy of the config to change, holding off every other change until `store_cfg` keeps it.
|
||||||
|
pub struct CfgEdit<'a> {
|
||||||
|
cfg: config::Config,
|
||||||
|
_held: tokio::sync::MutexGuard<'a, ()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::ops::Deref for CfgEdit<'_> {
|
||||||
|
type Target = config::Config;
|
||||||
|
fn deref(&self) -> &config::Config {
|
||||||
|
&self.cfg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::ops::DerefMut for CfgEdit<'_> {
|
||||||
|
fn deref_mut(&mut self) -> &mut config::Config {
|
||||||
|
&mut self.cfg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How soon a feed whose host could not be looked up is tried again (#143).
|
||||||
|
const RETRY_LOOKUP_SECS: i64 = 600;
|
||||||
|
|
||||||
impl Ctx {
|
impl Ctx {
|
||||||
|
/// When a feed is next due, taking a lookup retry into account: not before it.
|
||||||
|
fn due(&self, cfg: &config::Config, feed: &config::Feed, id: &str, state: &db::HttpState, subscribed: bool) -> Option<i64> {
|
||||||
|
let at = due_at(cfg, feed, state, subscribed);
|
||||||
|
match self.retry.lock().unwrap().get(id) {
|
||||||
|
Some(&retry) => Some(at.unwrap_or(0).max(retry)),
|
||||||
|
None => at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A scan's failure, recorded on the feed, unless it was only a moment's failure to look its
|
||||||
|
/// host up: on 2026-10-05 a DNS outage on Tower failed 204 feeds in a minute with
|
||||||
|
/// "Temporary failure in name resolution", and each, read once a day and backed off as a
|
||||||
|
/// failing feed, sat a day failing, unread and labelled gone (#143). Tried again in minutes
|
||||||
|
/// instead, with nothing on the feed to say it failed.
|
||||||
|
async fn feed_failed(&self, id: &str, url: &str, msg: &str) -> Result<()> {
|
||||||
|
if feed::is_temporary_lookup_failure(msg) {
|
||||||
|
self.retry.lock().unwrap().insert(id.to_owned(), db::now() + RETRY_LOOKUP_SECS);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
self.db.set_feed_error(id, url, msg).await
|
||||||
|
}
|
||||||
|
|
||||||
pub fn cfg(&self) -> std::sync::Arc<config::Config> {
|
pub fn cfg(&self) -> std::sync::Arc<config::Config> {
|
||||||
self.cfg.read().unwrap().clone()
|
self.cfg.read().unwrap().clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-reads config.toml into the live snapshot.
|
/// The config as it stands, to change and then keep with `store_cfg`; any other change waits
|
||||||
|
/// until then.
|
||||||
|
pub async fn edit_cfg(&self) -> CfgEdit<'_> {
|
||||||
|
let held = self.cfg_write.lock().await;
|
||||||
|
CfgEdit { cfg: (*self.cfg()).clone(), _held: held }
|
||||||
|
}
|
||||||
|
|
||||||
/// Keeps a changed catalogue or server settings: in the database, and for everything running
|
/// Keeps a changed catalogue or server settings: in the database, and for everything running
|
||||||
/// here from now on. What config.toml's save and reload did, before the database held them.
|
/// here from now on. What config.toml's save and reload did, before the database held them.
|
||||||
pub async fn store_cfg(&self, cfg: config::Config) -> Result<()> {
|
pub async fn store_cfg(&self, edit: CfgEdit<'_>) -> Result<()> {
|
||||||
self.db.store_config(&cfg).await?;
|
self.db.store_config(&edit.cfg).await?;
|
||||||
self.set_cfg(cfg);
|
self.set_cfg(edit.cfg);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,7 +320,10 @@ async fn main() -> Result<()> {
|
|||||||
let is_daemon = matches!(cli.command, Command::Daemon { .. });
|
let is_daemon = matches!(cli.command, Command::Daemon { .. });
|
||||||
// These write the catalogue from a process of their own, which a running daemon, holding its
|
// These write the catalogue from a process of their own, which a running daemon, holding its
|
||||||
// own copy, would write over at its next change (#120).
|
// own copy, would write over at its next change (#120).
|
||||||
let changes_catalogue = matches!(cli.command, Command::Add { .. } | Command::Rm { .. } | Command::Import { .. });
|
let changes_catalogue = matches!(
|
||||||
|
cli.command,
|
||||||
|
Command::Add { .. } | Command::Rm { .. } | Command::Import { .. } | Command::User { cmd: UserCmd::Rm { .. } }
|
||||||
|
);
|
||||||
let (events, _) = broadcast::channel(1024);
|
let (events, _) = broadcast::channel(1024);
|
||||||
let ctx = Arc::new(Ctx {
|
let ctx = Arc::new(Ctx {
|
||||||
cfg: std::sync::RwLock::new(std::sync::Arc::new(cfg)),
|
cfg: std::sync::RwLock::new(std::sync::Arc::new(cfg)),
|
||||||
@@ -264,6 +332,9 @@ async fn main() -> Result<()> {
|
|||||||
.user_agent(concat!("ipx/", env!("CARGO_PKG_VERSION")))
|
.user_agent(concat!("ipx/", env!("CARGO_PKG_VERSION")))
|
||||||
// Connecting only, so it bounds a download's start, not a long download (#108).
|
// Connecting only, so it bounds a download's start, not a long download (#108).
|
||||||
.connect_timeout(std::time::Duration::from_secs(10))
|
.connect_timeout(std::time::Duration::from_secs(10))
|
||||||
|
// Two minutes without a byte is a stalled download, however long it is: unbounded, one
|
||||||
|
// held the worker, and every scan behind it, for good, while the daemon read healthy (#152).
|
||||||
|
.read_timeout(std::time::Duration::from_secs(120))
|
||||||
.build()?,
|
.build()?,
|
||||||
feed_client: reqwest::Client::builder()
|
feed_client: reqwest::Client::builder()
|
||||||
.user_agent(concat!("ipx/", env!("CARGO_PKG_VERSION")))
|
.user_agent(concat!("ipx/", env!("CARGO_PKG_VERSION")))
|
||||||
@@ -275,6 +346,9 @@ async fn main() -> Result<()> {
|
|||||||
torrent_slots: Arc::new(tokio::sync::Semaphore::new(2)),
|
torrent_slots: Arc::new(tokio::sync::Semaphore::new(2)),
|
||||||
config_path: config_path.clone(),
|
config_path: config_path.clone(),
|
||||||
detach_torrents: is_daemon,
|
detach_torrents: is_daemon,
|
||||||
|
retry: Default::default(),
|
||||||
|
cfg_write: Default::default(),
|
||||||
|
deadair: Default::default(),
|
||||||
});
|
});
|
||||||
|
|
||||||
let result = match cli.command {
|
let result = match cli.command {
|
||||||
@@ -441,7 +515,7 @@ async fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd) -> Result<()> {
|
|||||||
.db
|
.db
|
||||||
.user_by_name(&name).await?
|
.user_by_name(&name).await?
|
||||||
.ok_or_else(|| anyhow::anyhow!("no such account: {name}"))?;
|
.ok_or_else(|| anyhow::anyhow!("no such account: {name}"))?;
|
||||||
ctx.db.delete_user(user.id).await?;
|
remove_user(ctx, user.id).await?;
|
||||||
println!("removed {name}");
|
println!("removed {name}");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -469,6 +543,7 @@ async fn run(ctx: &Arc<Ctx>, cmd: Cmd) -> Result<()> {
|
|||||||
/// memory, for a daemon told another ipx changed them (#120); config.toml, read again, for where
|
/// memory, for a daemon told another ipx changed them (#120); config.toml, read again, for where
|
||||||
/// things are and who gets in.
|
/// things are and who gets in.
|
||||||
async fn reload(ctx: &Ctx) -> Result<()> {
|
async fn reload(ctx: &Ctx) -> Result<()> {
|
||||||
|
let _held = ctx.cfg_write.lock().await;
|
||||||
let cfg = assemble_config(&ctx.db, config::Config::load(&ctx.config_path)?, &ctx.config_path).await?;
|
let cfg = assemble_config(&ctx.db, config::Config::load(&ctx.config_path)?, &ctx.config_path).await?;
|
||||||
ctx.set_cfg(cfg);
|
ctx.set_cfg(cfg);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -527,7 +602,10 @@ async fn daemon(
|
|||||||
Err(e) => tracing::warn!(error = %format!("{e:#}"), "could not retire feeds whose OPML is no longer in config"),
|
Err(e) => tracing::warn!(error = %format!("{e:#}"), "could not retire feeds whose OPML is no longer in config"),
|
||||||
}
|
}
|
||||||
|
|
||||||
let (tx_cmd, mut rx_cmd) = mpsc::channel::<Cmd>(64);
|
// Room for any one request's commands: a web request waits inside itself for a free place,
|
||||||
|
// and Download latest sends up to 100. With 64 the rest waited on downloads an hour long, and
|
||||||
|
// every other request that queues work waited behind them (#154).
|
||||||
|
let (tx_cmd, mut rx_cmd) = mpsc::channel::<Cmd>(1024);
|
||||||
|
|
||||||
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?;
|
||||||
// status and reload are answered by the socket itself; everything else waits its turn in the
|
// status and reload are answered by the socket itself; everything else waits its turn in the
|
||||||
@@ -562,6 +640,9 @@ async fn daemon(
|
|||||||
shutdown().await;
|
shutdown().await;
|
||||||
let _ = tx_stop.send(true);
|
let _ = tx_stop.send(true);
|
||||||
});
|
});
|
||||||
|
// Off the worker: the first pass over a library is hours of ffmpeg, and scans and downloads
|
||||||
|
// must not wait behind it.
|
||||||
|
tokio::spawn(deadair::run(ctx.clone(), rx_stop.clone()));
|
||||||
|
|
||||||
// Runs one job, abandoning it if a signal arrives. Returns false to end the loop.
|
// Runs one job, abandoning it if a signal arrives. Returns false to end the loop.
|
||||||
async fn until_stopped(
|
async fn until_stopped(
|
||||||
@@ -692,6 +773,7 @@ async fn start_web(
|
|||||||
cmds: cmds.clone(),
|
cmds: cmds.clone(),
|
||||||
events: events.clone(),
|
events: events.clone(),
|
||||||
access,
|
access,
|
||||||
|
playing: Default::default(),
|
||||||
};
|
};
|
||||||
Ok(Some(tokio::spawn(async move {
|
Ok(Some(tokio::spawn(async move {
|
||||||
if let Err(e) = web::serve(state, &bind).await {
|
if let Err(e) = web::serve(state, &bind).await {
|
||||||
@@ -721,38 +803,54 @@ async fn add(
|
|||||||
category: Option<String>,
|
category: Option<String>,
|
||||||
list: bool,
|
list: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let mut cfg = (*ctx.cfg()).clone();
|
let url = feed::find_feed(&ctx.client, &feed::expand_input(url)).await?;
|
||||||
let url = &feed::find_feed(&ctx.client, &feed::expand_input(url)).await?;
|
let (id, existed) = add_to_catalogue(ctx, &url, folder, keywords).await?;
|
||||||
// Includes feeds derived from an OPML, or the same show could be added twice.
|
// Listing a feed the catalogue already has, or giving it a category, is the point of asking
|
||||||
if let Some(existing) = subscriptions(ctx).await?.iter().find(|s| feed::same_feed(&s.cfg.url, url)) {
|
// again: it changes those, and nothing else. A feed an OPML lists has no entry to change.
|
||||||
// Listing a feed the catalogue already has, or giving it a category, is the point of
|
if list || category.is_some() {
|
||||||
// asking again: it changes those, and nothing else.
|
let mut cfg = ctx.edit_cfg().await;
|
||||||
if let Some(f) = cfg.feeds.get_mut(&existing.id)
|
if let Some(f) = cfg.feeds.get_mut(&id) {
|
||||||
&& (list || category.is_some())
|
|
||||||
{
|
|
||||||
f.listed |= list;
|
f.listed |= list;
|
||||||
if category.is_some() {
|
if category.is_some() {
|
||||||
f.category = category;
|
f.category = category;
|
||||||
}
|
}
|
||||||
ctx.store_cfg(cfg).await?;
|
ctx.store_cfg(cfg).await?;
|
||||||
println!("{} is already in the catalogue; updated its Directory listing", existing.id);
|
if existed {
|
||||||
|
println!("{id} is already in the catalogue; updated its Directory listing");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
anyhow::bail!("already subscribed as {:?}", existing.id);
|
|
||||||
}
|
}
|
||||||
let id = add_one(ctx, &mut cfg, url, folder, keywords).await?;
|
|
||||||
if let Some(f) = cfg.feeds.get_mut(&id) {
|
|
||||||
f.category = category;
|
|
||||||
f.listed = list;
|
|
||||||
}
|
}
|
||||||
ctx.store_cfg(cfg).await?;
|
if existed {
|
||||||
|
anyhow::bail!("already subscribed as {id:?}");
|
||||||
|
}
|
||||||
println!("added {id}{}", if list { ", listed in the Directory" } else { "" });
|
println!("added {id}{}", if list { ", listed in the Directory" } else { "" });
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Adds the feed at `url` to the catalogue unless it is there already, a feed an OPML lists
|
||||||
|
/// included, so one show is never added twice: its id, and whether it was there. For `ipx add`
|
||||||
|
/// and the page's Add feed alike, which were the same steps written twice (#161); each finds the
|
||||||
|
/// feed first, to say its own way why an address is not one.
|
||||||
|
pub async fn add_to_catalogue(
|
||||||
|
ctx: &Ctx,
|
||||||
|
url: &str,
|
||||||
|
folder: Option<String>,
|
||||||
|
keywords: Vec<String>,
|
||||||
|
) -> Result<(String, bool)> {
|
||||||
|
// Taken before the duplicate check, so two people adding the same feed at once add it once.
|
||||||
|
let mut cfg = ctx.edit_cfg().await;
|
||||||
|
if let Some(existing) = subscriptions(ctx).await?.into_iter().find(|s| feed::same_feed(&s.cfg.url, url)) {
|
||||||
|
return Ok((existing.id, true));
|
||||||
|
}
|
||||||
|
let id = add_one(ctx, &mut cfg, url, folder, keywords).await?;
|
||||||
|
ctx.store_cfg(cfg).await?;
|
||||||
|
Ok((id, false))
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns the new feed id. Adding checks the address is a feed first (`feed::find_feed`); this
|
/// Returns the new feed id. Adding checks the address is a feed first (`feed::find_feed`); this
|
||||||
/// still names one it cannot read from its URL rather than failing.
|
/// still names one it cannot read from its URL rather than failing.
|
||||||
pub async fn add_one(
|
async fn add_one(
|
||||||
ctx: &Ctx,
|
ctx: &Ctx,
|
||||||
cfg: &mut config::Config,
|
cfg: &mut config::Config,
|
||||||
url: &str,
|
url: &str,
|
||||||
@@ -819,7 +917,7 @@ fn url_stem(url: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn rm(ctx: &Ctx, feed: &str) -> Result<()> {
|
async fn rm(ctx: &Ctx, feed: &str) -> Result<()> {
|
||||||
let mut cfg = (*ctx.cfg()).clone();
|
let mut cfg = ctx.edit_cfg().await;
|
||||||
if cfg.feeds.remove(feed).is_none() {
|
if cfg.feeds.remove(feed).is_none() {
|
||||||
// Derived from an OPML: drop it here, though the subscription will list it again
|
// Derived from an OPML: drop it here, though the subscription will list it again
|
||||||
// on the next read unless the OPML itself goes.
|
// on the next read unless the OPML itself goes.
|
||||||
@@ -869,8 +967,8 @@ pub async fn subscribe_opml(
|
|||||||
let mut found = vec![];
|
let mut found = vec![];
|
||||||
collect_outlines(&doc.body.outlines, &mut found);
|
collect_outlines(&doc.body.outlines, &mut found);
|
||||||
|
|
||||||
|
let mut cfg = ctx.edit_cfg().await;
|
||||||
let known = subscriptions(ctx).await?;
|
let known = subscriptions(ctx).await?;
|
||||||
let mut cfg = (*ctx.cfg()).clone();
|
|
||||||
let mut ids = vec![];
|
let mut ids = vec![];
|
||||||
let mut grew = false;
|
let mut grew = false;
|
||||||
for (title, url) in found {
|
for (title, url) in found {
|
||||||
@@ -1024,7 +1122,7 @@ async fn clean_directory(ctx: &Ctx) -> Result<()> {
|
|||||||
if stale.is_empty() {
|
if stale.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let mut cfg = (*ctx.cfg()).clone();
|
let mut cfg = ctx.edit_cfg().await;
|
||||||
if stale.iter().fold(false, |any, id| cfg.feeds.remove(id).is_some() || any) {
|
if stale.iter().fold(false, |any, id| cfg.feeds.remove(id).is_some() || any) {
|
||||||
ctx.store_cfg(cfg).await?;
|
ctx.store_cfg(cfg).await?;
|
||||||
}
|
}
|
||||||
@@ -1093,7 +1191,7 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool, scope: &[String]
|
|||||||
state.last_modified = None;
|
state.last_modified = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
if !force && let Some(at) = due_at(&cfg, &sub.cfg, &state, subscribed.contains_key(id)) {
|
if !force && let Some(at) = ctx.due(&cfg, &sub.cfg, id, &state, subscribed.contains_key(id)) {
|
||||||
if at > db::now() {
|
if at > db::now() {
|
||||||
ctx.out.emit(Event::FeedSkip {
|
ctx.out.emit(Event::FeedSkip {
|
||||||
feed: id.clone(),
|
feed: id.clone(),
|
||||||
@@ -1165,7 +1263,7 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool, scope: &[String]
|
|||||||
// One bad feed must not end the scan.
|
// One bad feed must not end the scan.
|
||||||
let msg = format!("{e:#}");
|
let msg = format!("{e:#}");
|
||||||
ctx.out.emit(Event::FeedError { feed: id.clone(), msg: msg.clone() });
|
ctx.out.emit(Event::FeedError { feed: id.clone(), msg: msg.clone() });
|
||||||
ctx.db.set_feed_error(id, &feed_cfg.url, &msg).await?;
|
ctx.feed_failed(id, &feed_cfg.url, &msg).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1191,7 +1289,7 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool, scope: &[String]
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
let msg = format!("{e:#}");
|
let msg = format!("{e:#}");
|
||||||
ctx.out.emit(Event::FeedError { feed: id.clone(), msg: msg.clone() });
|
ctx.out.emit(Event::FeedError { feed: id.clone(), msg: msg.clone() });
|
||||||
ctx.db.set_feed_error(id, &feed_cfg.url, &msg).await?;
|
ctx.feed_failed(id, &feed_cfg.url, &msg).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1221,6 +1319,9 @@ pub async fn subscriptions(ctx: &Ctx) -> Result<Vec<Sub>> {
|
|||||||
.map(|(id, f)| Sub { id: id.clone(), cfg: f.clone(), managed: false })
|
.map(|(id, f)| Sub { id: id.clone(), cfg: f.clone(), managed: false })
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
// A group's title, once a group: asked for each feed in it, three queries a feed, it was about
|
||||||
|
// 96 for The Glass Cannon Network's 32 shows on every feed list, live update and scan (#159).
|
||||||
|
let mut group_titles = std::collections::HashMap::<String, String>::new();
|
||||||
for m in ctx.db.managed_feeds().await? {
|
for m in ctx.db.managed_feeds().await? {
|
||||||
if cfg.feeds.contains_key(&m.id) {
|
if cfg.feeds.contains_key(&m.id) {
|
||||||
continue; // promoted to config at some point; that entry wins
|
continue; // promoted to config at some point; that entry wins
|
||||||
@@ -1234,7 +1335,13 @@ pub async fn subscriptions(ctx: &Ctx) -> Result<Vec<Sub>> {
|
|||||||
}
|
}
|
||||||
let base = match parent.and_then(|p| p.folder.clone()) {
|
let base = match parent.and_then(|p| p.folder.clone()) {
|
||||||
Some(folder) => folder,
|
Some(folder) => folder,
|
||||||
None => ctx.db.feed_summary(&m.group_id).await.ok().and_then(|s| s.title).unwrap_or_else(|| m.group_id.clone()),
|
None => {
|
||||||
|
if !group_titles.contains_key(&m.group_id) {
|
||||||
|
let title = ctx.db.feed_summary(&m.group_id).await.ok().and_then(|s| s.title);
|
||||||
|
group_titles.insert(m.group_id.clone(), title.unwrap_or_else(|| m.group_id.clone()));
|
||||||
|
}
|
||||||
|
group_titles[&m.group_id].clone()
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let title = m.title.clone().unwrap_or_else(|| m.id.clone());
|
let title = m.title.clone().unwrap_or_else(|| m.id.clone());
|
||||||
out.push(Sub {
|
out.push(Sub {
|
||||||
@@ -1262,6 +1369,50 @@ pub async fn subscriptions(ctx: &Ctx) -> Result<Vec<Sub>> {
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Takes a feed off one person's list, and the feeds in it if it is a group. Everyone else's
|
||||||
|
/// lists are left alone.
|
||||||
|
pub async fn leave(ctx: &Ctx, user_id: i64, id: &str) -> Result<()> {
|
||||||
|
ctx.db.unsubscribe(user_id, id).await?;
|
||||||
|
for child in subscriptions(ctx).await?.iter().filter(|s| s.cfg.group.as_deref() == Some(id)) {
|
||||||
|
ctx.db.unsubscribe(user_id, &child.id).await?;
|
||||||
|
}
|
||||||
|
if ctx.db.subscriber_counts().await?.contains_key(id) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nobody is left: the feed stops being scanned. Its files and history stay, so if
|
||||||
|
// someone subscribes again they do not pull the back catalogue a second time. A feed an
|
||||||
|
// admin listed stays in the Directory, for the next person.
|
||||||
|
let mut cfg = ctx.edit_cfg().await;
|
||||||
|
if cfg.feeds.get(id).is_some_and(|f| f.listed) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if cfg.feeds.remove(id).is_none() {
|
||||||
|
// A derived feed: forget it here, though the OPML will list it again on the next
|
||||||
|
// read unless you unsubscribe from the OPML itself.
|
||||||
|
return ctx.db.drop_managed(id).await;
|
||||||
|
}
|
||||||
|
ctx.store_cfg(cfg).await?;
|
||||||
|
retire_group(ctx, id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes an account, leaving each of its feeds first as unsubscribing does. The foreign keys
|
||||||
|
/// took the subscriptions with the account, but not the feed: one only they read stayed in the
|
||||||
|
/// catalogue with nobody subscribed, downloading every new episode for no one (#157).
|
||||||
|
pub async fn remove_user(ctx: &Ctx, user_id: i64) -> Result<()> {
|
||||||
|
let catalogue = subscriptions(ctx).await?;
|
||||||
|
let mine: std::collections::HashSet<String> =
|
||||||
|
ctx.db.subscriptions_for(user_id).await?.into_iter().map(|s| s.feed_id).collect();
|
||||||
|
for id in &mine {
|
||||||
|
// A feed in a group of theirs goes with the group.
|
||||||
|
let in_theirs = catalogue.iter().any(|s| &s.id == id && s.cfg.group.as_ref().is_some_and(|g| mine.contains(g)));
|
||||||
|
if !in_theirs {
|
||||||
|
leave(ctx, user_id, id).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.db.delete_user(user_id).await
|
||||||
|
}
|
||||||
|
|
||||||
/// Retires every feed derived from `parent_id`, now that nothing subscribes to the OPML or
|
/// Retires every feed derived from `parent_id`, now that nothing subscribes to the OPML or
|
||||||
/// 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
|
||||||
@@ -1329,7 +1480,9 @@ async fn until_next_scan(ctx: &Ctx) -> std::time::Duration {
|
|||||||
.iter()
|
.iter()
|
||||||
// A feed with no row yet has never been checked: due now.
|
// A feed with no row yet has never been checked: due now.
|
||||||
.map(|s| {
|
.map(|s| {
|
||||||
states.get(&s.id).and_then(|st| due_at(&cfg, &s.cfg, st, subscribed.contains_key(&s.id))).unwrap_or(0)
|
let never = db::HttpState::default();
|
||||||
|
let state = states.get(&s.id).unwrap_or(&never);
|
||||||
|
ctx.due(&cfg, &s.cfg, &s.id, state, subscribed.contains_key(&s.id)).unwrap_or(0)
|
||||||
})
|
})
|
||||||
.min(),
|
.min(),
|
||||||
)
|
)
|
||||||
@@ -1406,7 +1559,7 @@ async fn follow_move(ctx: &Ctx, id: &str, to: &str) -> Result<()> {
|
|||||||
tracing::info!(feed = id, to, "the feed moved to an address another feed already has; leaving it");
|
tracing::info!(feed = id, to, "the feed moved to an address another feed already has; leaving it");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let mut cfg = (*ctx.cfg()).clone();
|
let mut cfg = ctx.edit_cfg().await;
|
||||||
let Some(f) = cfg.feeds.get_mut(id) else { return Ok(()) };
|
let Some(f) = cfg.feeds.get_mut(id) else { return Ok(()) };
|
||||||
let from = std::mem::replace(&mut f.url, to.to_owned());
|
let from = std::mem::replace(&mut f.url, to.to_owned());
|
||||||
ctx.store_cfg(cfg).await?;
|
ctx.store_cfg(cfg).await?;
|
||||||
@@ -1573,7 +1726,7 @@ async fn scan_one(
|
|||||||
{
|
{
|
||||||
match feed::classify(&ctx.client, &key, &parsed).await {
|
match feed::classify(&ctx.client, &key, &parsed).await {
|
||||||
Ok(category) => {
|
Ok(category) => {
|
||||||
let mut cfg = (*ctx.cfg()).clone();
|
let mut cfg = ctx.edit_cfg().await;
|
||||||
if let Some(f) = cfg.feeds.get_mut(id).filter(|f| f.category.is_none()) {
|
if let Some(f) = cfg.feeds.get_mut(id).filter(|f| f.category.is_none()) {
|
||||||
tracing::info!(feed = id, category = %category, "categorised");
|
tracing::info!(feed = id, category = %category, "categorised");
|
||||||
f.category = Some(category);
|
f.category = Some(category);
|
||||||
@@ -1598,13 +1751,17 @@ async fn scan_one(
|
|||||||
// changed nothing however often the feed was scanned.
|
// changed nothing however often the feed was scanned.
|
||||||
let skipped = ctx.db.skipped_by_filter(id).await?;
|
let skipped = ctx.db.skipped_by_filter(id).await?;
|
||||||
let (known_items, known_files) = ctx.db.stored_items(id).await?;
|
let (known_items, known_files) = ctx.db.stored_items(id).await?;
|
||||||
|
let changed = ctx.db.changed_items(id, &parsed.entries).await?;
|
||||||
let mut scan = Scan::default();
|
let mut scan = Scan::default();
|
||||||
// Its own span: the time a feed spends after its fetch was untraced (#96).
|
// Its own span: the time a feed spends after its fetch was untraced (#96).
|
||||||
let store = tracing::info_span!("store", items = parsed.entries.len());
|
let store = tracing::info_span!("store", items = parsed.entries.len());
|
||||||
tracing::Instrument::instrument(async {
|
tracing::Instrument::instrument(async {
|
||||||
for entry in &parsed.entries {
|
for entry in &parsed.entries {
|
||||||
// Only what is not stored yet is inserted; the insert would find the rest and do nothing.
|
// What is not stored yet is inserted, and what the feed has changed since is written
|
||||||
if !known_items.contains(&entry.guid) && ctx.db.record_entry(id, entry).await? {
|
// again (#141); the rest is left alone. A correction keeps the item's read state.
|
||||||
|
if (!known_items.contains(&entry.guid) || changed.contains(&entry.guid))
|
||||||
|
&& ctx.db.record_entry(id, entry).await?
|
||||||
|
{
|
||||||
scan.new_entries += 1;
|
scan.new_entries += 1;
|
||||||
}
|
}
|
||||||
for enc in &entry.enclosures {
|
for enc in &entry.enclosures {
|
||||||
@@ -1685,7 +1842,8 @@ async fn scan_one(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
match fetch_one(ctx, id, item.id, feed_cfg, &item.url, &dest_dir).await {
|
match fetch_one(ctx, id, item.id, feed_cfg, &item.url, &dest_dir).await {
|
||||||
Ok((path, bytes)) => {
|
Ok(None) => scan.torrents += 1,
|
||||||
|
Ok(Some((path, bytes))) => {
|
||||||
ctx.out.emit(Event::DownloadDone {
|
ctx.out.emit(Event::DownloadDone {
|
||||||
feed: id.to_string(),
|
feed: id.to_string(),
|
||||||
enclosure: item.id,
|
enclosure: item.id,
|
||||||
@@ -1883,7 +2041,12 @@ async fn policy_for(ctx: &Ctx, id: &str, feed_cfg: &config::Feed) -> Result<Poli
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn merge_policy(subs: &[db::Sub], feed_cfg: &config::Feed, global: usize) -> Policy {
|
fn merge_policy(subs: &[db::Sub], feed_cfg: &config::Feed, global: usize) -> Policy {
|
||||||
let cap = |n: Option<usize>| n.unwrap_or(if global == 0 { usize::MAX } else { global });
|
// 0 is every item, the whole back catalogue, wherever it is set: the server's default, a
|
||||||
|
// feed's or one person's. Only the server's was; a feed set to 0 downloaded nothing (#166).
|
||||||
|
let cap = |n: Option<usize>| match n.unwrap_or(global) {
|
||||||
|
0 => usize::MAX,
|
||||||
|
n => n,
|
||||||
|
};
|
||||||
|
|
||||||
if subs.is_empty() {
|
if subs.is_empty() {
|
||||||
return Policy {
|
return Policy {
|
||||||
@@ -1920,6 +2083,8 @@ fn merge_policy(subs: &[db::Sub], feed_cfg: &config::Feed, global: usize) -> Pol
|
|||||||
policy
|
policy
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Downloads one file and records it. None when it turned out to be a torrent, handed off to run
|
||||||
|
/// on its own, which records it when it finishes.
|
||||||
#[tracing::instrument(name = "download", skip_all, fields(feed = feed_id, enclosure = enclosure, url = url))]
|
#[tracing::instrument(name = "download", skip_all, fields(feed = feed_id, enclosure = enclosure, url = url))]
|
||||||
async fn fetch_one(
|
async fn fetch_one(
|
||||||
ctx: &Arc<Ctx>,
|
ctx: &Arc<Ctx>,
|
||||||
@@ -1928,7 +2093,7 @@ async fn fetch_one(
|
|||||||
feed_cfg: &config::Feed,
|
feed_cfg: &config::Feed,
|
||||||
url: &str,
|
url: &str,
|
||||||
dest_dir: &std::path::Path,
|
dest_dir: &std::path::Path,
|
||||||
) -> Result<(PathBuf, u64)> {
|
) -> Result<Option<(PathBuf, u64)>> {
|
||||||
// Throttled to whole percents, as the original's lastDLStepSize guard did.
|
// Throttled to whole percents, as the original's lastDLStepSize guard did.
|
||||||
let mut last_pct = -1i64;
|
let mut last_pct = -1i64;
|
||||||
let name = download::filename_for(url, None);
|
let name = download::filename_for(url, None);
|
||||||
@@ -1959,12 +2124,22 @@ async fn fetch_one(
|
|||||||
ctx.db.mark_enclosure(url, "skipped", Some("torrents disabled")).await?;
|
ctx.db.mark_enclosure(url, "skipped", Some("torrents disabled")).await?;
|
||||||
anyhow::bail!("body is a torrent and torrents are disabled");
|
anyhow::bail!("body is a torrent and torrents are disabled");
|
||||||
}
|
}
|
||||||
return torrent_one(ctx, feed_id, enclosure, url, dest_dir).await;
|
// As one advertised as a torrent goes: off the worker where it can be, and recorded either
|
||||||
|
// way. Run here and never recorded, it stayed pending, and every scan of its feed ran it
|
||||||
|
// again, on the worker, for up to the stall limit and the seeding time (#155).
|
||||||
|
if ctx.detach_torrents {
|
||||||
|
ctx.db.mark_enclosure(url, "downloading", None).await?;
|
||||||
|
spawn_torrent(ctx, feed_id.to_owned(), enclosure, url.to_owned(), dest_dir.to_owned());
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let (path, bytes) = torrent_one(ctx, feed_id, enclosure, url, dest_dir).await?;
|
||||||
|
ctx.db.mark_downloaded(url, &path, bytes).await?;
|
||||||
|
return Ok(Some((path, bytes)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let path = download::place(&got, dest_dir).await?;
|
let path = download::place(&got, dest_dir).await?;
|
||||||
ctx.db.mark_downloaded(url, &path, got.bytes).await?;
|
ctx.db.mark_downloaded(url, &path, got.bytes).await?;
|
||||||
Ok((path, got.bytes))
|
Ok(Some((path, got.bytes)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs a torrent off the command worker, so feed scans and HTTP downloads keep moving
|
/// Runs a torrent off the command worker, so feed scans and HTTP downloads keep moving
|
||||||
@@ -2038,14 +2213,15 @@ async fn download_one(ctx: &Arc<Ctx>, id: i64) -> Result<()> {
|
|||||||
if !cfg.torrent.enabled {
|
if !cfg.torrent.enabled {
|
||||||
Err(anyhow::anyhow!("torrents are disabled"))
|
Err(anyhow::anyhow!("torrents are disabled"))
|
||||||
} else {
|
} else {
|
||||||
torrent_one(ctx, &enc.feed_id, enc.id, &enc.url, &dest_dir).await
|
torrent_one(ctx, &enc.feed_id, enc.id, &enc.url, &dest_dir).await.map(Some)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fetch_one(ctx, &enc.feed_id, enc.id, feed_cfg, &enc.url, &dest_dir).await
|
fetch_one(ctx, &enc.feed_id, enc.id, feed_cfg, &enc.url, &dest_dir).await
|
||||||
};
|
};
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok((path, bytes)) => {
|
Ok(None) => {} // A torrent after all, running on its own.
|
||||||
|
Ok(Some((path, bytes))) => {
|
||||||
ctx.db.mark_downloaded(&enc.url, &path, bytes).await?;
|
ctx.db.mark_downloaded(&enc.url, &path, bytes).await?;
|
||||||
ctx.out.emit(Event::DownloadDone {
|
ctx.out.emit(Event::DownloadDone {
|
||||||
feed: enc.feed_id.clone(),
|
feed: enc.feed_id.clone(),
|
||||||
@@ -2247,6 +2423,11 @@ mod tests {
|
|||||||
assert!(!p.wanted(&["python"], &[]));
|
assert!(!p.wanted(&["python"], &[]));
|
||||||
assert_eq!(p.budget, 9);
|
assert_eq!(p.budget, 9);
|
||||||
|
|
||||||
|
// 0 is the whole back catalogue, set for the server, for the feed or by one person (#166).
|
||||||
|
assert_eq!(merge_policy(&[], &feed(), 0).budget, usize::MAX);
|
||||||
|
assert_eq!(merge_policy(&[], &config::Feed { max_new_per_check: Some(0), ..feed() }, 3).budget, usize::MAX);
|
||||||
|
assert_eq!(merge_policy(&[sub(None, None, Some(0)), sub(None, None, Some(2))], &feed(), 3).budget, usize::MAX);
|
||||||
|
|
||||||
// One person taking everything removes the filter for the shared copy.
|
// One person taking everything removes the filter for the shared copy.
|
||||||
let p = merge_policy(&[sub(Some(&["rust"]), None, None), sub(Some(&[]), None, None)], &feed(), 3);
|
let p = merge_policy(&[sub(Some(&["rust"]), None, None), sub(Some(&[]), None, None)], &feed(), 3);
|
||||||
assert!(p.wanted(&["python"], &[]));
|
assert!(p.wanted(&["python"], &[]));
|
||||||
@@ -2279,9 +2460,127 @@ mod tests {
|
|||||||
torrent_slots: Arc::new(tokio::sync::Semaphore::new(2)),
|
torrent_slots: Arc::new(tokio::sync::Semaphore::new(2)),
|
||||||
config_path: PathBuf::new(),
|
config_path: PathBuf::new(),
|
||||||
detach_torrents: false,
|
detach_torrents: false,
|
||||||
|
retry: Default::default(),
|
||||||
|
cfg_write: Default::default(),
|
||||||
|
deadair: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_removed_account_takes_the_feeds_only_it_read_with_it() {
|
||||||
|
let ctx = test_ctx(config::Config::default()).await;
|
||||||
|
let mut cfg = ctx.edit_cfg().await;
|
||||||
|
for id in ["only-theirs", "shared", "listed"] {
|
||||||
|
cfg.feeds.insert(id.into(), config::Feed { listed: id == "listed", ..feed() });
|
||||||
|
}
|
||||||
|
ctx.store_cfg(cfg).await.unwrap();
|
||||||
|
let gone = ctx.db.create_user("gone", None, false).await.unwrap();
|
||||||
|
let stays = ctx.db.create_user("stays", None, true).await.unwrap();
|
||||||
|
for id in ["only-theirs", "shared", "listed"] {
|
||||||
|
ctx.db.subscribe(gone, id).await.unwrap();
|
||||||
|
}
|
||||||
|
ctx.db.subscribe(stays, "shared").await.unwrap();
|
||||||
|
|
||||||
|
remove_user(&ctx, gone).await.unwrap();
|
||||||
|
assert!(ctx.db.user_by_id(gone).await.unwrap().is_none());
|
||||||
|
// Nobody reads it now: out of the catalogue, not downloading for no one (#157).
|
||||||
|
assert_eq!(ctx.cfg().feeds.keys().collect::<Vec<_>>(), ["listed", "shared"]);
|
||||||
|
assert!(ctx.db.subscription(stays, "shared").await.unwrap().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn deadair_hands_out_the_copys_clock_and_saves_in_the_originals() {
|
||||||
|
let ctx = test_ctx(config::Config::default()).await;
|
||||||
|
let db = &ctx.db;
|
||||||
|
let u = db.create_user("dee", None, false).await.unwrap();
|
||||||
|
db.subscribe(u, "f").await.unwrap();
|
||||||
|
for sql in [
|
||||||
|
"INSERT INTO entries (feed_id, guid, first_seen, duration) VALUES ('f', 'g', 1, 600)",
|
||||||
|
"INSERT INTO enclosures (id, feed_id, guid, url, path, state, downloaded_at)
|
||||||
|
VALUES (7, 'f', 'g', 'http://x/a.mp3', '/x/a.mp3', 'done', 100)",
|
||||||
|
] {
|
||||||
|
db.sql(sql).await.unwrap();
|
||||||
|
}
|
||||||
|
// Two minutes cut, both before the place saved.
|
||||||
|
db.store_deadair(7, Some("/x/a.deadair.mp3"), "[[60000,120000],[200000,260000]]", 120_000).await.unwrap();
|
||||||
|
db.set_position(u, "f", "g", 300, None).await.unwrap();
|
||||||
|
let order = db::order_sql("published", "desc");
|
||||||
|
let page = || {
|
||||||
|
let (order, ctx) = (order.clone(), &ctx);
|
||||||
|
async move {
|
||||||
|
let mut rows = ctx.db.entries_in(u, Some("f"), db::Filter::All, None, 0, 10, &order).await.unwrap();
|
||||||
|
deadair::for_reader(ctx, u, &mut rows).await.unwrap();
|
||||||
|
rows.remove(0)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let off = page().await;
|
||||||
|
assert_eq!((off.position, off.enclosures[0].deadair), (300, None), "off until turned on for the feed");
|
||||||
|
|
||||||
|
db.set_deadair(u, "f", Some(true)).await.unwrap();
|
||||||
|
let on = page().await;
|
||||||
|
assert_eq!((on.position, on.duration, on.enclosures[0].deadair), (180, Some(480), Some(120)));
|
||||||
|
|
||||||
|
// Saved from the copy at 3:20 of its 8:00: 5:20 of the original's 10:00, short of 75%.
|
||||||
|
let playing = deadair::Playing::default();
|
||||||
|
let copy = db.copies(&[7]).await.unwrap().remove(0);
|
||||||
|
playing.lock().unwrap().insert((u, "f".into(), "g".into()), Arc::new(copy));
|
||||||
|
let saved = deadair::on_save(&ctx, &playing, u, "f", "g", 200, Some(480)).await.unwrap();
|
||||||
|
assert_eq!(saved, (320, Some(600)));
|
||||||
|
assert_eq!(db.deadair_total(u).await.unwrap(), 0);
|
||||||
|
// Past 75% (6:00 of the copy is 8:00 of the original), counted once.
|
||||||
|
for _ in 0..2 {
|
||||||
|
deadair::on_save(&ctx, &playing, u, "f", "g", 360, None).await.unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(db.deadair_total(u).await.unwrap(), 120_000);
|
||||||
|
// Playing the original again: saved as it is.
|
||||||
|
playing.lock().unwrap().clear();
|
||||||
|
assert_eq!(deadair::on_save(&ctx, &playing, u, "f", "g", 200, Some(600)).await.unwrap(), (200, Some(600)));
|
||||||
|
|
||||||
|
// Turned off again: the original's clock.
|
||||||
|
db.set_deadair(u, "f", Some(false)).await.unwrap();
|
||||||
|
assert_eq!(page().await.enclosures[0].deadair, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn two_changes_to_the_catalogue_at_once_both_keep() {
|
||||||
|
let ctx = Arc::new(test_ctx(config::Config::default()).await);
|
||||||
|
// A slow change, as adding a feed is while it fetches, and a quick one made meanwhile.
|
||||||
|
let slow = {
|
||||||
|
let ctx = ctx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut cfg = ctx.edit_cfg().await;
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||||
|
cfg.feeds.insert("a".into(), feed());
|
||||||
|
ctx.store_cfg(cfg).await.unwrap();
|
||||||
|
})
|
||||||
|
};
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||||
|
let mut cfg = ctx.edit_cfg().await;
|
||||||
|
cfg.feeds.insert("b".into(), feed());
|
||||||
|
ctx.store_cfg(cfg).await.unwrap();
|
||||||
|
slow.await.unwrap();
|
||||||
|
assert_eq!(ctx.cfg().feeds.keys().collect::<Vec<_>>(), ["a", "b"], "neither stored over the other (#153)");
|
||||||
|
let (_, stored) = ctx.db.stored_config().await.unwrap().unwrap();
|
||||||
|
assert_eq!(stored.keys().collect::<Vec<_>>(), ["a", "b"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_moments_lookup_failure_is_tried_again_soon_and_not_held_against_the_feed() {
|
||||||
|
let ctx = test_ctx(config::Config::default()).await;
|
||||||
|
let blip = "connecting: dns error: failed to lookup address information: Temporary failure in name resolution";
|
||||||
|
ctx.feed_failed("npr", "https://feeds.npr.org/x", blip).await.unwrap();
|
||||||
|
// Nothing on the feed: no error, no run of failures to back off from or call it gone by.
|
||||||
|
let s = ctx.db.feed_summary("npr").await.unwrap();
|
||||||
|
assert!(s.last_error.is_none() && s.error_since.is_none(), "{:?}", s.last_error);
|
||||||
|
// Due again in minutes, though a feed nobody subscribes to is otherwise read once a day.
|
||||||
|
let state = db::HttpState { last_checked: Some(db::now() - 2 * 86_400), ..Default::default() };
|
||||||
|
let due = ctx.due(&config::Config::default(), &feed(), "npr", &state, false).unwrap();
|
||||||
|
assert!((due - db::now() - RETRY_LOOKUP_SECS).abs() <= 2, "{due}");
|
||||||
|
// A real failure is the feed's.
|
||||||
|
ctx.feed_failed("gone", "https://x/feed", "HTTP 404 Not Found").await.unwrap();
|
||||||
|
assert_eq!(ctx.db.feed_summary("gone").await.unwrap().last_error.as_deref(), Some("HTTP 404 Not Found"));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a_reload_takes_the_catalogue_another_ipx_wrote() {
|
async fn a_reload_takes_the_catalogue_another_ipx_wrote() {
|
||||||
let with = |id: &str| {
|
let with = |id: &str| {
|
||||||
@@ -2299,7 +2598,8 @@ mod tests {
|
|||||||
assert_eq!(urls, vec!["http://x/added-by-the-cli.xml".to_string()]);
|
assert_eq!(urls, vec!["http://x/added-by-the-cli.xml".to_string()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Answers /empty with a 200 and nothing, anything else with a feed of one item.
|
/// Answers /empty with a 200 and nothing, /fixed with a feed of one item as it should read,
|
||||||
|
/// anything else with the same item's text broken.
|
||||||
async fn empty_or_feed_server() -> String {
|
async fn empty_or_feed_server() -> String {
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
@@ -2310,9 +2610,13 @@ mod tests {
|
|||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut buf = [0u8; 2048];
|
let mut buf = [0u8; 2048];
|
||||||
let n = sock.read(&mut buf).await.unwrap_or(0);
|
let n = sock.read(&mut buf).await.unwrap_or(0);
|
||||||
let empty = String::from_utf8_lossy(&buf[..n]).starts_with("GET /empty");
|
let req = String::from_utf8_lossy(&buf[..n]).into_owned();
|
||||||
let body = if empty { "\n" } else {
|
let body = if req.starts_with("GET /empty") {
|
||||||
"<?xml version=\"1.0\"?><rss version=\"2.0\"><channel><title>T</title><item><title>One</title><guid>g1</guid></item></channel></rss>"
|
"\n"
|
||||||
|
} else if req.starts_with("GET /fixed") {
|
||||||
|
"<?xml version=\"1.0\"?><rss version=\"2.0\"><channel><title>T</title><item><title>One</title><guid>g1</guid><description>As it should read.</description></item></channel></rss>"
|
||||||
|
} else {
|
||||||
|
"<?xml version=\"1.0\"?><rss version=\"2.0\"><channel><title>T</title><item><title>One</title><guid>g1</guid><description>Somebody else's text.</description></item></channel></rss>"
|
||||||
};
|
};
|
||||||
let resp = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{body}", body.len());
|
let resp = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{body}", body.len());
|
||||||
let _ = sock.write_all(resp.as_bytes()).await;
|
let _ = sock.write_all(resp.as_bytes()).await;
|
||||||
@@ -2322,6 +2626,20 @@ mod tests {
|
|||||||
format!("http://{addr}")
|
format!("http://{addr}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_publishers_correction_reaches_an_item_already_stored() {
|
||||||
|
let base = empty_or_feed_server().await;
|
||||||
|
let ctx = Arc::new(test_ctx(config::Config::default()).await);
|
||||||
|
let at = |path: &str| config::Feed { url: format!("{base}{path}"), ..feed() };
|
||||||
|
let none = db::HttpState::default();
|
||||||
|
let text = || ctx.db.strings_for_test("SELECT description FROM entries WHERE feed_id = 'aicn'");
|
||||||
|
scan_one(&ctx, "aicn", &at("/feed"), &none, false, None).await.unwrap();
|
||||||
|
assert_eq!(text().await, ["Somebody else's text."]);
|
||||||
|
// The feed fixed, the item already stored: it was left as it was (#141).
|
||||||
|
scan_one(&ctx, "aicn", &at("/fixed"), &none, false, None).await.unwrap();
|
||||||
|
assert_eq!(text().await, ["As it should read."]);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn nothing_from_a_feed_that_never_posted_is_an_error_and_from_one_that_has_is_not() {
|
async fn nothing_from_a_feed_that_never_posted_is_an_error_and_from_one_that_has_is_not() {
|
||||||
let base = empty_or_feed_server().await;
|
let base = empty_or_feed_server().await;
|
||||||
|
|||||||
@@ -61,7 +61,11 @@ pub async fn run(cfg: &Config, db: &Db, dry_run: bool) -> Result<Report> {
|
|||||||
tracing::info!(feed = id, "forgot a failing feed nobody subscribes to");
|
tracing::info!(feed = id, "forgot a failing feed nobody subscribes to");
|
||||||
}
|
}
|
||||||
|
|
||||||
let candidates = db.reap_candidates().await?;
|
let mut candidates = db.reap_candidates().await?;
|
||||||
|
// A file's DeadAir copy is on the disk too, and goes when it goes (#171).
|
||||||
|
for c in &mut candidates {
|
||||||
|
c.bytes += copy_bytes(&c.path) as i64;
|
||||||
|
}
|
||||||
|
|
||||||
if cfg.general.max_age_days > 0 {
|
if cfg.general.max_age_days > 0 {
|
||||||
let cutoff = now() - (cfg.general.max_age_days * 86_400) as i64;
|
let cutoff = now() - (cfg.general.max_age_days * 86_400) as i64;
|
||||||
@@ -96,9 +100,13 @@ async fn remove(db: &Db, c: &Candidate, dry_run: bool) -> Result<u64> {
|
|||||||
if dry_run {
|
if dry_run {
|
||||||
return Ok(c.bytes.max(0) as u64);
|
return Ok(c.bytes.max(0) as u64);
|
||||||
}
|
}
|
||||||
let size = std::fs::metadata(&c.path).map(|m| m.len()).unwrap_or(c.bytes.max(0) as u64);
|
// A torrent's folder measures as a directory entry, not what is in it: the recorded size then.
|
||||||
|
let size = std::fs::metadata(&c.path)
|
||||||
|
.ok()
|
||||||
|
.filter(|m| m.is_file())
|
||||||
|
.map_or(c.bytes.max(0) as u64, |m| m.len() + copy_bytes(&c.path));
|
||||||
// Missing is fine: the point is that it is gone.
|
// Missing is fine: the point is that it is gone.
|
||||||
if let Err(e) = std::fs::remove_file(&c.path)
|
if let Err(e) = crate::download::remove(std::path::Path::new(&c.path))
|
||||||
&& e.kind() != std::io::ErrorKind::NotFound
|
&& e.kind() != std::io::ErrorKind::NotFound
|
||||||
{
|
{
|
||||||
tracing::warn!(path = c.path, error = %e, "could not delete");
|
tracing::warn!(path = c.path, error = %e, "could not delete");
|
||||||
@@ -108,6 +116,10 @@ async fn remove(db: &Db, c: &Candidate, dry_run: bool) -> Result<u64> {
|
|||||||
Ok(size)
|
Ok(size)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn copy_bytes(path: &str) -> u64 {
|
||||||
|
std::fs::metadata(crate::deadair::copy_path(std::path::Path::new(path))).map_or(0, |m| m.len())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -189,11 +201,36 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_marks_of_someone_who_left_count_for_nothing() {
|
||||||
|
// ray and sam subscribe; left read one episode and pinned the others before unsubscribing.
|
||||||
|
let db = Db::memory().await.unwrap();
|
||||||
|
db.exec_for_test(
|
||||||
|
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',true),(2,'sam',false),(3,'left',false);
|
||||||
|
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'),(2,'f');
|
||||||
|
INSERT INTO entries (feed_id, guid, first_seen) VALUES
|
||||||
|
('f', 'heard', 0), ('f', 'pinned', 0), ('f', 'old', 0);
|
||||||
|
INSERT INTO entry_state (user_id, feed_id, guid, read, flagged) VALUES
|
||||||
|
(3, 'f', 'heard', true, false),
|
||||||
|
(1, 'f', 'heard', true, false),
|
||||||
|
(3, 'f', 'pinned', false, true),
|
||||||
|
(3, 'f', 'old', false, true);
|
||||||
|
INSERT INTO enclosures (id, feed_id, guid, url, path, bytes_done, state, downloaded_at) VALUES
|
||||||
|
(1, 'f', 'heard', 'u1', '/tmp/heard', 10, 'done', 10),
|
||||||
|
(2, 'f', 'pinned', 'u2', '/tmp/pinned', 10, 'done', 20);",
|
||||||
|
).await
|
||||||
|
.unwrap();
|
||||||
|
let got: Vec<(i64, bool)> = db.reap_candidates().await.unwrap().iter().map(|c| (c.id, c.read)).collect();
|
||||||
|
assert_eq!(got, vec![(1, false), (2, false)], "sam has not heard it, and nobody here pinned it (#156)");
|
||||||
|
assert_eq!(db.prune_entries(500).await.unwrap(), 1, "the old item goes, a leaver's pin or not");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn prune_keeps_entries_that_still_have_a_file() {
|
async fn prune_keeps_entries_that_still_have_a_file() {
|
||||||
let db = Db::memory().await.unwrap();
|
let db = Db::memory().await.unwrap();
|
||||||
db.exec_for_test(
|
db.exec_for_test(
|
||||||
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',true);
|
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',true);
|
||||||
|
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f');
|
||||||
INSERT INTO entry_state (user_id, feed_id, guid, flagged) VALUES (1,'f','flagged',true);
|
INSERT INTO entry_state (user_id, feed_id, guid, flagged) VALUES (1,'f','flagged',true);
|
||||||
INSERT INTO entries (feed_id, guid, first_seen) VALUES
|
INSERT INTO entries (feed_id, guid, first_seen) VALUES
|
||||||
('f', 'has-file', 100),
|
('f', 'has-file', 100),
|
||||||
|
|||||||
270
src/web.rs
270
src/web.rs
@@ -32,6 +32,7 @@ pub struct WebState {
|
|||||||
pub events: broadcast::Sender<Event>,
|
pub events: broadcast::Sender<Event>,
|
||||||
/// Cloudflare Access's signing keys, fetched once and kept.
|
/// Cloudflare Access's signing keys, fetched once and kept.
|
||||||
pub access: Arc<crate::access::Keys>,
|
pub access: Arc<crate::access::Keys>,
|
||||||
|
pub playing: crate::deadair::Playing,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router(state: WebState) -> Router {
|
pub fn router(state: WebState) -> Router {
|
||||||
@@ -45,6 +46,7 @@ pub fn router(state: WebState) -> Router {
|
|||||||
.route("/api/feeds/{id}", patch(patch_feed).delete(remove_feed))
|
.route("/api/feeds/{id}", patch(patch_feed).delete(remove_feed))
|
||||||
.route("/api/feeds/{id}/entries", get(entries))
|
.route("/api/feeds/{id}/entries", get(entries))
|
||||||
.route("/api/entries", get(all_entries))
|
.route("/api/entries", get(all_entries))
|
||||||
|
.route("/api/entries/{feed_id}/{guid}", get(one_entry))
|
||||||
.route("/api/entries/{feed_id}/{guid}/flags", post(set_flags))
|
.route("/api/entries/{feed_id}/{guid}/flags", post(set_flags))
|
||||||
.route("/api/entries/{feed_id}/{guid}/position", post(set_position))
|
.route("/api/entries/{feed_id}/{guid}/position", post(set_position))
|
||||||
.route("/api/feeds/{id}/read-all", post(read_all))
|
.route("/api/feeds/{id}/read-all", post(read_all))
|
||||||
@@ -67,6 +69,7 @@ pub fn router(state: WebState) -> Router {
|
|||||||
.route("/admin", get(admin_page))
|
.route("/admin", get(admin_page))
|
||||||
.route("/admin.js", get(admin_js))
|
.route("/admin.js", get(admin_js))
|
||||||
.route("/media/{id}", get(media))
|
.route("/media/{id}", get(media))
|
||||||
|
.route("/media/{id}/deadair", get(media_deadair))
|
||||||
.route("/api/art", get(art))
|
.route("/api/art", get(art))
|
||||||
.layer(middleware::from_fn_with_state(state.clone(), auth))
|
.layer(middleware::from_fn_with_state(state.clone(), auth))
|
||||||
// Signing in cannot require being signed in, so these sit outside the auth layer.
|
// Signing in cannot require being signed in, so these sit outside the auth layer.
|
||||||
@@ -91,9 +94,17 @@ pub fn router(state: WebState) -> Router {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn serve(state: WebState, bind: &str) -> Result<()> {
|
pub async fn serve(state: WebState, bind: &str) -> Result<()> {
|
||||||
|
use axum::serve::ListenerExt;
|
||||||
|
// Nagle off, as axum 0.7 had it: under load a response goes out in parts, and with Nagle
|
||||||
|
// on its last small part waits for the browser's delayed ACK, up to 40 ms (#170).
|
||||||
let listener = tokio::net::TcpListener::bind(bind)
|
let listener = tokio::net::TcpListener::bind(bind)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("binding {bind}"))?;
|
.with_context(|| format!("binding {bind}"))?
|
||||||
|
.tap_io(|tcp| {
|
||||||
|
if let Err(e) = tcp.set_nodelay(true) {
|
||||||
|
tracing::debug!(error = %e, "could not set TCP_NODELAY");
|
||||||
|
}
|
||||||
|
});
|
||||||
tracing::info!(bind, "web ui listening");
|
tracing::info!(bind, "web ui listening");
|
||||||
axum::serve(
|
axum::serve(
|
||||||
listener,
|
listener,
|
||||||
@@ -365,8 +376,10 @@ async fn me(
|
|||||||
let url = state.ctx.cfg().web.sign_out_url.clone();
|
let url = state.ctx.cfg().web.sign_out_url.clone();
|
||||||
let sign_out = (by_proxy && !url.is_empty()).then_some(url);
|
let sign_out = (by_proxy && !url.is_empty()).then_some(url);
|
||||||
let blocked = state.ctx.db.blocklist(user.id, "").await.unwrap_or_default();
|
let blocked = state.ctx.db.blocklist(user.id, "").await.unwrap_or_default();
|
||||||
|
let saved = state.ctx.db.deadair_total(user.id).await.unwrap_or(0) / 1000;
|
||||||
Json(serde_json::json!({
|
Json(serde_json::json!({
|
||||||
"name": user.name, "admin": user.is_admin, "sign_out": sign_out, "blocked": blocked,
|
"name": user.name, "admin": user.is_admin, "sign_out": sign_out, "blocked": blocked,
|
||||||
|
"deadair_saved": saved,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -558,7 +571,7 @@ async fn remove_user(
|
|||||||
target.name
|
target.name
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
state.ctx.db.delete_user(id).await?;
|
crate::remove_user(&state.ctx, id).await?;
|
||||||
tracing::info!(by = %user.name, user = %target.name, "account removed");
|
tracing::info!(by = %user.name, user = %target.name, "account removed");
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
@@ -760,6 +773,8 @@ struct FeedRow {
|
|||||||
keywords: Vec<String>,
|
keywords: Vec<String>,
|
||||||
/// Your words that hide an item of this feed, beside your list for every feed.
|
/// Your words that hide an item of this feed, beside your list for every feed.
|
||||||
blocked: Vec<String>,
|
blocked: Vec<String>,
|
||||||
|
/// Played from DeadAir's copies: on for this feed or its group, off until you turn it on.
|
||||||
|
deadair: bool,
|
||||||
allow_explicit: bool,
|
allow_explicit: bool,
|
||||||
auto_download: bool,
|
auto_download: bool,
|
||||||
max_new_per_check: Option<usize>,
|
max_new_per_check: Option<usize>,
|
||||||
@@ -807,20 +822,26 @@ async fn feeds(
|
|||||||
/// The feed list as this person sees it, or with `only` the one row for that feed: what a live
|
/// The feed list as this person sees it, or with `only` the one row for that feed: what a live
|
||||||
/// update sends after the feed changes, so the page redraws a row instead of reloading the list.
|
/// update sends after the feed changes, so the page redraws a row instead of reloading the list.
|
||||||
async fn feed_rows(state: &WebState, user: &crate::db::User, only: Option<&str>) -> anyhow::Result<Vec<FeedRow>> {
|
async fn feed_rows(state: &WebState, user: &crate::db::User, only: Option<&str>) -> anyhow::Result<Vec<FeedRow>> {
|
||||||
|
use crate::db::step;
|
||||||
|
use tracing::Instrument;
|
||||||
let cfg = state.ctx.cfg();
|
let cfg = state.ctx.cfg();
|
||||||
// Config entries plus the feeds derived from OPML subscriptions -- the catalogue.
|
// Config entries plus the feeds derived from OPML subscriptions -- the catalogue.
|
||||||
// What comes back is only the part of it this person subscribes to.
|
// What comes back is only the part of it this person subscribes to.
|
||||||
let subs = crate::subscriptions(&state.ctx).await?;
|
let subs = crate::subscriptions(&state.ctx).instrument(step("catalogue")).await?;
|
||||||
let mine: std::collections::HashMap<String, crate::db::Sub> = state
|
let mine: std::collections::HashMap<String, crate::db::Sub> = state
|
||||||
.ctx
|
.ctx
|
||||||
.db
|
.db
|
||||||
.subscriptions_for(user.id).await?
|
.subscriptions_for(user.id)
|
||||||
|
.instrument(step("subscriptions"))
|
||||||
|
.await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|s| (s.feed_id.clone(), s))
|
.map(|s| (s.feed_id.clone(), s))
|
||||||
.collect();
|
.collect();
|
||||||
let counts = state.ctx.db.subscriber_counts().await?;
|
let counts = state.ctx.db.subscriber_counts().instrument(step("subscriber_counts")).await?;
|
||||||
let pinned = state.ctx.db.pinned_feeds(user.id).await?;
|
let pinned = state.ctx.db.pinned_feeds(user.id).instrument(step("pinned")).await?;
|
||||||
let mut listed = state.ctx.db.feed_list(user.id, only).await?;
|
let deadair: std::collections::HashMap<String, bool> =
|
||||||
|
state.ctx.db.deadair_prefs(Some(user.id)).await?.into_iter().map(|(_, f, w)| (f, w)).collect();
|
||||||
|
let mut listed = state.ctx.db.feed_list(user.id, only).instrument(step("feed_list")).await?;
|
||||||
let mut out = Vec::with_capacity(mine.len());
|
let mut out = Vec::with_capacity(mine.len());
|
||||||
for sub in subs.iter().filter(|s| only.is_none_or(|o| o == s.id)) {
|
for sub in subs.iter().filter(|s| only.is_none_or(|o| o == s.id)) {
|
||||||
let (id, feed) = (&sub.id, &sub.cfg);
|
let (id, feed) = (&sub.id, &sub.cfg);
|
||||||
@@ -844,6 +865,7 @@ async fn feed_rows(state: &WebState, user: &crate::db::User, only: Option<&str>)
|
|||||||
.or_else(|| up.and_then(|u| u.keywords.clone()))
|
.or_else(|| up.and_then(|u| u.keywords.clone()))
|
||||||
.unwrap_or_else(|| feed.keywords.clone()),
|
.unwrap_or_else(|| feed.keywords.clone()),
|
||||||
blocked,
|
blocked,
|
||||||
|
deadair: deadair.get(id).or_else(|| feed.group.as_deref().and_then(|g| deadair.get(g))).copied().unwrap_or(false),
|
||||||
allow_explicit: mine
|
allow_explicit: mine
|
||||||
.allow_explicit
|
.allow_explicit
|
||||||
.or(up.and_then(|u| u.allow_explicit))
|
.or(up.and_then(|u| u.allow_explicit))
|
||||||
@@ -954,20 +976,11 @@ async fn popular(state: &WebState, user_id: i64, everything: bool) -> Result<Vec
|
|||||||
let counts = db.subscriber_counts().await?;
|
let counts = db.subscriber_counts().await?;
|
||||||
let media = db.media_feeds().await?;
|
let media = db.media_feeds().await?;
|
||||||
let catalogue = crate::subscriptions(&state.ctx).await?;
|
let catalogue = crate::subscriptions(&state.ctx).await?;
|
||||||
let by_id: std::collections::HashMap<&str, &crate::config::Feed> =
|
let ok = listable(&catalogue);
|
||||||
catalogue.iter().map(|s| (s.id.as_str(), &s.cfg)).collect();
|
|
||||||
let is_folder: std::collections::HashSet<&str> =
|
|
||||||
catalogue.iter().filter_map(|s| s.cfg.group.as_deref()).collect();
|
|
||||||
let mut out = vec![];
|
let mut out = vec![];
|
||||||
for s in &catalogue {
|
for s in &catalogue {
|
||||||
let n = counts.get(&s.id).copied().unwrap_or(0);
|
let n = counts.get(&s.id).copied().unwrap_or(0);
|
||||||
// A feed inside an OPML that looks private is as private as the OPML.
|
if (n == 0 && !everything) || !ok.contains(s.id.as_str()) {
|
||||||
let folder = s.cfg.group.as_deref().and_then(|g| by_id.get(g));
|
|
||||||
if (n == 0 && !everything)
|
|
||||||
|| is_folder.contains(s.id.as_str())
|
|
||||||
|| looks_private(&s.cfg)
|
|
||||||
|| folder.is_some_and(|f| looks_private(f))
|
|
||||||
{
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let sum = listed.remove(&s.id).map(|l| l.summary).unwrap_or_default();
|
let sum = listed.remove(&s.id).map(|l| l.summary).unwrap_or_default();
|
||||||
@@ -989,6 +1002,26 @@ async fn popular(state: &WebState, user_id: i64, everything: bool) -> Result<Vec
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The feeds the Directory may list: not an OPML or a Patreon creator itself, which is listed as
|
||||||
|
/// the feeds in it, and neither the feed nor the group it is in private. Asked of one feed, without
|
||||||
|
/// building the whole Directory, by a feed's Directory page and by subscribing from it: building
|
||||||
|
/// it was nearly all of the 0.57 s a feed's page took (#160).
|
||||||
|
fn listable(catalogue: &[crate::Sub]) -> std::collections::HashSet<&str> {
|
||||||
|
let by_id: std::collections::HashMap<&str, &crate::config::Feed> =
|
||||||
|
catalogue.iter().map(|s| (s.id.as_str(), &s.cfg)).collect();
|
||||||
|
let is_folder: std::collections::HashSet<&str> = catalogue.iter().filter_map(|s| s.cfg.group.as_deref()).collect();
|
||||||
|
catalogue
|
||||||
|
.iter()
|
||||||
|
// A feed inside an OPML that looks private is as private as the OPML.
|
||||||
|
.filter(|s| {
|
||||||
|
!is_folder.contains(s.id.as_str())
|
||||||
|
&& !looks_private(&s.cfg)
|
||||||
|
&& !s.cfg.group.as_deref().and_then(|g| by_id.get(g)).is_some_and(|f| looks_private(f))
|
||||||
|
})
|
||||||
|
.map(|s| s.id.as_str())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_popular(
|
async fn get_popular(
|
||||||
State(state): State<WebState>,
|
State(state): State<WebState>,
|
||||||
user: crate::db::User,
|
user: crate::db::User,
|
||||||
@@ -1024,9 +1057,22 @@ struct ListedItem {
|
|||||||
struct Listed {
|
struct Listed {
|
||||||
/// What the feed says it is (#130), sanitized as its items are.
|
/// What the feed says it is (#130), sanitized as its items are.
|
||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
|
/// Why its last check failed, when it did (#142), in the words a feed's own page uses.
|
||||||
|
failing: Option<String>,
|
||||||
items: Vec<ListedItem>,
|
items: Vec<ListedItem>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A feed's last error as its Directory page says it: explained once it has been failing a day, as
|
||||||
|
/// the feed's own page does, or else that the check failed; never the error as it stands, which can
|
||||||
|
/// name the feed's address, and the Directory names none. A day, so that a site down for an hour,
|
||||||
|
/// or a resolver for a minute, is not called gone (#143).
|
||||||
|
fn failure_words(err: &str, failing_for: i64) -> String {
|
||||||
|
Some(err)
|
||||||
|
.filter(|_| failing_for >= FLAG_AFTER_SECS)
|
||||||
|
.and_then(crate::feed::explain_failure)
|
||||||
|
.map_or_else(|| "Its last check failed.".to_owned(), |f| f.reason.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
/// A listed feed's description and latest twenty items. Only a feed the Directory lists, so a
|
/// A listed feed's description and latest twenty items. Only a feed the Directory lists, so a
|
||||||
/// guessed id reaches nothing private, as `subscribe_popular` checks.
|
/// guessed id reaches nothing private, as `subscribe_popular` checks.
|
||||||
async fn get_listed(
|
async fn get_listed(
|
||||||
@@ -1034,13 +1080,16 @@ async fn get_listed(
|
|||||||
user: crate::db::User,
|
user: crate::db::User,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<Listed>, ApiError> {
|
) -> Result<Json<Listed>, ApiError> {
|
||||||
if !popular(&state, user.id, true).await?.iter().any(|p| p.id == id) {
|
if !listable(&crate::subscriptions(&state.ctx).await?).contains(id.as_str()) {
|
||||||
return Err(ApiError::not_found(format!("{id:?} is not in the directory")));
|
return Err(ApiError::not_found(format!("{id:?} is not in the directory")));
|
||||||
}
|
}
|
||||||
let order = crate::db::order_sql("published", "desc");
|
let order = crate::db::order_sql("published", "desc");
|
||||||
let rows = state.ctx.db.entries_in(user.id, Some(&id), crate::db::Filter::parse("all"), None, 0, 20, &order).await?;
|
let rows = state.ctx.db.entries_in(user.id, Some(&id), crate::db::Filter::parse("all"), None, 0, 20, &order).await?;
|
||||||
let mut sanitizer = feed_sanitizer();
|
let mut sanitizer = feed_sanitizer();
|
||||||
let description = state.ctx.db.feed_description(&id).await?.map(|d| clean_description(&mut sanitizer, &d, None));
|
let description = state.ctx.db.feed_description(&id).await?.map(|d| clean_description(&mut sanitizer, &d, None));
|
||||||
|
let summary = state.ctx.db.feed_summary(&id).await?;
|
||||||
|
let failing_for = summary.error_since.map_or(0, |since| crate::db::now() - since);
|
||||||
|
let failing = summary.last_error.as_deref().map(|e| failure_words(e, failing_for));
|
||||||
let items = rows
|
let items = rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|e| ListedItem {
|
.map(|e| ListedItem {
|
||||||
@@ -1052,7 +1101,7 @@ async fn get_listed(
|
|||||||
duration: e.duration,
|
duration: e.duration,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Ok(Json(Listed { description, items }))
|
Ok(Json(Listed { description, failing, items }))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sort_name(p: &PopularRow) -> String {
|
fn sort_name(p: &PopularRow) -> String {
|
||||||
@@ -1066,7 +1115,7 @@ async fn subscribe_popular(
|
|||||||
user: crate::db::User,
|
user: crate::db::User,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
if !popular(&state, user.id, true).await?.iter().any(|p| p.id == id) {
|
if !listable(&crate::subscriptions(&state.ctx).await?).contains(id.as_str()) {
|
||||||
return Err(ApiError::bad_request(format!("{id:?} is not in the directory")));
|
return Err(ApiError::bad_request(format!("{id:?} is not in the directory")));
|
||||||
}
|
}
|
||||||
state.ctx.db.subscribe(user.id, &id).await?;
|
state.ctx.db.subscribe(user.id, &id).await?;
|
||||||
@@ -1146,6 +1195,18 @@ impl IntoResponse for ApiError {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_failing_feed_says_why_in_the_directory_without_its_address() {
|
||||||
|
let day = FLAG_AFTER_SECS;
|
||||||
|
let nothing = "the feed's address answered with nothing at all";
|
||||||
|
assert_eq!(failure_words(nothing, day), "This address answers with nothing; the site may be gone.");
|
||||||
|
// Not before it has been failing a day, as on the feed's own page (#143).
|
||||||
|
assert_eq!(failure_words(nothing, 3600), "Its last check failed.");
|
||||||
|
// reqwest names the URL; the Directory names none.
|
||||||
|
let raw = "error sending request for url (http://daily-quests.com/comic/?feed=atom): connection closed";
|
||||||
|
assert_eq!(failure_words(raw, day), "Its last check failed.");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_relative_image_resolves_against_the_post() {
|
fn a_relative_image_resolves_against_the_post() {
|
||||||
let mut b = feed_sanitizer();
|
let mut b = feed_sanitizer();
|
||||||
@@ -1220,6 +1281,25 @@ mod tests {
|
|||||||
assert!(looks_private(&basic), "a feed with a login configured");
|
assert!(looks_private(&basic), "a feed with a login configured");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_directory_lists_feeds_not_their_groups_and_nothing_private() {
|
||||||
|
let sub = |id: &str, url: &str, group: Option<&str>| crate::Sub {
|
||||||
|
id: id.into(),
|
||||||
|
cfg: crate::config::Feed { group: group.map(Into::into), ..feed(url) },
|
||||||
|
managed: group.is_some(),
|
||||||
|
};
|
||||||
|
let catalogue = [
|
||||||
|
sub("plain", "https://example.com/rss", None),
|
||||||
|
sub("opml", "https://example.com/list.opml", None),
|
||||||
|
sub("kid", "https://kid.example/rss", Some("opml")),
|
||||||
|
sub("paid", "https://www.patreon.com/rss/x?auth=abc", None),
|
||||||
|
sub("paid-show", "https://www.patreon.com/rss/x?auth=abc&show=1", Some("paid")),
|
||||||
|
];
|
||||||
|
let mut got: Vec<&str> = listable(&catalogue).into_iter().collect();
|
||||||
|
got.sort();
|
||||||
|
assert_eq!(got, ["kid", "plain"], "the OPML as its feeds; a private feed and its shows never");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn only_the_last_admin_is_protected() {
|
fn only_the_last_admin_is_protected() {
|
||||||
let u = |id, is_admin| crate::db::User {
|
let u = |id, is_admin| crate::db::User {
|
||||||
@@ -1374,10 +1454,30 @@ async fn entry_page(
|
|||||||
row.description = Some(clean_description(&mut sanitizer, d, row.link.as_deref()));
|
row.description = Some(clean_description(&mut sanitizer, d, row.link.as_deref()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
crate::deadair::for_reader(&state.ctx, user_id, &mut rows).await?;
|
||||||
let total = db.count_in(user_id, feed, filter, search).await?;
|
let total = db.count_in(user_id, feed, filter, search).await?;
|
||||||
Ok(Json(EntryPage { total, entries: rows }))
|
Ok(Json(EntryPage { total, entries: rows }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One entry, in the clock of the file this person will play (`deadair::for_reader`).
|
||||||
|
async fn one_entry(
|
||||||
|
State(state): State<WebState>,
|
||||||
|
Path((feed_id, guid)): Path<(String, String)>,
|
||||||
|
user: crate::db::User,
|
||||||
|
) -> Result<Json<crate::db::EntryRow>, ApiError> {
|
||||||
|
subscribed(&state, &user, &feed_id).await?;
|
||||||
|
let Some(row) = state.ctx.db.entry(user.id, &feed_id, &guid).await? else {
|
||||||
|
return Err(ApiError::not_found("no such entry"));
|
||||||
|
};
|
||||||
|
let mut rows = [row];
|
||||||
|
crate::deadair::for_reader(&state.ctx, user.id, &mut rows).await?;
|
||||||
|
let [mut row] = rows;
|
||||||
|
if let Some(d) = &row.description {
|
||||||
|
row.description = Some(clean_description(&mut feed_sanitizer(), d, row.link.as_deref()));
|
||||||
|
}
|
||||||
|
Ok(Json(row))
|
||||||
|
}
|
||||||
|
|
||||||
/// Feed HTML is untrusted: it reaches the page only after ammonia has been through it.
|
/// Feed HTML is untrusted: it reaches the page only after ammonia has been through it.
|
||||||
fn feed_sanitizer() -> ammonia::Builder<'static> {
|
fn feed_sanitizer() -> ammonia::Builder<'static> {
|
||||||
let mut b = ammonia::Builder::new();
|
let mut b = ammonia::Builder::new();
|
||||||
@@ -1429,7 +1529,6 @@ async fn add_feed(
|
|||||||
user: crate::db::User,
|
user: crate::db::User,
|
||||||
Json(body): Json<NewFeed>,
|
Json(body): Json<NewFeed>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
let mut cfg = (*state.ctx.cfg()).clone();
|
|
||||||
// Before the duplicate check, so a site's page finds the feed someone already has. What is
|
// Before the duplicate check, so a site's page finds the feed someone already has. What is
|
||||||
// not a feed and links none is refused here, with why, and nothing is added.
|
// not a feed and links none is refused here, with why, and nothing is added.
|
||||||
let url = crate::feed::find_feed(&state.ctx.client, &crate::feed::expand_input(&body.url))
|
let url = crate::feed::find_feed(&state.ctx.client, &crate::feed::expand_input(&body.url))
|
||||||
@@ -1437,26 +1536,14 @@ async fn add_feed(
|
|||||||
.map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
|
.map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
|
||||||
// Someone else may already have it. Then adding costs nothing: no second fetch, no
|
// Someone else may already have it. Then adding costs nothing: no second fetch, no
|
||||||
// second copy on disk, just another name against the same feed.
|
// second copy on disk, just another name against the same feed.
|
||||||
if let Some(existing) = crate::subscriptions(&state.ctx).await?
|
let (id, existed) = crate::add_to_catalogue(&state.ctx, &url, body.folder, body.keywords).await?;
|
||||||
.into_iter()
|
let already = existed && state.ctx.db.subscription(user.id, &id).await?.is_some();
|
||||||
.find(|s| crate::feed::same_feed(&s.cfg.url, &url))
|
|
||||||
{
|
|
||||||
let already = state.ctx.db.subscription(user.id, &existing.id).await?.is_some();
|
|
||||||
state.ctx.db.subscribe(user.id, &existing.id).await?;
|
|
||||||
if !already {
|
|
||||||
explicit_on_add(&state, user.id, &existing.id, body.allow_explicit).await?;
|
|
||||||
}
|
|
||||||
scan_soon(&state, Some(existing.id.clone())).await;
|
|
||||||
return Ok(Json(
|
|
||||||
serde_json::json!({ "id": existing.id, "existing": already }),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let id = crate::add_one(&state.ctx, &mut cfg, &url, body.folder, body.keywords).await?;
|
|
||||||
state.ctx.store_cfg(cfg).await?;
|
|
||||||
state.ctx.db.subscribe(user.id, &id).await?;
|
state.ctx.db.subscribe(user.id, &id).await?;
|
||||||
|
if !already {
|
||||||
explicit_on_add(&state, user.id, &id, body.allow_explicit).await?;
|
explicit_on_add(&state, user.id, &id, body.allow_explicit).await?;
|
||||||
|
}
|
||||||
scan_soon(&state, Some(id.clone())).await;
|
scan_soon(&state, Some(id.clone())).await;
|
||||||
Ok(Json(serde_json::json!({ "id": id, "existing": false })))
|
Ok(Json(serde_json::json!({ "id": id, "existing": already })))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Queues a scan, so a feed just added shows its items without anyone pressing Scan now.
|
/// Queues a scan, so a feed just added shows its items without anyone pressing Scan now.
|
||||||
@@ -1490,6 +1577,10 @@ struct FeedPatch {
|
|||||||
#[serde(default, deserialize_with = "double_option")]
|
#[serde(default, deserialize_with = "double_option")]
|
||||||
max_new_per_check: Option<Option<usize>>,
|
max_new_per_check: Option<Option<usize>>,
|
||||||
pinned: Option<bool>,
|
pinned: Option<bool>,
|
||||||
|
/// True plays the feed from DeadAir's copies, false keeps it out (of its group's too), null
|
||||||
|
/// follows its group again (#171).
|
||||||
|
#[serde(default, deserialize_with = "double_option")]
|
||||||
|
deadair: Option<Option<bool>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A block list as sent: trimmed, blanks and repeats dropped, and bounded, since each word is
|
/// A block list as sent: trimmed, blanks and repeats dropped, and bounded, since each word is
|
||||||
@@ -1527,14 +1618,13 @@ async fn patch_feed(
|
|||||||
{
|
{
|
||||||
return Err(ApiError::not_found("you do not subscribe to that feed"));
|
return Err(ApiError::not_found("you do not subscribe to that feed"));
|
||||||
}
|
}
|
||||||
|
if let Some(wanted) = body.deadair {
|
||||||
|
state.ctx.db.set_deadair(user.id, &id, wanted).await?;
|
||||||
|
state.ctx.deadair.notify_one();
|
||||||
|
}
|
||||||
// What one person wants -- which items, whether to fetch them, how many at a time --
|
// What one person wants -- which items, whether to fetch them, how many at a time --
|
||||||
// is theirs. It goes on their subscription and nobody else sees the change.
|
// is theirs. It goes on their subscription and nobody else sees the change.
|
||||||
if state.ctx.db.subscription(user.id, &id).await?.is_some() {
|
if let Some(mut mine) = state.ctx.db.subscription(user.id, &id).await? {
|
||||||
let mut mine = state
|
|
||||||
.ctx
|
|
||||||
.db
|
|
||||||
.subscription(user.id, &id).await?
|
|
||||||
.unwrap_or_else(|| crate::db::Sub { feed_id: id.clone(), ..Default::default() });
|
|
||||||
let mut touched = false;
|
let mut touched = false;
|
||||||
if let Some(v) = body.keywords.clone() {
|
if let Some(v) = body.keywords.clone() {
|
||||||
mine.keywords = Some(v.into_iter().filter(|k| !k.trim().is_empty()).collect());
|
mine.keywords = Some(v.into_iter().filter(|k| !k.trim().is_empty()).collect());
|
||||||
@@ -1572,7 +1662,7 @@ async fn patch_feed(
|
|||||||
"the feed's address, folder, schedule and category 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.edit_cfg().await;
|
||||||
|
|
||||||
// Derived feeds have no config entry. Editing one is the moment it earns a real
|
// Derived feeds have no config entry. Editing one is the moment it earns a real
|
||||||
// entry: promote it, so the config holds your decisions and nothing else.
|
// entry: promote it, so the config holds your decisions and nothing else.
|
||||||
@@ -1633,34 +1723,7 @@ async fn remove_feed(
|
|||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
user: crate::db::User,
|
user: crate::db::User,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
// Unsubscribing is personal: it takes the feed off your list and leaves everyone
|
crate::leave(&state.ctx, user.id, &id).await?;
|
||||||
// else's alone.
|
|
||||||
state.ctx.db.unsubscribe(user.id, &id).await?;
|
|
||||||
for child in crate::subscriptions(&state.ctx).await?
|
|
||||||
.iter()
|
|
||||||
.filter(|s| s.cfg.group.as_deref() == Some(id.as_str()))
|
|
||||||
{
|
|
||||||
state.ctx.db.unsubscribe(user.id, &child.id).await?;
|
|
||||||
}
|
|
||||||
if state.ctx.db.subscriber_counts().await?.contains_key(&id) {
|
|
||||||
return Ok(StatusCode::NO_CONTENT);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nobody is left: the feed stops being scanned. Its files and history stay, so if
|
|
||||||
// someone subscribes again they do not pull the back catalogue a second time. A feed an
|
|
||||||
// admin listed stays in the Directory, for the next person.
|
|
||||||
let mut cfg = (*state.ctx.cfg()).clone();
|
|
||||||
if cfg.feeds.get(&id).is_some_and(|f| f.listed) {
|
|
||||||
return Ok(StatusCode::NO_CONTENT);
|
|
||||||
}
|
|
||||||
if cfg.feeds.remove(&id).is_none() {
|
|
||||||
// A derived feed: forget it here, though the OPML will list it again on the next
|
|
||||||
// read unless you unsubscribe from the OPML itself.
|
|
||||||
state.ctx.db.drop_managed(&id).await?;
|
|
||||||
return Ok(StatusCode::NO_CONTENT);
|
|
||||||
}
|
|
||||||
state.ctx.store_cfg(cfg).await?;
|
|
||||||
crate::retire_group(&state.ctx, &id).await?;
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1700,7 +1763,7 @@ async fn download_now(
|
|||||||
.ctx
|
.ctx
|
||||||
.db
|
.db
|
||||||
.enclosure(id).await?
|
.enclosure(id).await?
|
||||||
.ok_or_else(|| anyhow::anyhow!("no enclosure {id}"))?;
|
.ok_or_else(|| ApiError::not_found(format!("no file {id}")))?;
|
||||||
subscribed(&state, &user, &enc.feed_id).await?;
|
subscribed(&state, &user, &enc.feed_id).await?;
|
||||||
if enc.path.is_some() {
|
if enc.path.is_some() {
|
||||||
return Ok(StatusCode::NO_CONTENT); // Already here.
|
return Ok(StatusCode::NO_CONTENT); // Already here.
|
||||||
@@ -1730,7 +1793,7 @@ async fn delete_file(
|
|||||||
.ctx
|
.ctx
|
||||||
.db
|
.db
|
||||||
.enclosure(id).await?
|
.enclosure(id).await?
|
||||||
.ok_or_else(|| anyhow::anyhow!("no enclosure {id}"))?;
|
.ok_or_else(|| ApiError::not_found(format!("no file {id}")))?;
|
||||||
subscribed(&state, &user, &enc.feed_id).await?;
|
subscribed(&state, &user, &enc.feed_id).await?;
|
||||||
|
|
||||||
// There is one copy of the file: deleting it deletes everyone's. Say so before doing
|
// There is one copy of the file: deleting it deletes everyone's. Say so before doing
|
||||||
@@ -1756,7 +1819,7 @@ async fn delete_file(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(path) = &enc.path
|
if let Some(path) = &enc.path
|
||||||
&& let Err(e) = std::fs::remove_file(path)
|
&& let Err(e) = crate::download::remove(std::path::Path::new(path))
|
||||||
&& e.kind() != std::io::ErrorKind::NotFound
|
&& e.kind() != std::io::ErrorKind::NotFound
|
||||||
{
|
{
|
||||||
return Err(e.into());
|
return Err(e.into());
|
||||||
@@ -1852,16 +1915,43 @@ async fn media(
|
|||||||
user: crate::db::User,
|
user: crate::db::User,
|
||||||
req: Request,
|
req: Request,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
|
serve_media(state, id, user, req, false).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DeadAir's copy of a file (#171), at an address of its own: a player that had part of one
|
||||||
|
/// version cached and asked the same address for more would stitch the two together.
|
||||||
|
async fn media_deadair(
|
||||||
|
State(state): State<WebState>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
user: crate::db::User,
|
||||||
|
req: Request,
|
||||||
|
) -> Response {
|
||||||
|
serve_media(state, id, user, req, true).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn serve_media(state: WebState, id: i64, user: crate::db::User, req: Request, copy: bool) -> Response {
|
||||||
let Ok(Some(enc)) = state.ctx.db.enclosure(id).await else {
|
let Ok(Some(enc)) = state.ctx.db.enclosure(id).await else {
|
||||||
return (StatusCode::NOT_FOUND, "no such enclosure").into_response();
|
return (StatusCode::NOT_FOUND, "no such enclosure").into_response();
|
||||||
};
|
};
|
||||||
if let Err(e) = subscribed(&state, &user, &enc.feed_id).await {
|
if let Err(e) = subscribed(&state, &user, &enc.feed_id).await {
|
||||||
return e.into_response();
|
return e.into_response();
|
||||||
}
|
}
|
||||||
let Some(path) = enc.path else {
|
let Some(mut path) = enc.path else {
|
||||||
return (StatusCode::NOT_FOUND, "not downloaded").into_response();
|
return (StatusCode::NOT_FOUND, "not downloaded").into_response();
|
||||||
};
|
};
|
||||||
match ServeFile::new(path).oneshot(req).await {
|
// Which version this person is playing, so the positions their player saves are read in its
|
||||||
|
// clock (`deadair::on_save`).
|
||||||
|
let key = (user.id, enc.feed_id.clone(), enc.guid.clone());
|
||||||
|
if copy {
|
||||||
|
let Some(c) = state.ctx.db.copies(&[id]).await.ok().and_then(|c| c.into_iter().next()) else {
|
||||||
|
return (StatusCode::NOT_FOUND, "no DeadAir copy").into_response();
|
||||||
|
};
|
||||||
|
path = c.path.clone();
|
||||||
|
state.playing.lock().unwrap().insert(key, Arc::new(c));
|
||||||
|
} else {
|
||||||
|
state.playing.lock().unwrap().remove(&key);
|
||||||
|
}
|
||||||
|
match ServeFile::new(crate::download::playable(std::path::Path::new(&path))).oneshot(req).await {
|
||||||
Ok(r) => r.into_response(),
|
Ok(r) => r.into_response(),
|
||||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
}
|
}
|
||||||
@@ -1876,10 +1966,11 @@ struct ArtQuery {
|
|||||||
/// time it is fetched from the publisher and kept on disk (`art`, up to `art_cache_mb`), so
|
/// time it is fetched from the publisher and kept on disk (`art`, up to `art_cache_mb`), so
|
||||||
/// later it is fast, the publisher is not asked on every visit, and it outlives the publisher's
|
/// later it is fast, the publisher is not asked on every visit, and it outlives the publisher's
|
||||||
/// server. Only an address some feed or item names as its artwork, and only an image up to
|
/// server. Only an address some feed or item names as its artwork, and only an image up to
|
||||||
/// 5 MB, so the route cannot be pointed at anything else. It began as a way round http-only
|
/// 20 MB, so the route cannot be pointed at anything else. It began as a way round http-only
|
||||||
/// artwork on the https page (#90).
|
/// artwork on the https page (#90).
|
||||||
async fn art(State(state): State<WebState>, Query(q): Query<ArtQuery>) -> Response {
|
async fn art(State(state): State<WebState>, Query(q): Query<ArtQuery>) -> Response {
|
||||||
const MAX: usize = 5 << 20;
|
// 5 MB turned away the Glass Cannon Network's covers, 7 MB PNGs (#169).
|
||||||
|
const MAX: usize = 20 << 20;
|
||||||
let web = q.u.starts_with("http://") || q.u.starts_with("https://");
|
let web = q.u.starts_with("http://") || q.u.starts_with("https://");
|
||||||
if !web || !state.ctx.db.names_image(&q.u).await.unwrap_or(false) {
|
if !web || !state.ctx.db.names_image(&q.u).await.unwrap_or(false) {
|
||||||
return (StatusCode::NOT_FOUND, "no feed names that artwork").into_response();
|
return (StatusCode::NOT_FOUND, "no feed names that artwork").into_response();
|
||||||
@@ -1889,6 +1980,9 @@ async fn art(State(state): State<WebState>, Query(q): Query<ArtQuery>) -> Respon
|
|||||||
if keep && let Some((kind, body)) = crate::art::read(&file) {
|
if keep && let Some((kind, body)) = crate::art::read(&file) {
|
||||||
return art_response(kind, body);
|
return art_response(kind, body);
|
||||||
}
|
}
|
||||||
|
if let Some(why) = crate::art::failed(&q.u) {
|
||||||
|
return (StatusCode::BAD_GATEWAY, why).into_response();
|
||||||
|
}
|
||||||
let got = async {
|
let got = async {
|
||||||
let mut r = state.ctx.client.get(&q.u).timeout(crate::feed::FEED_TIMEOUT).send().await?.error_for_status()?;
|
let mut r = state.ctx.client.get(&q.u).timeout(crate::feed::FEED_TIMEOUT).send().await?.error_for_status()?;
|
||||||
let kind = r.headers().get(header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or("").to_owned();
|
let kind = r.headers().get(header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or("").to_owned();
|
||||||
@@ -1907,7 +2001,13 @@ async fn art(State(state): State<WebState>, Query(q): Query<ArtQuery>) -> Respon
|
|||||||
}
|
}
|
||||||
art_response(kind, body)
|
art_response(kind, body)
|
||||||
}
|
}
|
||||||
Err(e) => (StatusCode::BAD_GATEWAY, format!("{e:#}")).into_response(),
|
Err(e) => {
|
||||||
|
let why = format!("{e:#}");
|
||||||
|
// The access line says only that it failed, not which image or why (#169).
|
||||||
|
tracing::warn!(url = %q.u, error = %why, "could not fetch artwork");
|
||||||
|
crate::art::fail(&q.u, &why);
|
||||||
|
(StatusCode::BAD_GATEWAY, why).into_response()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1939,7 +2039,9 @@ 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, body.duration).await?;
|
let (secs, duration) =
|
||||||
|
crate::deadair::on_save(&state.ctx, &state.playing, user.id, &feed_id, &guid, body.secs, body.duration).await?;
|
||||||
|
state.ctx.db.set_position(user.id, &feed_id, &guid, secs, duration).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2128,7 +2230,7 @@ async fn patch_settings(
|
|||||||
if !user.is_admin {
|
if !user.is_admin {
|
||||||
return Err(ApiError::forbidden("only an admin changes these settings"));
|
return Err(ApiError::forbidden("only an admin changes these settings"));
|
||||||
}
|
}
|
||||||
let mut cfg = (*state.ctx.cfg()).clone();
|
let mut cfg = state.ctx.edit_cfg().await;
|
||||||
if let Some(sched) = body.schedule {
|
if let Some(sched) = body.schedule {
|
||||||
let sched = sched.trim().to_owned();
|
let sched = sched.trim().to_owned();
|
||||||
if crate::config::parse_interval(&sched).is_none() {
|
if crate::config::parse_interval(&sched).is_none() {
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ function makeContext(html, script, { media = false } = {}) {
|
|||||||
EventSource: function () { this.close = () => {}; },
|
EventSource: function () { this.close = () => {}; },
|
||||||
MediaMetadata: function () {},
|
MediaMetadata: function () {},
|
||||||
Blob: function () {},
|
Blob: function () {},
|
||||||
|
getComputedStyle: () => ({ backgroundColor: '' }),
|
||||||
setTimeout, clearTimeout, setInterval, clearInterval,
|
setTimeout, clearTimeout, setInterval, clearInterval,
|
||||||
confirm: () => false, prompt: () => null, alert(){},
|
confirm: () => false, prompt: () => null, alert(){},
|
||||||
Date, Math, JSON, Object, Array, String, Number, Promise, Error, FormData: function(){},
|
Date, Math, JSON, Object, Array, String, Number, Promise, Error, FormData: function(){},
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ test('the three panes are there and the item text lands in the bottom one', asyn
|
|||||||
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
await expect(page.locator('#list')).toBeVisible();
|
await expect(page.locator('#list')).toBeVisible();
|
||||||
await expect(page.locator('#grab')).toBeVisible(); // the draggable divider
|
await expect(page.locator('#grab')).toBeVisible(); // the draggable divider
|
||||||
await expect(page.locator('#detail')).toContainText('Pick an item');
|
await expect(page.locator('#detail .dt')).toHaveCount(0); // nothing picked, nothing said
|
||||||
|
|
||||||
await page.locator('.ep', { hasText: 'First Episode' }).click();
|
await page.locator('.ep', { hasText: 'First Episode' }).click();
|
||||||
await expect(page.locator('#detail .dt')).toHaveText('First Episode');
|
await expect(page.locator('#detail .dt')).toHaveText('First Episode');
|
||||||
@@ -1656,3 +1656,139 @@ test('a refresh button turns while what it checks is being checked', async ({ pa
|
|||||||
await page.evaluate(() => setScanning('multi-show', false));
|
await page.evaluate(() => setScanning('multi-show', false));
|
||||||
expect(await spin('#scanAll .i')).toBe('none');
|
expect(await spin('#scanAll .i')).toBe('none');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe('on a narrow phone', () => {
|
||||||
|
test.use({ viewport: { width: 360, height: 760 } });
|
||||||
|
|
||||||
|
test("a feed's buttons stay on one row, and the bar round the island is the page's colour", async ({ page }) => {
|
||||||
|
// Seven 44px buttons did not fit 360px, and Unsubscribe went onto a row of its own (#146).
|
||||||
|
await page.locator('#burger').click();
|
||||||
|
await page.locator('.feed', { hasText: 'Test Show' }).first().click();
|
||||||
|
const tops = await page.locator('#content .acts .btn').evaluateAll(bs => bs.map(b => b.getBoundingClientRect().top));
|
||||||
|
expect(tops.length).toBe(7);
|
||||||
|
expect(new Set(tops).size).toBe(1);
|
||||||
|
// iOS 26 paints behind the status bar and the Dynamic Island in the colour of a sticky bar at
|
||||||
|
// the top edge, older ones in theme-color: both the top bar's, which is the page's (#147).
|
||||||
|
const bar = await page.locator('#topbar').evaluate(b => [getComputedStyle(b).position, b.getBoundingClientRect().top, getComputedStyle(b).backgroundColor]);
|
||||||
|
expect(bar.slice(0, 2)).toEqual(['sticky', 0]);
|
||||||
|
expect(bar[2]).toBe(await page.evaluate(() => getComputedStyle(document.body).backgroundColor));
|
||||||
|
expect(await page.evaluate(() => document.querySelector('meta[name=theme-color]').content)).toBe(bar[2]);
|
||||||
|
// Glass's bar is see-through on a desktop, which no flat strip can match: solid on a phone.
|
||||||
|
for (const mode of ['dark', 'light']) {
|
||||||
|
const [c, tc] = await page.evaluate(m => { setTheme('glass', m);
|
||||||
|
return [getComputedStyle(document.querySelector('#topbar')).backgroundColor, document.querySelector('meta[name=theme-color]').content]; }, mode);
|
||||||
|
expect(c).toMatch(/^rgb\(/);
|
||||||
|
expect(tc).toBe(c);
|
||||||
|
}
|
||||||
|
await page.evaluate(() => setTheme('modern', 'dark'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a page brought back into view catches up with what was read elsewhere', async ({ page }) => {
|
||||||
|
// Reading an item tells no other page, and a phone's page misses everything while it is in the
|
||||||
|
// background: All Subscriptions went on counting an item read elsewhere as unread (#168).
|
||||||
|
const badge = page.locator('.place[data-id=":all"] .badge');
|
||||||
|
const [one] = (await (await page.request.get('/api/entries?limit=1')).json()).entries;
|
||||||
|
const flag = read => page.request.post(`/api/entries/${encodeURIComponent(one.feed_id)}/${encodeURIComponent(one.guid)}/flags`, { data: { read } });
|
||||||
|
await flag(false);
|
||||||
|
await page.reload();
|
||||||
|
const before = Number(await badge.textContent());
|
||||||
|
expect(before).toBeGreaterThan(0);
|
||||||
|
await flag(true); // read on another device
|
||||||
|
await page.evaluate(() => {
|
||||||
|
Object.defineProperty(document, 'hidden', { configurable: true, get: () => false });
|
||||||
|
document.dispatchEvent(new Event('visibilitychange'));
|
||||||
|
});
|
||||||
|
await expect(badge).toHaveText(String(before - 1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a feed\'s settings say what downloads, take words as chips, and keep what is saved', async ({ page }) => {
|
||||||
|
// The redesigned dialog (#167): the download choice, word chips, the fold, and a save that holds.
|
||||||
|
const open = async () => {
|
||||||
|
await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click();
|
||||||
|
await page.locator('#content .acts [data-a="settings"]').click();
|
||||||
|
await expect(page.locator('#modalCard.fset')).toBeVisible();
|
||||||
|
};
|
||||||
|
await open();
|
||||||
|
// The strip is the feed's own items, and Newest 5 lights every one of its two.
|
||||||
|
await expect(page.locator('#sepwin i')).toHaveCount(2);
|
||||||
|
await page.locator('.seg label', { hasText: 'Newest' }).click();
|
||||||
|
await page.locator('#smax').fill('5');
|
||||||
|
await expect(page.locator('#sepwin i.on')).toHaveCount(2);
|
||||||
|
await expect(page.locator('#swcap')).toContainText('Downloads the newest');
|
||||||
|
// Words become chips with Enter or a comma, and come off with their x.
|
||||||
|
await page.locator('#skwin').fill('first, second');
|
||||||
|
await page.locator('#skwin').press('Enter');
|
||||||
|
await expect(page.locator('#skw .chip')).toHaveCount(2);
|
||||||
|
await page.locator('#skw .chip', { hasText: 'second' }).locator('.chipx').click();
|
||||||
|
await expect(page.locator('#skw .chip')).toHaveCount(1);
|
||||||
|
await page.locator('#sblockin').fill('spoiler');
|
||||||
|
await page.locator('#sblockin').press('Enter');
|
||||||
|
// What only automatic downloads use folds away with them.
|
||||||
|
await page.locator('.switch', { hasText: 'Download automatically' }).click();
|
||||||
|
await expect(page.locator('#sdeps')).toHaveClass(/\boff\b/);
|
||||||
|
await page.locator('.switch', { hasText: 'Download automatically' }).click();
|
||||||
|
await expect(page.locator('#sdeps')).not.toHaveClass(/\boff\b/);
|
||||||
|
await page.locator('#ssave').click();
|
||||||
|
await expect(page.locator('#modal.on')).toBeHidden();
|
||||||
|
|
||||||
|
// Saved: open again and it is all there.
|
||||||
|
await open();
|
||||||
|
await expect(page.locator('[name=swin][value=newest]')).toBeChecked();
|
||||||
|
await expect(page.locator('#smax')).toHaveValue('5');
|
||||||
|
await expect(page.locator('#skw .chip')).toHaveText(['first']);
|
||||||
|
await expect(page.locator('#sblock .chip')).toHaveText(['spoiler']);
|
||||||
|
|
||||||
|
// Back as it was, for the tests after this one.
|
||||||
|
await page.locator('.seg label', { hasText: 'Default' }).click();
|
||||||
|
await page.locator('#skw .chipx').click();
|
||||||
|
await page.locator('#sblock .chipx').click();
|
||||||
|
await page.locator('#ssave').click();
|
||||||
|
await expect(page.locator('#modal.on')).toBeHidden();
|
||||||
|
await open();
|
||||||
|
await expect(page.locator('[name=swin][value=default]')).toBeChecked();
|
||||||
|
await expect(page.locator('#skw .chip, #sblock .chip')).toHaveCount(0);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DeadAir is off until turned on for a show, then plays its shortened copy and switches back', async ({ page }) => {
|
||||||
|
// #171/#172. A show of its own, with real audio, added here and left at the end.
|
||||||
|
const { id } = await (await page.request.post('/api/feeds', { data: { url: 'http://127.0.0.1:8792/quiet.xml' } })).json();
|
||||||
|
const entry = async () => {
|
||||||
|
const r = await page.request.get(`/api/feeds/${encodeURIComponent(id)}/entries`);
|
||||||
|
return (await r.json()).entries?.[0];
|
||||||
|
};
|
||||||
|
await expect.poll(async () => (await entry())?.guid, { timeout: 15_000 }).toBe('quiet-1');
|
||||||
|
await page.request.post(`/api/feeds/${encodeURIComponent(id)}/download-latest`, { data: { count: 1 } });
|
||||||
|
await expect.poll(async () => !!(await entry())?.enclosures[0]?.path, { timeout: 15_000 }).toBe(true);
|
||||||
|
// Off for a show until someone turns it on: no copy is made.
|
||||||
|
expect((await entry()).enclosures[0].deadair).toBeUndefined();
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// On, in the show's settings.
|
||||||
|
await page.locator('#feedlist .feed', { hasText: 'Quiet Show' }).first().click();
|
||||||
|
await page.locator('#content .acts [data-a="settings"]').click();
|
||||||
|
const sw = page.locator('#sdead');
|
||||||
|
await expect(sw).not.toBeChecked();
|
||||||
|
await sw.locator('xpath=..').click();
|
||||||
|
await page.locator('#ssave').click();
|
||||||
|
// Made at once, not at the next pass: about 1.8 s off each of four 2 s silences.
|
||||||
|
await expect.poll(async () => (await entry()).enclosures[0].deadair ?? 0, { timeout: 20_000 }).toBeGreaterThan(5);
|
||||||
|
await page.reload();
|
||||||
|
await page.locator('#feedlist .feed', { hasText: 'Quiet Show' }).first().click();
|
||||||
|
const row = page.locator('.ep', { hasText: 'Pauses Between' });
|
||||||
|
await expect(row).toContainText('s shorter');
|
||||||
|
|
||||||
|
// Played from the copy, with the switch beside the speed pressed.
|
||||||
|
await row.locator('[data-a=play]').click();
|
||||||
|
await expect.poll(() => page.evaluate(() => document.querySelector('#audio').getAttribute('src'))).toMatch(/\/deadair$/);
|
||||||
|
await expect(page.locator('#pdead')).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
// Off from the player: the original, from the same place.
|
||||||
|
await page.locator('#pdead').click();
|
||||||
|
await expect(page.locator('#pdead')).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
await expect.poll(() => page.evaluate(() => document.querySelector('#audio').getAttribute('src'))).toMatch(/\/media\/\d+$/);
|
||||||
|
expect((await entry()).enclosures[0].deadair).toBeUndefined();
|
||||||
|
|
||||||
|
await page.locator('#pclose').click();
|
||||||
|
await page.request.delete(`/api/feeds/${encodeURIComponent(id)}`);
|
||||||
|
});
|
||||||
|
|||||||
BIN
tests/ui/fixtures/quiet.mp3
Normal file
BIN
tests/ui/fixtures/quiet.mp3
Normal file
Binary file not shown.
8
tests/ui/fixtures/quiet.xml
Normal file
8
tests/ui/fixtures/quiet.xml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
|
||||||
|
<channel><title>Quiet Show</title><link>http://127.0.0.1:8792/</link><description>Real audio with long silences, for DeadAir.</description>
|
||||||
|
<item><title>Pauses Between</title><guid>quiet-1</guid>
|
||||||
|
<pubDate>Mon, 15 Sep 2026 10:00:00 +0000</pubDate>
|
||||||
|
<description>A second of tone, then two of silence, five times.</description>
|
||||||
|
<enclosure url="http://127.0.0.1:8792/quiet.mp3" length="60518" type="audio/mpeg"/></item>
|
||||||
|
</channel></rss>
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="color-scheme" content="dark light">
|
<meta name="color-scheme" content="dark light">
|
||||||
|
<meta name="theme-color" content="#0a1726">
|
||||||
<title>iPX admin</title>
|
<title>iPX admin</title>
|
||||||
<link rel="icon" type="image/png" sizes="128x128" id="favicon" href="/favicon.png" data-light="/favicon.png" data-dark="/favicon-dark.png">
|
<link rel="icon" type="image/png" sizes="128x128" id="favicon" href="/favicon.png" data-light="/favicon.png" data-dark="/favicon-dark.png">
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
@@ -13,7 +14,7 @@
|
|||||||
<!-- The server sends this page, and its script, to admins only. -->
|
<!-- The server sends this page, and its script, to admins only. -->
|
||||||
<header id="topbar">
|
<header id="topbar">
|
||||||
<a class="btn ico" href="/" title="Back to iPX" aria-label="Back to iPX" data-icon="left"></a>
|
<a class="btn ico" href="/" title="Back to iPX" aria-label="Back to iPX" data-icon="left"></a>
|
||||||
<img class="logo onlight" src="/logo.svg" alt="" width="26"><img class="logo ondark" src="/logo-dark.svg" alt="" width="26">
|
<img class="logo onlight" src="/logo.svg" alt="" width="26"><img class="logo ondark" src="/logo-dark.svg" alt="" width="26"><img class="logo classic" src="/icon.png" alt="" width="26">
|
||||||
<h1>Admin</h1>
|
<h1>Admin</h1>
|
||||||
<span class="grow"></span>
|
<span class="grow"></span>
|
||||||
<nav class="tabs" id="atabs">
|
<nav class="tabs" id="atabs">
|
||||||
|
|||||||
136
web/app.css
136
web/app.css
@@ -426,6 +426,7 @@
|
|||||||
--glass-hi:rgba(255,255,255,.26);
|
--glass-hi:rgba(255,255,255,.26);
|
||||||
--glass-lo:rgba(255,255,255,.10);
|
--glass-lo:rgba(255,255,255,.10);
|
||||||
--glass-sheen:rgba(255,255,255,.07);
|
--glass-sheen:rgba(255,255,255,.07);
|
||||||
|
--glass-bar:#191e34; /* the top bar on a phone: what the glass one showed, on average */
|
||||||
--wash1:rgba(64,120,255,.30);
|
--wash1:rgba(64,120,255,.30);
|
||||||
--wash2:rgba(175,82,222,.24);
|
--wash2:rgba(175,82,222,.24);
|
||||||
--wash3:rgba(48,176,199,.20);
|
--wash3:rgba(48,176,199,.20);
|
||||||
@@ -450,6 +451,7 @@
|
|||||||
--glass-hi:rgba(255,255,255,.9);
|
--glass-hi:rgba(255,255,255,.9);
|
||||||
--glass-lo:rgba(255,255,255,.45);
|
--glass-lo:rgba(255,255,255,.45);
|
||||||
--glass-sheen:rgba(255,255,255,.5);
|
--glass-sheen:rgba(255,255,255,.5);
|
||||||
|
--glass-bar:#f6f7ff;
|
||||||
--wash1:rgba(64,120,255,.22);
|
--wash1:rgba(64,120,255,.22);
|
||||||
--wash2:rgba(175,82,222,.16);
|
--wash2:rgba(175,82,222,.16);
|
||||||
--wash3:rgba(48,176,199,.18);
|
--wash3:rgba(48,176,199,.18);
|
||||||
@@ -458,6 +460,10 @@
|
|||||||
/* A rule that sets display beats the UA's [hidden], and several below do. */
|
/* A rule that sets display beats the UA's [hidden], and several below do. */
|
||||||
[hidden]{display:none!important}
|
[hidden]{display:none!important}
|
||||||
html,body{height:100%}
|
html,body{height:100%}
|
||||||
|
/* What iOS paints behind the status bar where nothing sticky is at the top edge, as in the
|
||||||
|
reader on a phone, which brings its own. With no colour of its own, the root went black or
|
||||||
|
white there instead (#147). */
|
||||||
|
html{background-color:var(--bg)}
|
||||||
body{
|
body{
|
||||||
margin:0;background:var(--bg);color:var(--fg);
|
margin:0;background:var(--bg);color:var(--fg);
|
||||||
font:14.5px/1.55 Inter,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
|
font:14.5px/1.55 Inter,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
|
||||||
@@ -498,6 +504,9 @@ a{color:var(--accent)}
|
|||||||
/* The logo in the mode's own variant. Dark unless the page says light, as the palette is: with
|
/* The logo in the mode's own variant. Dark unless the page says light, as the palette is: with
|
||||||
Auto, data-mode arrives with the script, and until then the page is drawn dark. */
|
Auto, data-mode arrives with the script, and until then the page is drawn dark. */
|
||||||
:root[data-mode="light"] .logo.ondark,:root:not([data-mode="light"]) .logo.onlight{display:none}
|
:root[data-mode="light"] .logo.ondark,:root:not([data-mode="light"]) .logo.onlight{display:none}
|
||||||
|
/* Classic is the 2004 app, so it wears the 2004 app's icon. */
|
||||||
|
.logo.classic,:root[data-theme="classic"] .logo:not(.classic){display:none}
|
||||||
|
:root[data-theme="classic"] .logo.classic{display:block}
|
||||||
.iconbtn{
|
.iconbtn{
|
||||||
width:30px;height:30px;border-radius:8px;display:grid;place-items:center;
|
width:30px;height:30px;border-radius:8px;display:grid;place-items:center;
|
||||||
color:var(--dim);flex:none;
|
color:var(--dim);flex:none;
|
||||||
@@ -522,6 +531,10 @@ a{color:var(--accent)}
|
|||||||
padding-top:calc(7px + var(--safe-t));padding-bottom:7px;
|
padding-top:calc(7px + var(--safe-t));padding-bottom:7px;
|
||||||
padding-left:calc(12px + var(--safe-l));padding-right:calc(12px + var(--safe-r));
|
padding-left:calc(12px + var(--safe-l));padding-right:calc(12px + var(--safe-r));
|
||||||
background:var(--panel);border-bottom:1px solid var(--line);
|
background:var(--panel);border-bottom:1px solid var(--line);
|
||||||
|
/* Nothing scrolls under it: it is sticky only because iOS 26 paints the strip behind the status
|
||||||
|
bar and the Dynamic Island in the colour of a sticky or fixed bar at the top edge, and ignores
|
||||||
|
theme-color, so the bar now runs all the way up behind the island (#147). */
|
||||||
|
position:sticky;top:0;
|
||||||
}
|
}
|
||||||
#topbar .grow{flex:1}
|
#topbar .grow{flex:1}
|
||||||
#topbar #epSearch{width:260px;flex:none}
|
#topbar #epSearch{width:260px;flex:none}
|
||||||
@@ -717,6 +730,7 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
|
|||||||
.show .meta{min-width:0;flex:1;display:flex;flex-direction:column;align-items:flex-start;gap:5px}
|
.show .meta{min-width:0;flex:1;display:flex;flex-direction:column;align-items:flex-start;gap:5px}
|
||||||
.show h2{margin:0 0 2px;font-size:30px;line-height:1.15;font-weight:700;letter-spacing:-.02em;overflow-wrap:anywhere}
|
.show h2{margin:0 0 2px;font-size:30px;line-height:1.15;font-weight:700;letter-spacing:-.02em;overflow-wrap:anywhere}
|
||||||
.show .sub{margin:0;color:var(--dim);font-size:13px;font-variant-numeric:tabular-nums}
|
.show .sub{margin:0;color:var(--dim);font-size:13px;font-variant-numeric:tabular-nums}
|
||||||
|
.show .dfail{color:var(--bad)}
|
||||||
.show .acts{margin-top:8px}
|
.show .acts{margin-top:8px}
|
||||||
.dlink{padding:0;font-size:13px;color:var(--accent);text-align:left}
|
.dlink{padding:0;font-size:13px;color:var(--accent);text-align:left}
|
||||||
.dlink:hover{text-decoration:underline}
|
.dlink:hover{text-decoration:underline}
|
||||||
@@ -801,6 +815,9 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
|
|||||||
.dt{font-size:18px;font-weight:650;margin:0 0 5px;line-height:1.3;overflow-wrap:anywhere}
|
.dt{font-size:18px;font-weight:650;margin:0 0 5px;line-height:1.3;overflow-wrap:anywhere}
|
||||||
.dmeta{color:var(--faint);font-size:12.5px;display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:14px}
|
.dmeta{color:var(--faint);font-size:12.5px;display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:14px}
|
||||||
.dmeta .btn{padding:3px 9px;font-size:12px}
|
.dmeta .btn{padding:3px 9px;font-size:12px}
|
||||||
|
/* The reader's buttons go as one: as items of the line itself, a long feed name took them onto
|
||||||
|
the next line one at a time (#146). */
|
||||||
|
.dacts{display:flex;gap:inherit;flex:none}
|
||||||
#detail .encbox{margin:0 0 10px}
|
#detail .encbox{margin:0 0 10px}
|
||||||
.dbody{font-size:14.5px;line-height:1.65;overflow-wrap:anywhere;color:var(--fg)}
|
.dbody{font-size:14.5px;line-height:1.65;overflow-wrap:anywhere;color:var(--fg)}
|
||||||
.dbody img{max-width:100%;height:auto;border-radius:6px}
|
.dbody img{max-width:100%;height:auto;border-radius:6px}
|
||||||
@@ -817,7 +834,7 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
|
|||||||
.fhead .sub{color:var(--dim);font-size:13px;margin-bottom:8px}
|
.fhead .sub{color:var(--dim);font-size:13px;margin-bottom:8px}
|
||||||
.fhead .sub a{color:var(--dim);text-decoration:none}
|
.fhead .sub a{color:var(--dim);text-decoration:none}
|
||||||
.fhead .sub a:hover{color:var(--accent)}
|
.fhead .sub a:hover{color:var(--accent)}
|
||||||
.acts{display:flex;gap:7px;flex-wrap:wrap;align-items:center;margin-top:auto}
|
.acts{display:flex;gap:7px;align-items:center;margin-top:auto}
|
||||||
.acts .btn{display:inline-flex;align-items:center;gap:6px;padding:7px 13px;border-radius:999px}
|
.acts .btn{display:inline-flex;align-items:center;gap:6px;padding:7px 13px;border-radius:999px}
|
||||||
.acts .btn i{font-style:normal;font-size:13px;line-height:1;opacity:.7}
|
.acts .btn i{font-style:normal;font-size:13px;line-height:1;opacity:.7}
|
||||||
.acts .btn:hover{background:var(--raise)}
|
.acts .btn:hover{background:var(--raise)}
|
||||||
@@ -912,6 +929,11 @@ body.adminpage #log{max-width:none}
|
|||||||
the row does not move when it changes. */
|
the row does not move when it changes. */
|
||||||
.ep .fl.on,.ep .fl.on:hover{color:var(--ink);background:radial-gradient(circle,var(--accent) 8.5px,transparent 9px)}
|
.ep .fl.on,.ep .fl.on:hover{color:var(--ink);background:radial-gradient(circle,var(--accent) 8.5px,transparent 9px)}
|
||||||
.ep .fl.on .i{width:9px;height:9px}
|
.ep .fl.on .i{width:9px;height:9px}
|
||||||
|
/* The reader's and a feed's pin button, and the toolbar's, pinned: filled with the accent, as the
|
||||||
|
disc is. A solid pin on the button's own grey read the same as an outlined one (#148). */
|
||||||
|
.btn.ico[data-a=pin][aria-pressed=true],.btn.ico[data-a=pin][aria-pressed=true]:hover,
|
||||||
|
.btn.ico[data-a=flag][aria-pressed=true],.btn.ico[data-a=flag][aria-pressed=true]:hover,
|
||||||
|
#tbFlag[aria-pressed=true],#tbFlag[aria-pressed=true]:hover{background:var(--accent);border-color:var(--accent);color:var(--ink)}
|
||||||
/* 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
|
||||||
paused animation was tried first; the frames it held were a pixel apart and read as dots. */
|
paused animation was tried first; the frames it held were a pixel apart and read as dots. */
|
||||||
.eq{display:inline-flex;align-items:flex-end;gap:2px;width:13px;height:12px;flex:none}
|
.eq{display:inline-flex;align-items:flex-end;gap:2px;width:13px;height:12px;flex:none}
|
||||||
@@ -1033,6 +1055,72 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
|||||||
.inline input[type=number]{flex:none;width:90px}
|
.inline input[type=number]{flex:none;width:90px}
|
||||||
.check input{width:16px;height:16px;accent-color:var(--accent)}
|
.check input{width:16px;height:16px;accent-color:var(--accent)}
|
||||||
.cardacts{display:flex;gap:8px;justify-content:flex-end;margin-top:16px}
|
.cardacts{display:flex;gap:8px;justify-content:flex-end;margin-top:16px}
|
||||||
|
|
||||||
|
/* ---------- a feed's settings (#167) ---------- */
|
||||||
|
/* A frame of its own: the body scrolls and Save stays at the foot, where on a phone it had been
|
||||||
|
three screens down. */
|
||||||
|
.card.fset{padding:0;display:flex;flex-direction:column;overflow:hidden;width:min(520px,100%)}
|
||||||
|
.fsetbody{overflow:auto;min-height:0;padding:22px 24px 24px}
|
||||||
|
.card.fset .fsetfoot{flex:none;margin:0;padding:12px 24px;border-top:1px solid var(--line)}
|
||||||
|
.fsethead{display:flex;gap:14px;align-items:center;margin-bottom:18px}
|
||||||
|
.fsethead .art{width:56px;height:56px;border-radius:12px;font-size:18px}
|
||||||
|
.fsethead h3{margin:0;font-size:19px;line-height:1.25;letter-spacing:-.01em;overflow-wrap:anywhere}
|
||||||
|
.fsethead p{margin:3px 0 0;font-size:12.5px;line-height:1.45;color:var(--dim)}
|
||||||
|
.faddr{margin-bottom:18px}
|
||||||
|
.moved{margin:2px 0 0;font-size:13px;color:var(--accent)}
|
||||||
|
/* On or off, drawn as iOS draws it, as the app is on a phone (#53). The box is the real control,
|
||||||
|
unseen over the track, so a click, a tap, the keyboard and a screen reader all reach it. */
|
||||||
|
.switch{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:38px;font-size:14px;font-weight:500;cursor:pointer}
|
||||||
|
/* The one switch the rest hang from. */
|
||||||
|
.switch.lead{font-weight:600}
|
||||||
|
.sw{position:relative;flex:none;width:42px;height:26px}
|
||||||
|
.sw input{position:absolute;inset:0;width:100%;height:100%;margin:0;opacity:0;cursor:pointer}
|
||||||
|
.sw i{position:absolute;inset:0;border-radius:999px;background:var(--raise);border:1px solid var(--line);pointer-events:none;transition:background-color .18s,border-color .18s}
|
||||||
|
/* White, as iOS has it, in every theme: in --ink, the dark themes' knob was a black hole in the track. */
|
||||||
|
.sw i::after{content:"";position:absolute;top:2px;left:2px;width:20px;height:20px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.35);transition:transform .18s}
|
||||||
|
.sw input:checked+i{background:var(--accent);border-color:var(--accent)}
|
||||||
|
.sw input:checked+i::after{transform:translateX(16px)}
|
||||||
|
.sw input:focus-visible+i{outline:2px solid var(--accent);outline-offset:2px}
|
||||||
|
/* The switch and what only matters while it downloads on its own, in one box; the rest folds away
|
||||||
|
when it is off. In --line, not --edge, so the box shows on a phone, which draws no outlines. The
|
||||||
|
rows in a grid of one row, 1fr open and 0fr shut, so the fold has a height to move. */
|
||||||
|
.fdl{border:1px solid var(--line);border-radius:12px;padding:4px 14px}
|
||||||
|
/* The same box round a part of a dialog that stands apart: API tokens in Settings. */
|
||||||
|
.field.boxed{border:1px solid var(--line);border-radius:12px;padding:12px 14px}
|
||||||
|
.fdeps{display:grid;grid-template-rows:1fr;transition:grid-template-rows .22s ease}
|
||||||
|
.fdeps.off{grid-template-rows:0fr}
|
||||||
|
.fdeps>div{min-height:0;overflow:hidden}
|
||||||
|
.fdepsin{padding:6px 2px 10px}
|
||||||
|
/* The app's own segmented control, with radio buttons in it, for the arrow keys. */
|
||||||
|
.seg{margin-bottom:12px}
|
||||||
|
.seg label{flex:1;position:relative;text-align:center;white-space:nowrap;padding:5px 10px;border-radius:7px;font-size:13px;color:var(--dim);cursor:pointer}
|
||||||
|
.seg input{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none}
|
||||||
|
.seg label:has(input:checked){background:var(--raise);color:var(--fg);font-weight:500}
|
||||||
|
.seg label:has(input:focus-visible){outline:2px solid var(--accent);outline-offset:-2px}
|
||||||
|
/* The feed's newest items, newest first, each as tall as it is long; lit, those that download on
|
||||||
|
their own. Dragged along, it sets how many. Only drawing: the number in the sentence below is
|
||||||
|
what a keyboard and a screen reader use. */
|
||||||
|
.epwin{display:flex;align-items:flex-end;gap:2px;height:38px;margin-bottom:8px;cursor:ew-resize;touch-action:pan-y;user-select:none;-webkit-user-select:none}
|
||||||
|
.epwin:empty{display:none}
|
||||||
|
.epwin i{flex:1 1 0;min-width:2px;max-width:10px;height:var(--h);border-radius:2px;background:var(--line);transition:background-color .2s calc(var(--n) * 12ms)}
|
||||||
|
.epwin i.on{background:var(--accent)}
|
||||||
|
.epwin i.nofile{height:4px}
|
||||||
|
.epwin small{flex:none;align-self:center;margin-left:8px;font-size:12px;color:var(--faint);font-variant-numeric:tabular-nums}
|
||||||
|
.epwin.all small{color:var(--accent)}
|
||||||
|
.wcap{margin:0 0 16px;font-size:13px;line-height:1.55;color:var(--dim)}
|
||||||
|
.wcap input[type=number]{display:inline-block;width:4.2em;margin:0 2px;padding:1px 4px;font-size:13px;text-align:center;font-variant-numeric:tabular-nums;color:var(--fg)}
|
||||||
|
.ffield{display:grid;gap:6px;margin-bottom:14px}
|
||||||
|
.fdl+.ffield{margin-top:18px}
|
||||||
|
.flabel{font-size:14px;font-weight:500}
|
||||||
|
.cap{margin:0;font-size:12.5px;line-height:1.45;color:var(--dim)}
|
||||||
|
/* Words and phrases as chips: in one comma-separated box, one ran into the next. */
|
||||||
|
.chips{display:flex;flex-wrap:wrap;gap:6px;align-items:center;min-height:38px;padding:5px;border:1px solid var(--line);border-radius:9px;background:var(--bg);cursor:text}
|
||||||
|
.chips:focus-within{border-color:var(--accent)}
|
||||||
|
.chip{display:inline-flex;align-items:center;gap:2px;max-width:100%;padding:3px 3px 3px 10px;border-radius:999px;background:var(--panel2);color:var(--fg);font-size:13px;line-height:1.3;overflow-wrap:anywhere}
|
||||||
|
.chipx{display:grid;place-items:center;flex:none;width:20px;height:20px;padding:0;border-radius:50%;color:var(--dim)}
|
||||||
|
.chipx:hover{background:var(--raise);color:var(--fg)}
|
||||||
|
.chipx .i{width:9px;height:9px}
|
||||||
|
.chips .chipin{flex:1 0 8ch;width:auto;min-width:0;padding:4px 6px;border:0;background:none}
|
||||||
#toasts{position:fixed;bottom:88px;right:18px;display:flex;flex-direction:column;gap:8px;z-index:60}
|
#toasts{position:fixed;bottom:88px;right:18px;display:flex;flex-direction:column;gap:8px;z-index:60}
|
||||||
.toast{
|
.toast{
|
||||||
background:var(--raise);border:1px solid var(--line);border-radius:9px;
|
background:var(--raise);border:1px solid var(--line);border-radius:9px;
|
||||||
@@ -1072,8 +1160,10 @@ body:has(#player.on) #status{padding-bottom:3px}
|
|||||||
|
|
||||||
@media (max-width:820px){
|
@media (max-width:820px){
|
||||||
#shell{grid-template-columns:1fr}
|
#shell{grid-template-columns:1fr}
|
||||||
#sidebar{position:fixed;inset:0 auto 0 0;padding-top:var(--safe-t);width:min(300px,86vw);z-index:42;transform:translateX(-100%);transition:transform .2s;box-shadow:var(--shadow)}
|
/* The shadow only while open: off the screen, the drawer's fell across the page's left edge,
|
||||||
#sidebar.open{transform:none}
|
the top bar's included (#151). */
|
||||||
|
#sidebar{position:fixed;inset:0 auto 0 0;padding-top:var(--safe-t);width:min(300px,86vw);z-index:42;transform:translateX(-100%);transition:transform .2s,box-shadow .2s}
|
||||||
|
#sidebar.open{transform:none;box-shadow:var(--shadow)}
|
||||||
#scrim{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:41}
|
#scrim{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:41}
|
||||||
#player{
|
#player{
|
||||||
position:relative;z-index:39;gap:8px;
|
position:relative;z-index:39;gap:8px;
|
||||||
@@ -1110,8 +1200,8 @@ body:has(#player.on) #status{padding-bottom:3px}
|
|||||||
#dpeek>div{width:calc(100% - 16px);height:100%;overflow:hidden;padding:12px 16px 90px;padding-top:calc(12px + var(--safe-t));background:var(--panel)}
|
#dpeek>div{width:calc(100% - 16px);height:100%;overflow:hidden;padding:12px 16px 90px;padding-top:calc(12px + var(--safe-t));background:var(--panel)}
|
||||||
#dpeek.next>div{margin-left:16px}
|
#dpeek.next>div{margin-left:16px}
|
||||||
#dpeek .encbox{margin:0 0 10px}
|
#dpeek .encbox{margin:0 0 10px}
|
||||||
#dpeek.end{display:flex;align-items:center;padding-left:28px;color:var(--faint);font-size:13px}
|
/* Past the last item, the accent and no words: the colour says it is the end. */
|
||||||
#dpeek.end p{max-width:4.5em;margin:0}
|
#dpeek.end{background:var(--accent)}
|
||||||
/* Going back to the list from the first item: the list waits under a dimmer that lifts as the
|
/* Going back to the list from the first item: the list waits under a dimmer that lifts as the
|
||||||
reader, shadowed along its edge, is drawn off it. */
|
reader, shadowed along its edge, is drawn off it. */
|
||||||
#dpeek.dim{width:100%;z-index:37;background:#000}
|
#dpeek.dim{width:100%;z-index:37;background:#000}
|
||||||
@@ -1206,6 +1296,9 @@ body:has(#player.on) #status{padding-bottom:3px}
|
|||||||
.fhead .sub{font-size:15px}
|
.fhead .sub{font-size:15px}
|
||||||
.acts{gap:8px}
|
.acts{gap:8px}
|
||||||
.acts .btn{min-width:44px;height:44px;padding:0 13px;justify-content:center;font-size:15px}
|
.acts .btn{min-width:44px;height:44px;padding:0 13px;justify-content:center;font-size:15px}
|
||||||
|
/* Seven to a row with no room to spare at 390px: below that an icon button narrows rather than
|
||||||
|
the row breaking, which left Unsubscribe alone on a second row (#146). */
|
||||||
|
.acts .btn.ico{flex:0 1 44px;min-width:0;padding:0}
|
||||||
|
|
||||||
/* Tabs as a segmented control: equal segments on one track, the chosen one lifted. */
|
/* Tabs as a segmented control: equal segments on one track, the chosen one lifted. */
|
||||||
.tabs{flex:1 1 100%;border-radius:10px;padding:2px}
|
.tabs{flex:1 1 100%;border-radius:10px;padding:2px}
|
||||||
@@ -1240,6 +1333,25 @@ body:has(#player.on) #status{padding-bottom:3px}
|
|||||||
.card h3{font-size:22px}
|
.card h3{font-size:22px}
|
||||||
.field label,.field .hint{font-size:13px}
|
.field label,.field .hint{font-size:13px}
|
||||||
.check{font-size:17px}
|
.check{font-size:17px}
|
||||||
|
/* A feed's settings, at a phone's sizes. */
|
||||||
|
.card.fset{padding:0}
|
||||||
|
.fsetbody{padding:20px 16px 24px}
|
||||||
|
.card.fset .fsetfoot{padding:12px 16px calc(12px + var(--safe-b))}
|
||||||
|
.fsethead .art{width:64px;height:64px;border-radius:14px}
|
||||||
|
.fsethead p,.wcap,.cap{font-size:14px}
|
||||||
|
.switch,.flabel{font-size:17px}
|
||||||
|
.switch{min-height:44px}
|
||||||
|
.sw{width:51px;height:31px}
|
||||||
|
.sw i::after{width:25px;height:25px}
|
||||||
|
.sw input:checked+i::after{transform:translateX(20px)}
|
||||||
|
.seg label{padding:7px 4px;border-radius:8px;font-size:14px}
|
||||||
|
.seg label:has(input:checked){box-shadow:0 1px 3px rgba(0,0,0,.22)}
|
||||||
|
/* 16px or more, or Safari zooms the page in on it. */
|
||||||
|
.wcap input[type=number]{font-size:16px;padding:3px 4px}
|
||||||
|
.chips{background:var(--panel2);border-color:var(--edge);border-radius:10px}
|
||||||
|
.chip{background:var(--panel);font-size:15px;padding:5px 4px 5px 12px}
|
||||||
|
.chipx{width:26px;height:26px}
|
||||||
|
.fdl,.field.boxed{border-radius:14px}
|
||||||
.toast{border-radius:14px}
|
.toast{border-radius:14px}
|
||||||
}
|
}
|
||||||
@keyframes sheet{from{transform:translateY(100%)}}
|
@keyframes sheet{from{transform:translateY(100%)}}
|
||||||
@@ -1253,6 +1365,8 @@ body:has(#player.on) #status{padding-bottom:3px}
|
|||||||
:root[data-theme="classic"] #topbar,
|
:root[data-theme="classic"] #topbar,
|
||||||
:root[data-theme="classic"] #status,
|
:root[data-theme="classic"] #status,
|
||||||
:root[data-theme="classic"] #player{background:linear-gradient(#ececec,#c9c9c9);border-color:#8e8e8e;color:#1a1a1a}
|
:root[data-theme="classic"] #player{background:linear-gradient(#ececec,#c9c9c9);border-color:#8e8e8e;color:#1a1a1a}
|
||||||
|
/* The colour iOS takes for above the bar, where it reads no gradient: the top of the metal. */
|
||||||
|
:root[data-theme="classic"] #topbar{background-color:#ececec}
|
||||||
:root[data-theme="classic"] #status{text-align:center}
|
:root[data-theme="classic"] #status{text-align:center}
|
||||||
:root[data-theme="classic"] #sidebar{background:#e7ebf1;border-right-color:#a3a3a3}
|
:root[data-theme="classic"] #sidebar{background:#e7ebf1;border-right-color:#a3a3a3}
|
||||||
:root[data-theme="classic"] #files{background:#f4f4f4}
|
:root[data-theme="classic"] #files{background:#f4f4f4}
|
||||||
@@ -1273,6 +1387,11 @@ body:has(#player.on) #status{padding-bottom:3px}
|
|||||||
:root[data-theme="classic"] .feed.sel .txt small,
|
:root[data-theme="classic"] .feed.sel .txt small,
|
||||||
:root[data-theme="classic"] .place.sel .ico{color:#e6eeff}
|
:root[data-theme="classic"] .place.sel .ico{color:#e6eeff}
|
||||||
:root[data-theme="classic"] #epSearch{border-radius:14px}
|
:root[data-theme="classic"] #epSearch{border-radius:14px}
|
||||||
|
/* The 2004 app had checkboxes, not switches. */
|
||||||
|
:root[data-theme="classic"] .sw{width:auto;height:auto}
|
||||||
|
:root[data-theme="classic"] .sw input{position:static;width:14px;height:14px;opacity:1}
|
||||||
|
:root[data-theme="classic"] .sw i{display:none}
|
||||||
|
:root[data-theme="classic"] .seg label:has(input:checked){background:linear-gradient(#86b0ec,#3a73cf);color:#fff}
|
||||||
:root[data-theme="classic"] .badge{background:#c9302c;color:#fff}
|
:root[data-theme="classic"] .badge{background:#c9302c;color:#fff}
|
||||||
:root[data-theme="classic"] .badge.zero{background:#b4bdc9;color:#fff}
|
:root[data-theme="classic"] .badge.zero{background:#b4bdc9;color:#fff}
|
||||||
:root[data-theme="classic"] .ephead,
|
:root[data-theme="classic"] .ephead,
|
||||||
@@ -1349,7 +1468,7 @@ body:has(#player.on) #status{padding-bottom:3px}
|
|||||||
:root[data-theme="glass"] .toast.bad{border-color:var(--bad)}
|
:root[data-theme="glass"] .toast.bad{border-color:var(--bad)}
|
||||||
/* A narrow screen slides the feed list over the page, so it keeps its shadow. */
|
/* A narrow screen slides the feed list over the page, so it keeps its shadow. */
|
||||||
@media (max-width:820px){
|
@media (max-width:820px){
|
||||||
:root[data-theme="glass"] #sidebar{box-shadow:inset -1px 0 0 var(--glass-hi),var(--shadow)}
|
:root[data-theme="glass"] #sidebar.open{box-shadow:inset -1px 0 0 var(--glass-hi),var(--shadow)}
|
||||||
}
|
}
|
||||||
/* The card is the glass; a lighter scrim leaves something behind it to see through. */
|
/* The card is the glass; a lighter scrim leaves something behind it to see through. */
|
||||||
:root[data-theme="glass"] #modal{background:rgba(0,0,0,.3)}
|
:root[data-theme="glass"] #modal{background:rgba(0,0,0,.3)}
|
||||||
@@ -1368,12 +1487,17 @@ body:has(#player.on) #status{padding-bottom:3px}
|
|||||||
:root[data-theme="glass"] .sidefoot button{border-radius:10px}
|
:root[data-theme="glass"] .sidefoot button{border-radius:10px}
|
||||||
}
|
}
|
||||||
:root[data-theme="glass"] .btn.primary{box-shadow:inset 0 1px 0 rgba(255,255,255,.35)}
|
:root[data-theme="glass"] .btn.primary{box-shadow:inset 0 1px 0 rgba(255,255,255,.35)}
|
||||||
|
:root[data-theme="glass"] .card.fset .fsetfoot{border-top-color:var(--glass-edge);background:color-mix(in srgb,var(--panel) 60%,transparent)}
|
||||||
/* On a phone the rim all round a small capsule or bar read as the outline #53 took away, so the
|
/* On a phone the rim all round a small capsule or bar read as the outline #53 took away, so the
|
||||||
light only catches the top edge there, and the bars at the top and bottom have none. */
|
light only catches the top edge there, and the bars at the top and bottom have none. */
|
||||||
@media (max-width:820px){
|
@media (max-width:820px){
|
||||||
:root[data-theme="glass"]{--glass-rim:inset 0 1px 0 var(--glass-lo)}
|
:root[data-theme="glass"]{--glass-rim:inset 0 1px 0 var(--glass-lo)}
|
||||||
:root[data-theme="glass"] #topbar,
|
:root[data-theme="glass"] #topbar,
|
||||||
:root[data-theme="glass"] #status{box-shadow:none}
|
:root[data-theme="glass"] #status{box-shadow:none}
|
||||||
|
/* Solid, in the colour the glass showed on average. iOS paints the strip above it, behind the
|
||||||
|
status bar and the Dynamic Island, in one flat colour, and over a see-through bar that met it
|
||||||
|
at a seam: near-black above a blue bar (#147). */
|
||||||
|
:root[data-theme="glass"] #topbar{background:var(--glass-bar);-webkit-backdrop-filter:none;backdrop-filter:none}
|
||||||
}
|
}
|
||||||
/* Someone who has asked the system for less transparency, or more contrast, gets solid panels. */
|
/* Someone who has asked the system for less transparency, or more contrast, gets solid panels. */
|
||||||
@media (prefers-reduced-transparency:reduce),(prefers-contrast:more){
|
@media (prefers-reduced-transparency:reduce),(prefers-contrast:more){
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<meta name="color-scheme" content="dark light">
|
<meta name="color-scheme" content="dark light">
|
||||||
|
<meta name="theme-color" content="#0a1726">
|
||||||
<title>iPX</title>
|
<title>iPX</title>
|
||||||
<link rel="icon" type="image/png" sizes="128x128" id="favicon" href="/favicon.png" data-light="/favicon.png" data-dark="/favicon-dark.png">
|
<link rel="icon" type="image/png" sizes="128x128" id="favicon" href="/favicon.png" data-light="/favicon.png" data-dark="/favicon-dark.png">
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
@@ -14,7 +15,7 @@
|
|||||||
<button class="iconbtn" id="burger" title="Feeds" aria-label="Feeds" data-icon="menu"></button>
|
<button class="iconbtn" id="burger" title="Feeds" aria-label="Feeds" data-icon="menu"></button>
|
||||||
<!-- One group per thing acted on, in the order the panes read: feeds, then the selected item.
|
<!-- One group per thing acted on, in the order the panes read: feeds, then the selected item.
|
||||||
A phone hides the item group, which it has no table for. -->
|
A phone hides the item group, which it has no table for. -->
|
||||||
<span id="applogo" title="iPX {version}"><img class="logo onlight" src="/logo.svg" alt="iPX"><img class="logo ondark" src="/logo-dark.svg" alt="iPX"></span>
|
<span id="applogo" title="iPX {version}"><img class="logo onlight" src="/logo.svg" alt="iPX"><img class="logo ondark" src="/logo-dark.svg" alt="iPX"><img class="logo classic" src="/icon.png" alt="iPodderX"></span>
|
||||||
<div class="tgroup">
|
<div class="tgroup">
|
||||||
<button id="addFeed" title="Add a feed" aria-label="Add a feed" data-icon="plus"></button>
|
<button id="addFeed" title="Add a feed" aria-label="Add a feed" data-icon="plus"></button>
|
||||||
<button id="tbRemove" title="Unsubscribe from this feed" aria-label="Unsubscribe from this feed" data-icon="circleMinus" disabled></button>
|
<button id="tbRemove" title="Unsubscribe from this feed" aria-label="Unsubscribe from this feed" data-icon="circleMinus" disabled></button>
|
||||||
@@ -65,6 +66,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="pright">
|
<div id="pright">
|
||||||
|
<button class="iconbtn" id="pdead" aria-pressed="false" aria-label="DeadAir" hidden></button>
|
||||||
<select id="rate" title="Speed">
|
<select id="rate" title="Speed">
|
||||||
<option value="0.8">0.8×</option><option value="1" selected>1×</option>
|
<option value="0.8">0.8×</option><option value="1" selected>1×</option>
|
||||||
<option value="1.25">1.25×</option><option value="1.5">1.5×</option>
|
<option value="1.25">1.25×</option><option value="1.5">1.5×</option>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
/* ---------------- modals ---------------- */
|
/* ---------------- modals ---------------- */
|
||||||
function openModal(html: string, wide?: boolean){
|
/// `cls` names a dialog with a frame of its own. Set whole each time, so it never outlives its dialog.
|
||||||
|
function openModal(html: string, wide?: boolean, cls?: string){
|
||||||
$('#modalCard').innerHTML=html;
|
$('#modalCard').innerHTML=html;
|
||||||
$('#modalCard').classList.toggle('wide',!!wide);
|
$('#modalCard').className='card'+(wide?' wide':'')+(cls?' '+cls:'');
|
||||||
$('#modal').classList.add('on');
|
$('#modal').classList.add('on');
|
||||||
}
|
}
|
||||||
function closeModal(){
|
function closeModal(){
|
||||||
@@ -137,7 +138,8 @@ function drawDirectory(box=$('#directory'), focus?: string){
|
|||||||
<div class="show">${artHTML(show.image,NAME(show))}<div class="meta"><h2>${esc(NAME(show))}</h2>${
|
<div class="show">${artHTML(show.image,NAME(show))}<div class="meta"><h2>${esc(NAME(show))}</h2>${
|
||||||
// Its category's page is one click away from here.
|
// Its category's page is one click away from here.
|
||||||
show.category?`<button type="button" class="dlink" data-go="${esc(show.category)}">${esc(show.subcategory||show.category)}</button>`:''}
|
show.category?`<button type="button" class="dlink" data-go="${esc(show.category)}">${esc(show.subcategory||show.category)}</button>`:''}
|
||||||
<p class="sub">${n?`${n} ${n===1?'person here subscribes':'people here subscribe'}`:'Nobody here subscribes yet'}</p>
|
${n?`<p class="sub">${n} ${n===1?'person here subscribes':'people here subscribe'}</p>`:''}
|
||||||
|
<p class="sub dfail" id="dfail" hidden></p>
|
||||||
<div class="acts"><button class="btn ico primary" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.plus}</button></div></div></div>
|
<div class="acts"><button class="btn ico primary" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.plus}</button></div></div></div>
|
||||||
<div class="about" id="dabout" hidden><p></p><button type="button" class="dlink" aria-expanded="false" hidden>More</button></div>`
|
<div class="about" id="dabout" hidden><p></p><button type="button" class="dlink" aria-expanded="false" hidden>More</button></div>`
|
||||||
+sec(show.podcast?'Latest episodes':'Latest posts','<div class="latest" id="dshow"><p class="hint">Loading…</p></div>');
|
+sec(show.podcast?'Latest episodes':'Latest posts','<div class="latest" id="dshow"><p class="hint">Loading…</p></div>');
|
||||||
@@ -217,6 +219,8 @@ async function fillShow(box,p){
|
|||||||
more.textContent=open?'Less':'More'; more.setAttribute('aria-expanded',String(open));
|
more.textContent=open?'Less':'More'; more.setAttribute('aria-expanded',String(open));
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
// A broken feed says so, or it reads as a quiet one (#142).
|
||||||
|
if(got.failing){ const f=$('#dfail',box); f.textContent=got.failing; f.hidden=false; }
|
||||||
const items=got.items;
|
const items=got.items;
|
||||||
list.innerHTML=items.length?items.map(e=>{
|
list.innerHTML=items.length?items.map(e=>{
|
||||||
const name=entryName(e), text=name.derived?'':plainText(e.description).slice(0,500);
|
const name=entryName(e), text=name.derived?'':plainText(e.description).slice(0,500);
|
||||||
@@ -224,7 +228,8 @@ async function fillShow(box,p){
|
|||||||
return `<article class="lep"><b>${e.link?`<a href="${esc(e.link)}" target="_blank" rel="noopener">${esc(name.text)}</a>`:esc(name.text)}</b>`
|
return `<article class="lep"><b>${e.link?`<a href="${esc(e.link)}" target="_blank" rel="noopener">${esc(name.text)}</a>`:esc(name.text)}</b>`
|
||||||
+(text?`<p>${esc(text)}</p>`:'')
|
+(text?`<p>${esc(text)}</p>`:'')
|
||||||
+(when.length?`<small>${when.map(s=>`<span>${s}</span>`).join('<span class="dot"></span>')}</small>`:'')+'</article>';
|
+(when.length?`<small>${when.map(s=>`<span>${s}</span>`).join('<span class="dot"></span>')}</small>`:'')+'</article>';
|
||||||
}).join(''):`<p class="hint">Nothing read from it yet. A feed nobody here subscribes to is checked once a day.</p>`;
|
}).join(''):`<p class="hint">Nothing read from it yet.${p.subscribers||got.failing?''
|
||||||
|
:' A feed nobody here subscribes to is checked once a day.'}</p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A category as a tile: its name, how many feeds, and the covers of its three most subscribed
|
/// A category as a tile: its name, how many feeds, and the covers of its three most subscribed
|
||||||
@@ -358,7 +363,15 @@ function due(ts){
|
|||||||
/// Your settings: the theme, your subscriptions as OPML, and, to read, what the server does
|
/// Your settings: the theme, your subscriptions as OPML, and, to read, what the server does
|
||||||
/// with feeds. The server's own settings, the accounts and the log are on /admin, which only an
|
/// with feeds. The server's own settings, the accounts and the log are on /admin, which only an
|
||||||
/// admin is sent (issue #19); this used to hold them too, shown to admins only.
|
/// admin is sent (issue #19); this used to hold them too, shown to admins only.
|
||||||
|
function deadSavedText(me){
|
||||||
|
const s=me&&me.deadair_saved||0;
|
||||||
|
return s?`It has saved you ${spanText(s)} of dead air, on episodes you heard three quarters of or more.`
|
||||||
|
:'Turn it on in a show\'s settings, or beside the speed while it plays, to cut the dead air from it.';
|
||||||
|
}
|
||||||
|
|
||||||
async function prefsModal(){
|
async function prefsModal(){
|
||||||
|
// Fresh, so the time saved counts what was heard since the page loaded.
|
||||||
|
api('/api/me').then(u=>{ S.me=u; const h=$('#sdeadsaved'); if(h) h.textContent=deadSavedText(u); }).catch(()=>{});
|
||||||
const g = await api('/api/settings');
|
const g = await api('/api/settings');
|
||||||
const admin = !!(S.me&&S.me.admin);
|
const admin = !!(S.me&&S.me.admin);
|
||||||
openModal(`<button class="iconbtn cardx" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button><h3>Settings</h3>
|
openModal(`<button class="iconbtn cardx" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button><h3>Settings</h3>
|
||||||
@@ -369,6 +382,8 @@ async function prefsModal(){
|
|||||||
<select id="smode">${Object.entries(MODES).map(([k,t])=>
|
<select id="smode">${Object.entries(MODES).map(([k,t])=>
|
||||||
`<option value="${k}"${theme.mode===k?' selected':''}>${esc(t)}</option>`).join('')}</select>
|
`<option value="${k}"${theme.mode===k?' selected':''}>${esc(t)}</option>`).join('')}</select>
|
||||||
<span class="hint">Auto follows your system's light/dark setting.</span></div>
|
<span class="hint">Auto follows your system's light/dark setting.</span></div>
|
||||||
|
<div class="field"><label>DeadAir</label>
|
||||||
|
<span class="hint" id="sdeadsaved">${deadSavedText(S.me)}</span></div>
|
||||||
<div class="field"><label>Subscriptions</label>
|
<div class="field"><label>Subscriptions</label>
|
||||||
<div class="inline">
|
<div class="inline">
|
||||||
<!-- Words as well as icons: a floppy disk and a plus mean nothing on their own here. -->
|
<!-- Words as well as icons: a floppy disk and a plus mean nothing on their own here. -->
|
||||||
@@ -382,7 +397,7 @@ async function prefsModal(){
|
|||||||
<span class="hint">Comma separated words or phrases, in every feed you read. An item with
|
<span class="hint">Comma separated words or phrases, in every feed you read. An item with
|
||||||
one in its title or text is hidden from you and not downloaded for you. Each feed's
|
one in its title or text is hidden from you and not downloaded for you. Each feed's
|
||||||
settings can add more.</span></div>
|
settings can add more.</span></div>
|
||||||
<div class="field"><label for="tokname">API tokens</label>
|
<div class="field boxed"><label for="tokname">API tokens</label>
|
||||||
<div id="tokens"></div>
|
<div id="tokens"></div>
|
||||||
<div class="inline"><input type="text" id="tokname" placeholder="What it is for">
|
<div class="inline"><input type="text" id="tokname" placeholder="What it is for">
|
||||||
<button class="btn" id="tokadd" title="Make an API token" aria-label="Make an API token">${ICON.plus} Make</button></div>
|
<button class="btn" id="tokadd" title="Make an API token" aria-label="Make an API token">${ICON.plus} Make</button></div>
|
||||||
@@ -436,73 +451,176 @@ async function drawTokens(){
|
|||||||
|
|
||||||
const splitWords=(s: string)=>s.split(',').map(w=>w.trim()).filter(Boolean);
|
const splitWords=(s: string)=>s.split(',').map(w=>w.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
/// Words as chips, for the words that pick or hide items: each word or phrase ends with Enter or a
|
||||||
|
/// comma, and comes off with its x or Backspace. One comma-separated box ran one phrase into the
|
||||||
|
/// next (#167).
|
||||||
|
const chipHTML=(w: string)=>`<span class="chip" data-w="${esc(w)}">${esc(w)}<button type="button" class="chipx"
|
||||||
|
title="Remove ${esc(w)}" aria-label="Remove ${esc(w)}">${ICON.close}</button></span>`;
|
||||||
|
/// What goes inside a chip box. The box itself is written out where it is used, id and all: the page
|
||||||
|
/// check finds ids by reading them, and one put together here would read as missing.
|
||||||
|
const CHIP_HINT='Add a word or phrase';
|
||||||
|
function chipsHTML(words: string[], input: string){
|
||||||
|
return `${words.map(chipHTML).join('')}<input type="text" class="chipin" id="${input}"
|
||||||
|
placeholder="${words.length?'':CHIP_HINT}" enterkeyhint="done" autocomplete="off" spellcheck="false">`;
|
||||||
|
}
|
||||||
|
/// The words in a chip box, with whatever is typed and not yet a chip.
|
||||||
|
function chipWords(box){
|
||||||
|
return [...$$('.chip',box).map(c=>c.dataset.w), ...splitWords($('.chipin',box).value)];
|
||||||
|
}
|
||||||
|
function wireChips(box){
|
||||||
|
const input=$('.chipin',box);
|
||||||
|
// The hint only in an empty box: beside chips it reads as one more.
|
||||||
|
const hint=()=>{ input.placeholder=$$('.chip',box).length?'':CHIP_HINT; };
|
||||||
|
const add=()=>{
|
||||||
|
const have=$$('.chip',box).map(c=>c.dataset.w.toLowerCase());
|
||||||
|
for(const w of splitWords(input.value)){
|
||||||
|
if(have.includes(w.toLowerCase())) continue;
|
||||||
|
input.insertAdjacentHTML('beforebegin',chipHTML(w)); have.push(w.toLowerCase());
|
||||||
|
}
|
||||||
|
input.value=''; hint();
|
||||||
|
};
|
||||||
|
input.onkeydown=e=>{
|
||||||
|
if(e.key==='Enter'||e.key===','){ e.preventDefault(); add(); }
|
||||||
|
else if(e.key==='Backspace'&&!input.value){ input.previousElementSibling?.remove(); hint(); }
|
||||||
|
};
|
||||||
|
// Typed and left without Enter is still meant: Save takes it either way.
|
||||||
|
input.onblur=add;
|
||||||
|
box.onclick=e=>{
|
||||||
|
const x=e.target.closest('.chipx');
|
||||||
|
if(x){ x.parentElement.remove(); hint(); }
|
||||||
|
input.focus();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Your settings for one feed, under its address (#167). What it says is what will happen, in this feed's numbers: the download limit was a bare
|
||||||
|
/// box whose blank, number and 0 were explained in bold small print.
|
||||||
function settingsModal(f, newUrl?: string){
|
function settingsModal(f, newUrl?: string){
|
||||||
const isGroup = S.feeds.some(c=>c.group===f.id);
|
const kids=S.feeds.filter(c=>c.group===f.id).length;
|
||||||
openModal(`<h3>${esc(f.title||f.id)}</h3>
|
const isGroup=kids>0;
|
||||||
${isGroup?`<p class="hint" style="margin:-6px 0 12px">This is ${isPatreon(f)?'a Patreon creator':'an OPML subscription'}. These
|
const parent=f.managed?S.feeds.find(p=>p.id===f.group):null;
|
||||||
settings apply to it and are inherited by every feed inside it.</p>`:''}
|
const admin=!!(S.me&&S.me.admin);
|
||||||
${f.managed?`<p class="hint" style="margin:-6px 0 12px">This feed comes from
|
const name=f.title||f.id;
|
||||||
${isPatreon(S.feeds.find(p=>p.id===f.group))?'a Patreon creator':'an OPML subscription'} and follows its settings. Saving anything here gives it its own entry in
|
// Blank follows the server's default; 0 is every item (#166).
|
||||||
config.toml, and it stops following the subscription's settings.</p>`:''}
|
let mode=f.max_new_per_check==null?'default':f.max_new_per_check===0?'all':'newest';
|
||||||
<p class="hint" style="margin:-4px 0 10px">These are <b>your</b> settings for this feed.
|
let newest=f.max_new_per_check>0?f.max_new_per_check:(globalMax||3);
|
||||||
Everyone else keeps their own.</p>
|
const whose=isGroup
|
||||||
<div class="field"><label>Keywords</label>
|
? `Your settings for the ${plural(kids,'show')} in this ${isPatreon(f)?'Patreon creator':'OPML subscription'}. A show you set on its own keeps its own.`
|
||||||
<input type="text" id="skw" value="${esc(f.keywords.join(', '))}">
|
: parent
|
||||||
<span class="hint">Comma separated. Empty takes everything.</span></div>
|
? `It comes from ${esc(parent.title||parent.id)}, and follows your settings there until you save these.`
|
||||||
${isGroup?'':`<div class="field"><label for="sblock">Hide items mentioning</label>
|
: 'Your settings for this feed. Everyone else who reads it keeps their own.';
|
||||||
<input type="text" id="sblock" value="${esc((f.blocked||[]).join(', '))}">
|
const sw=(input: string, text: string, cls='')=>`<label class="switch${cls}"><span>${text}</span><span class="sw">${input}<i></i></span></label>`;
|
||||||
<span class="hint">Comma separated words or phrases. An item with one in its title or text
|
openModal(`<div class="fsetbody">
|
||||||
is hidden from you and not downloaded for you, as well as those your Settings hide
|
<header class="fsethead">${artHTML(f.image,name)}<div><h3>${esc(name)}</h3><p>${whose}</p></div></header>
|
||||||
everywhere.</span></div>`}
|
<div class="field faddr"><label for="surl">Address</label>
|
||||||
<div class="field"><label>Newest episodes to download</label>
|
<div class="inline"><input type="text" id="surl" value="${esc(newUrl||f.url)}" spellcheck="false"${admin?'':' readonly'}>
|
||||||
<input type="number" id="smax" min="0" value="${f.max_new_per_check??''}">
|
<button type="button" class="btn ico" id="scopy" title="Copy the address" aria-label="Copy the address">${ICON.copy}</button></div>
|
||||||
<span class="hint">Blank follows the global default (${globalMax}). Older episodes stay
|
${newUrl?'<p class="moved">The feed says it has moved here. Save to follow it.</p>':''}</div>
|
||||||
listed to download by hand. <b>0 means every episode</b>, the whole back catalogue.</span></div>
|
<div class="fdl">${sw(`<input type="checkbox" role="switch" id="sauto"${f.auto_download?' checked':''}>`,'Download automatically',' lead')}
|
||||||
<label class="check"><input type="checkbox" id="sauto" ${f.auto_download?'checked':''}> Download new items automatically</label>
|
<div class="fdeps${f.auto_download?'':' off'}" id="sdeps"><div><div class="fdepsin">
|
||||||
<label class="check"><input type="checkbox" id="sexp" ${f.allow_explicit?'checked':''}> Allow items marked explicit</label>
|
<div class="tabs seg" role="radiogroup" aria-label="How many to download">${
|
||||||
<div class="field"><label>Feed URL</label>
|
[['default',`Default (${globalMax||'all'})`],['newest','Newest'],['all','All']].map(([v,t])=>
|
||||||
<div class="inline">
|
`<label><input type="radio" name="swin" value="${v}"${mode===v?' checked':''}>${t}</label>`).join('')}</div>
|
||||||
<input type="text" id="surl" value="${esc(newUrl||f.url)}" spellcheck="false" ${S.me&&S.me.admin?'':'readonly'}>
|
<div class="epwin" id="sepwin" aria-hidden="true"></div>
|
||||||
<button type="button" class="btn ico" id="scopy" title="Copy the URL" aria-label="Copy the URL">${ICON.copy}</button>
|
<p class="wcap" id="swcap"></p>
|
||||||
|
<div class="ffield"><label class="flabel" for="skwin">Only items that mention</label>
|
||||||
|
<div class="chips" id="skw">${chipsHTML(f.keywords,'skwin')}</div>
|
||||||
|
<p class="cap">Any one of these will do, and a phrase needs all its words. Empty takes everything.</p></div>
|
||||||
|
${sw(`<input type="checkbox" role="switch" id="sexp"${f.allow_explicit?' checked':''}>`,'Include explicit items')}
|
||||||
|
</div></div></div></div>
|
||||||
|
<div class="ffield">${sw(`<input type="checkbox" role="switch" id="sdead"${f.deadair?' checked':''}>`,'DeadAir',' lead')}
|
||||||
|
<p class="cap">Cuts the dead air: long silences in ${isGroup?'these shows':'this show'} shrink to a beat, so
|
||||||
|
episodes finish sooner and every voice keeps its own pace. Leave it off for stories whose pauses
|
||||||
|
are part of the telling.</p></div>
|
||||||
|
${isGroup?'':`<div class="ffield"><label class="flabel" for="sblockin">Hide items that mention</label>
|
||||||
|
<div class="chips" id="sblock">${chipsHTML(f.blocked||[],'sblockin')}</div>
|
||||||
|
<p class="cap">Whole words or phrases. Hidden from your list and not downloaded for you, along
|
||||||
|
with what Settings hides everywhere.</p></div>`}
|
||||||
</div>
|
</div>
|
||||||
<span class="hint">${S.me&&S.me.admin
|
<div class="cardacts fsetfoot"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
||||||
? `Shared with everyone reading this feed. Editing it keeps every item and download —
|
<button class="btn ico primary" id="ssave" title="Save" aria-label="Save">${ICON.check}</button></div>`, false, 'fset');
|
||||||
handy when an auth token in the URL is rotated. The feed is re-checked from scratch
|
|
||||||
on the next scan.`
|
// What only matters while it downloads on its own folds away, out of reach, when it does not.
|
||||||
: `The same for everyone reading this feed, so only an admin can change it.`}</span></div>
|
const deps=$('#sdeps'), inner=$('.fdepsin',deps);
|
||||||
${S.me&&S.me.admin?`<div class="field"><label>Download folder (shared)</label>
|
inner.inert=!f.auto_download;
|
||||||
<input type="text" id="sfolder" value="${esc(f.folder||'')}" placeholder="${esc(f.title||f.id)}">
|
$('#sauto').onchange=e=>{ deps.classList.toggle('off',!e.target.checked); inner.inert=!e.target.checked; };
|
||||||
<span class="hint">Where the files land. There is one copy however many people
|
|
||||||
subscribe, so this is the same for everyone.</span></div>`:''}
|
// The download limit, said as what it does. A number in a box hid that it is a window on the
|
||||||
${S.me&&S.me.admin?`<div class="field"><label>Directory category (shared)</label>
|
// newest items, not a pace through the back catalogue (#97).
|
||||||
<input type="text" id="scat" list="scats" value="${esc(f.category||'')}" placeholder="${esc(f.feed_category||'None')}">
|
const each=isGroup?' of each show':'';
|
||||||
<datalist id="scats"></datalist>
|
const older=' Older ones stay listed, to download by hand.';
|
||||||
<span class="hint">${f.feed_category
|
const caption=()=>mode==='newest'
|
||||||
? `The feed names its own, ${esc(f.feed_category)}, and the Directory uses that.`
|
? `Downloads the newest <input type="number" id="smax" min="1" value="${newest}"
|
||||||
: `The feed names none, so the Directory files it under this. Pick one already listed where it fits.`}</span></div>`:''}
|
aria-label="How many of the newest to download">${each} on its own.${older}`
|
||||||
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
: mode==='all'
|
||||||
<button class="btn ico primary" id="ssave" title="Save" aria-label="Save">${ICON.check}</button></div>`);
|
? `Downloads every item${each}: the whole back catalogue${!isGroup&&f.entries?`, ${f.entries} so far`:''}, and each new one.`
|
||||||
|
: globalMax
|
||||||
|
? `The server's default: downloads the newest ${globalMax}${each} on its own.${older}`
|
||||||
|
: `The server's default: downloads every item${each}, the whole back catalogue.`;
|
||||||
|
const limit=()=>mode==='all'||(mode==='default'&&!globalMax)?Infinity:mode==='default'?globalMax:newest;
|
||||||
|
let bars=[];
|
||||||
|
const draw=(words: boolean)=>{
|
||||||
|
// The newest items with a file, as hold_back counts them: one whose files a filter all
|
||||||
|
// skipped holds no place.
|
||||||
|
let left=limit();
|
||||||
|
// Everything: the older ones past the strip are lit too, in words.
|
||||||
|
$('#sepwin')?.classList.toggle('all',left===Infinity);
|
||||||
|
for(const b of bars){
|
||||||
|
const on=!b.classList.contains('nofile')&&left>0;
|
||||||
|
if(on) left--;
|
||||||
|
b.classList.toggle('on',on);
|
||||||
|
}
|
||||||
|
if(!words) return;
|
||||||
|
$('#swcap').innerHTML=caption();
|
||||||
|
const n=$('#smax');
|
||||||
|
if(n) n.oninput=()=>{ newest=Math.max(1,Math.floor(Number(n.value))||1); draw(false); };
|
||||||
|
};
|
||||||
|
for(const r of $$('[name=swin]')) r.onchange=()=>{ mode=r.value; draw(true); };
|
||||||
|
draw(true);
|
||||||
|
// The feed's own newest items, each as tall as it is long, so the strip reads as this show.
|
||||||
|
if(!isGroup) api(`/api/feeds/${encodeURIComponent(f.id)}/entries?limit=40`).then(page=>{
|
||||||
|
const items=page?.entries||[];
|
||||||
|
const strip=$('#sepwin'); if(!items.length||!strip) return;
|
||||||
|
const longest=Math.max(1,...items.map(e=>e.duration||0));
|
||||||
|
strip.innerHTML=items.map((e,i)=>{
|
||||||
|
const file=e.enclosures.some(x=>x.state!=='skipped');
|
||||||
|
const h=e.duration?Math.round(30+70*Math.sqrt(e.duration/longest)):60;
|
||||||
|
return `<i class="${file?'':'nofile'}" style="--h:${h}%;--n:${i}"></i>`;
|
||||||
|
}).join('')+(f.entries>items.length?`<small>+${f.entries-items.length} older</small>`:'');
|
||||||
|
bars=$$('i',strip);
|
||||||
|
draw(false);
|
||||||
|
// Drag along it to set how many: as far as the finger, the newest that far down.
|
||||||
|
const pick=(x: number)=>{
|
||||||
|
let n=0;
|
||||||
|
for(const b of bars){
|
||||||
|
if(!b.classList.contains('nofile')) n++;
|
||||||
|
if(x<=b.getBoundingClientRect().right) break;
|
||||||
|
}
|
||||||
|
newest=Math.max(1,n);
|
||||||
|
if(mode!=='newest'){ mode='newest'; $('[name=swin][value=newest]').checked=true; draw(true); }
|
||||||
|
else { $('#smax').value=String(newest); draw(false); }
|
||||||
|
};
|
||||||
|
strip.onpointerdown=e=>{ strip.setPointerCapture(e.pointerId); pick(e.clientX); };
|
||||||
|
strip.onpointermove=e=>{ if(strip.hasPointerCapture(e.pointerId)) pick(e.clientX); };
|
||||||
|
}).catch(()=>{});
|
||||||
|
|
||||||
|
wireChips($('#skw'));
|
||||||
|
if($('#sblock')) wireChips($('#sblock'));
|
||||||
$('#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
|
// Opened to follow a feed that moved: the new address is what there is to look at.
|
||||||
// than starting a second chip beside it.
|
if(newUrl){ $('#surl').scrollIntoView({block:'center'}); $('#surl').focus(); }
|
||||||
if($('#scats')) api('/api/directory').then(rows=>{ $('#scats').innerHTML=[...new Set((rows||[])
|
|
||||||
.flatMap(p=>[p.category,p.subcategory]).filter(Boolean))].sort().map(c=>`<option value="${esc(c)}">`).join(''); }).catch(()=>{});
|
|
||||||
$('#ssave').onclick=async()=>{
|
$('#ssave').onclick=async()=>{
|
||||||
const max=$('#smax').value;
|
|
||||||
try{
|
try{
|
||||||
const patch: Record<string, unknown>={
|
const patch: Record<string, unknown>={
|
||||||
keywords:splitWords($('#skw').value),
|
keywords:chipWords($('#skw')),
|
||||||
max_new_per_check:max===''?null:Number(max),
|
max_new_per_check:mode==='default'?null:mode==='all'?0:newest,
|
||||||
auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked};
|
auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked, deadair:$('#sdead').checked};
|
||||||
if($('#sblock')) patch.blocked=splitWords($('#sblock').value);
|
if($('#sblock')) patch.blocked=chipWords($('#sblock'));
|
||||||
// The shared half is an admin's to change, and the API refuses it from anyone else.
|
// The address is the same for everyone: an admin's to change, for a feed that moved, and the
|
||||||
if(S.me&&S.me.admin){
|
// API refuses it from anyone else. The folder and category are not this dialog's at all.
|
||||||
patch.url=$('#surl').value.trim();
|
if(admin) patch.url=$('#surl').value.trim();
|
||||||
patch.folder=$('#sfolder').value.trim()||null;
|
|
||||||
patch.category=$('#scat').value.trim()||null;
|
|
||||||
}
|
|
||||||
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify(patch)});
|
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify(patch)});
|
||||||
closeModal(); toast('Saved — applies on the next scan');
|
closeModal(); toast('Saved. It applies from the next check.');
|
||||||
await loadFeeds(true); renderFeed(); loadEntries();
|
await loadFeeds(true); renderFeed(); loadEntries();
|
||||||
}catch(e){ toast(e.message,true); }
|
}catch(e){ toast(e.message,true); }
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
/* ---------------- live events ---------------- */
|
/* ---------------- live events ---------------- */
|
||||||
let sse;
|
let sse;
|
||||||
|
/// The feed list and the open list, taken again from the server. The events keep both current,
|
||||||
|
/// and one missed is a count wrong until a reload: reading an item sends none to your other
|
||||||
|
/// pages, so one read on another device stayed unread here, and an iPhone stops the stream while
|
||||||
|
/// the app is in the background, when everything is missed (#168).
|
||||||
|
const catchUp=(()=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(()=>{
|
||||||
|
loadFeeds(true).catch(()=>{});
|
||||||
|
if(S.feed) loadEntries().catch(()=>{});
|
||||||
|
},300); }; })();
|
||||||
|
document.addEventListener('visibilitychange',()=>{ if(!document.hidden) catchUp(); });
|
||||||
|
let dropped=false;
|
||||||
function connect(){
|
function connect(){
|
||||||
sse=new EventSource('/api/events');
|
sse=new EventSource('/api/events');
|
||||||
|
// Back after a drop: whatever was sent meanwhile went to nobody.
|
||||||
|
sse.onopen=()=>{ if(dropped){ dropped=false; catchUp(); } };
|
||||||
const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
|
const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
|
||||||
const refreshEntries=soon(()=>{ if(S.feed) loadEntries(); });
|
const refreshEntries=soon(()=>{ if(S.feed) loadEntries(); });
|
||||||
// Every scan's events reach everyone; only this person's feeds are theirs to show or refresh.
|
// Every scan's events reach everyone; only this person's feeds are theirs to show or refresh.
|
||||||
@@ -48,7 +60,7 @@ function connect(){
|
|||||||
// page reloads; reload the list here when a scan synced an OPML if that ever matters.
|
// page reloads; reload the list here when a scan synced an OPML if that ever matters.
|
||||||
else if(ev.ev==='scan_done'){ scanning.clear(); paintScanning(); if(ev.feeds) refreshEntries(); }
|
else if(ev.ev==='scan_done'){ scanning.clear(); paintScanning(); if(ev.feeds) refreshEntries(); }
|
||||||
};
|
};
|
||||||
sse.onerror=()=>{ sse.close(); setTimeout(connect,4000); };
|
sse.onerror=()=>{ sse.close(); dropped=true; setTimeout(connect,4000); };
|
||||||
}
|
}
|
||||||
connect();
|
connect();
|
||||||
loadFeeds();
|
loadFeeds();
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ document.addEventListener('touchmove', ev => {
|
|||||||
}, {passive: false});
|
}, {passive: false});
|
||||||
|
|
||||||
/// On a phone, what lies beside the reader while it is dragged by dx: the item it will move on
|
/// On a phone, what lies beside the reader while it is dragged by dx: the item it will move on
|
||||||
/// to, a note that there are no more, or, going back to the list, a dimmer over the list. The
|
/// to, the accent past the last one, or, going back to the list, a dimmer over the list. The
|
||||||
/// reader sits over the list there, and moving it alone showed the list through the gap it left
|
/// reader sits over the list there, and moving it alone showed the list through the gap it left
|
||||||
/// (issue #52). Kept outside #content, which every feed render rebuilds, and inert, since its
|
/// (issue #52). Kept outside #content, which every feed render rebuilds, and inert, since its
|
||||||
/// copy of the reader's markup repeats the reader's ids.
|
/// copy of the reader's markup repeats the reader's ids.
|
||||||
@@ -109,7 +109,7 @@ function beside(dx: number){
|
|||||||
}
|
}
|
||||||
if(p.dataset.key !== kind + (e?.guid || '')){
|
if(p.dataset.key !== kind + (e?.guid || '')){
|
||||||
p.dataset.key = kind + (e?.guid || ''); p.className = kind;
|
p.dataset.key = kind + (e?.guid || ''); p.className = kind;
|
||||||
p.innerHTML = e ? `<div>${detailHtml(e)}</div>` : kind === 'end' ? '<p>No more items</p>' : '';
|
p.innerHTML = e ? `<div>${detailHtml(e)}</div>` : '';
|
||||||
}
|
}
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ function renderEntries(){
|
|||||||
const pane=$('#list'), top=pane?pane.scrollTop:0;
|
const pane=$('#list'), top=pane?pane.scrollTop:0;
|
||||||
box.innerHTML='';
|
box.innerHTML='';
|
||||||
if(!S.entries.length){
|
if(!S.entries.length){
|
||||||
box.innerHTML=`<p class="empty">${S.q?'Nothing matches that search.':'Nothing here yet — try Scan now.'}</p>`;
|
// Said only for a search, where the words typed are why; an empty feed or tab speaks for itself.
|
||||||
|
if(S.q) box.innerHTML='<p class="empty">Nothing matches that search.</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for(const e of S.entries) box.appendChild(epEl(e));
|
for(const e of S.entries) box.appendChild(epEl(e));
|
||||||
@@ -77,6 +78,7 @@ function epEl(e){
|
|||||||
num&&`<span>${num}</span>`,
|
num&&`<span>${num}</span>`,
|
||||||
left&&`<span>${left}</span>`,
|
left&&`<span>${left}</span>`,
|
||||||
others>0&&`<span>+${others} more file${others===1?'':'s'}</span>`,
|
others>0&&`<span>+${others} more file${others===1?'':'s'}</span>`,
|
||||||
|
enc&&enc.deadair>0&&`<span title="DeadAir cut its dead air">${shorter(enc.deadair)}</span>`,
|
||||||
enc&&enc.last_error&&enc.state==='error'&&`<span style="color:var(--bad)">${esc(enc.last_error)}</span>`,
|
enc&&enc.last_error&&enc.state==='error'&&`<span style="color:var(--bad)">${esc(enc.last_error)}</span>`,
|
||||||
].filter(Boolean).join('<span class="dot"></span>')}</div>
|
].filter(Boolean).join('<span class="dot"></span>')}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -265,7 +267,7 @@ function showDetail(e){
|
|||||||
syncTools(e);
|
syncTools(e);
|
||||||
if(!e){
|
if(!e){
|
||||||
dropTurned(S.sel);
|
dropTurned(S.sel);
|
||||||
box.innerHTML='<p class="empty">Pick an item to read it.</p>';
|
box.innerHTML='';
|
||||||
if(files) files.innerHTML='<p class="empty">No files</p>';
|
if(files) files.innerHTML='<p class="empty">No files</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -300,13 +302,13 @@ function detailHtml(e){
|
|||||||
${/* Joined, so a missing date or number leaves no stray dot behind. */
|
${/* Joined, so a missing date or number leaves no stray dot behind. */
|
||||||
[f&&esc(f.title||f.id), num, dateOf(e.published), e.duration&&clock(e.duration)]
|
[f&&esc(f.title||f.id), num, dateOf(e.published), e.duration&&clock(e.duration)]
|
||||||
.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'}"
|
<span class="dacts"><button class="btn ico" data-a="read" title="Mark ${e.read?'unread':'read'}"
|
||||||
aria-label="Mark ${e.read?'unread':'read'}" aria-pressed="${!!e.read}">${e.read?ICON.check:ICON.unread}</button>
|
aria-label="Mark ${e.read?'unread':'read'}" aria-pressed="${!!e.read}">${e.read?ICON.check:ICON.unread}</button>
|
||||||
<button class="btn ico" data-a="flag" title="${e.flagged?'Pinned: never deleted. Unpin':'Pin, 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?'Unpin':'Pin'}" aria-pressed="${!!e.flagged}">${e.flagged?ICON.pinOn:ICON.pin}</button>
|
aria-label="${e.flagged?'Unpin':'Pin'}" aria-pressed="${!!e.flagged}">${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>
|
||||||
<button class="btn ico" data-a="share" title="Share" aria-label="Share">${ICON.share}</button>`:''}
|
<button class="btn ico" data-a="share" title="Share" aria-label="Share">${ICON.share}</button>`:''}</span>
|
||||||
</div>
|
</div>
|
||||||
${narrow?encs:''}
|
${narrow?encs:''}
|
||||||
<div class="dbody">${(e.description&&e.description.trim())||'<em>No show notes.</em>'}</div>`;
|
<div class="dbody">${(e.description&&e.description.trim())||'<em>No show notes.</em>'}</div>`;
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
/* ---------------- player ---------------- */
|
/* ---------------- player ---------------- */
|
||||||
const audio=$('#audio');
|
const audio=$('#audio');
|
||||||
const player: {guid: string|null, feed: string|null, entry: any, enc?: number, moved?: boolean,
|
const player: {guid: string|null, feed: string|null, entry: any, enc?: number, src?: string, moved?: boolean,
|
||||||
saveAt: number, marked: boolean}={guid:null,feed:null,entry:null,saveAt:0,marked:false};
|
saveAt: number, marked: boolean}={guid:null,feed:null,entry:null,saveAt:0,marked:false};
|
||||||
|
|
||||||
|
/// Where a file plays from: DeadAir's copy when the server says this person plays it (#171),
|
||||||
|
/// whose times the entry is already given in, else the file itself.
|
||||||
|
const mediaSrc=enc=>enc.deadair!=null?`/media/${enc.id}/deadair`:`/media/${enc.id}`;
|
||||||
|
|
||||||
// Marked read when an item has actually been listened to -- at the end, or past 90%.
|
// Marked read when an item has actually been listened to -- at the end, or past 90%.
|
||||||
// NOT on play: doing that made the item vanish from the Unread list the instant
|
// NOT on play: doing that made the item vanish from the Unread list the instant
|
||||||
// you pressed play, which looks exactly like it went missing.
|
// you pressed play, which looks exactly like it went missing.
|
||||||
@@ -20,14 +24,15 @@ function play(e,enc=e.enclosures.find(isPlayable)){
|
|||||||
toast(e.enclosures.some(x=>x.path) ? 'That file is not audio or video' : 'Not downloaded yet', true);
|
toast(e.enclosures.some(x=>x.path) ? 'That file is not audio or video' : 'Not downloaded yet', true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 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. So
|
||||||
const resuming = player.guid===e.guid && player.enc===enc.id;
|
// does the other version of the same file, from the place the entry gives in its clock.
|
||||||
|
const resuming = player.guid===e.guid && player.enc===enc.id && player.src===mediaSrc(enc);
|
||||||
// Every play button is a pause button for what is playing, as the player bar's is.
|
// Every play button is a pause button for what is playing, as the player bar's is.
|
||||||
if(resuming && !audio.paused){ audio.pause(); return; }
|
if(resuming && !audio.paused){ audio.pause(); return; }
|
||||||
if(!resuming){
|
if(!resuming){
|
||||||
player.guid=e.guid; player.feed=e.feed_id; player.entry=e; player.enc=enc.id; player.moved=false;
|
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=player.src=mediaSrc(enc);
|
||||||
audio.currentTime=0;
|
audio.currentTime=0;
|
||||||
if(e.position>5) audio.addEventListener('loadedmetadata',()=>{audio.currentTime=e.position},{once:true});
|
if(e.position>5) audio.addEventListener('loadedmetadata',()=>{audio.currentTime=e.position},{once:true});
|
||||||
// Initials, if it comes to that, are the feed's: the episode's read as "SE" beside the feed's art.
|
// Initials, if it comes to that, are the feed's: the episode's read as "SE" beside the feed's art.
|
||||||
@@ -38,6 +43,7 @@ function play(e,enc=e.enclosures.find(isPlayable)){
|
|||||||
$('#player').classList.add('on');
|
$('#player').classList.add('on');
|
||||||
mediaSession(e,f);
|
mediaSession(e,f);
|
||||||
player.marked=false;
|
player.marked=false;
|
||||||
|
paintDead();
|
||||||
}
|
}
|
||||||
audio.play().catch(err=>toast('Playback failed: '+err.message,true));
|
audio.play().catch(err=>toast('Playback failed: '+err.message,true));
|
||||||
renderEntries();
|
renderEntries();
|
||||||
@@ -110,6 +116,48 @@ $('#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)};
|
||||||
|
/// DeadAir's switch beside the speed: on or off for the show that is playing. The state is in the
|
||||||
|
/// icon's shape and aria-pressed, never a colour.
|
||||||
|
function paintDead(){
|
||||||
|
const b=$('#pdead'), f=S.feeds.find(x=>x.id===player.feed);
|
||||||
|
b.hidden=!f||document.body.classList.contains('has-video');
|
||||||
|
if(b.hidden) return;
|
||||||
|
b.setAttribute('aria-pressed',String(!!f.deadair));
|
||||||
|
b.innerHTML=f.deadair?ICON.deadairOn:ICON.deadair;
|
||||||
|
b.title=f.deadair?'DeadAir is cutting the dead air from this show':'DeadAir: cut the dead air from this show';
|
||||||
|
}
|
||||||
|
$('#pdead').onclick=async()=>{
|
||||||
|
const f=S.feeds.find(x=>x.id===player.feed); if(!f) return;
|
||||||
|
const on=!f.deadair;
|
||||||
|
try{ await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({deadair:on})}); }
|
||||||
|
catch(err){ toast(err.message,true); return; }
|
||||||
|
f.deadair=on; paintDead();
|
||||||
|
const switched=await switchVersion();
|
||||||
|
toast(!on?'DeadAir is off for this show':switched?'DeadAir is cutting the dead air from this show'
|
||||||
|
:'DeadAir is on for this show. Episodes lose their dead air a few minutes after they download.');
|
||||||
|
renderEntries();
|
||||||
|
};
|
||||||
|
/// Carries on at the same place in the other version of what is playing, once DeadAir has been
|
||||||
|
/// turned on or off for its show. True if it changed version: a copy not made yet plays next time.
|
||||||
|
async function switchVersion(){
|
||||||
|
if(!player.guid) return false;
|
||||||
|
const [feed,guid]=[player.feed,player.guid].map(encodeURIComponent);
|
||||||
|
// Saved and waited for, not beaconed, so the entry asked for next has this place in it.
|
||||||
|
if(audio.readyState){
|
||||||
|
await api(`/api/entries/${feed}/${guid}/position`,{method:'POST',body:JSON.stringify({
|
||||||
|
secs:Math.floor(audio.currentTime),duration:isFinite(audio.duration)?Math.floor(audio.duration):null})}).catch(()=>{});
|
||||||
|
player.moved=false;
|
||||||
|
}
|
||||||
|
const e=await api(`/api/entries/${feed}/${guid}`).catch(()=>null);
|
||||||
|
const enc=e&&e.enclosures.find(x=>x.id===player.enc);
|
||||||
|
if(!enc||mediaSrc(enc)===player.src) return false;
|
||||||
|
const was=!audio.paused;
|
||||||
|
player.entry=e; player.src=mediaSrc(enc); player.saveAt=e.position;
|
||||||
|
audio.src=player.src;
|
||||||
|
audio.addEventListener('loadedmetadata',()=>{audio.currentTime=e.position},{once:true});
|
||||||
|
if(was) audio.play().catch(()=>{});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
$('#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.
|
// Called here, not left to the pause event: closing a player already paused fires none.
|
||||||
syncListening()};
|
syncListening()};
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ function setTheme(name = theme.name, mode = theme.mode, save = false){
|
|||||||
: theme.mode === 'auto' ? (systemDark && !systemDark.matches ? 'light' : 'dark') : theme.mode;
|
: theme.mode === 'auto' ? (systemDark && !systemDark.matches ? 'light' : 'dark') : theme.mode;
|
||||||
// The tab's icon in the same variant as the logo on the page.
|
// The tab's icon in the same variant as the logo on the page.
|
||||||
const fav = $('#favicon'); if(fav) fav.href = fav.dataset[root.dataset.mode];
|
const fav = $('#favicon'); if(fav) fav.href = fav.dataset[root.dataset.mode];
|
||||||
|
// The top bar's colour for an iPhone before iOS 26 to paint behind the status bar, as Android's
|
||||||
|
// toolbar does; the page's where the bar is see-through (Glass). iOS 26 reads the bar itself.
|
||||||
|
const tc = $('meta[name=theme-color]'), bar = $('#topbar');
|
||||||
|
if(tc){ const c = bar ? getComputedStyle(bar).backgroundColor : ''; tc.content = /^rgb\(/.test(c) ? c : getComputedStyle(root).backgroundColor; }
|
||||||
const sel = $('#stheme'); if(sel) sel.value = theme.name;
|
const sel = $('#stheme'); if(sel) sel.value = theme.name;
|
||||||
const ms = $('#smode'); if(ms) ms.value = theme.mode;
|
const ms = $('#smode'); if(ms) ms.value = theme.mode;
|
||||||
const mf = $('#smodefield'); if(mf) mf.hidden = !both;
|
const mf = $('#smodefield'); if(mf) mf.hidden = !both;
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ const ICON={
|
|||||||
// for the outline's stroke, so the two draw the same size.
|
// 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
|
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
|
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
|
||||||
|
// DeadAir (#171): a waveform. Off for this show, outlined with the silence still in its
|
||||||
|
// middle; on, solid with the gap closed up. Padded like pin for the outline's stroke.
|
||||||
|
deadair:fa('-16 -16 544 544','<g fill="none" stroke="currentColor" stroke-width="24"><rect x="14" y="200" width="44" height="112" rx="22"/><rect x="84" y="136" width="44" height="240" rx="22"/><rect x="154" y="96" width="44" height="320" rx="22"/><rect x="314" y="96" width="44" height="320" rx="22"/><rect x="384" y="136" width="44" height="240" rx="22"/><rect x="454" y="200" width="44" height="112" rx="22"/></g>'),
|
||||||
|
deadairOn:fa('-16 -16 544 544','<g fill="currentColor"><rect x="59" y="200" width="44" height="112" rx="22"/><rect x="129" y="136" width="44" height="240" rx="22"/><rect x="199" y="96" width="44" height="320" rx="22"/><rect x="269" y="96" width="44" height="320" rx="22"/><rect x="339" y="136" width="44" height="240" rx="22"/><rect x="409" y="200" width="44" height="112" rx="22"/></g>'),
|
||||||
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
|
||||||
@@ -56,6 +60,13 @@ const EQ='<span class="eq" aria-hidden="true"><i></i><i></i><i></i></span>';
|
|||||||
// The page's own buttons name their icon; this draws it in, ahead of any label they carry.
|
// The page's own buttons name their icon; this draws it in, ahead of any label they carry.
|
||||||
for(const b of $$('[data-icon]')) b.insertAdjacentHTML('afterbegin',ICON[b.dataset.icon]);
|
for(const b of $$('[data-icon]')) b.insertAdjacentHTML('afterbegin',ICON[b.dataset.icon]);
|
||||||
|
|
||||||
|
/// What DeadAir took off an episode, as the list says it.
|
||||||
|
function shorter(secs){ return secs<60?`${secs} s shorter`:`${Math.round(secs/60)} min shorter`; }
|
||||||
|
/// A total of time saved: "3 h 12 min", "12 min", "40 s".
|
||||||
|
function spanText(secs){
|
||||||
|
const h=Math.floor(secs/3600), m=Math.floor(secs%3600/60);
|
||||||
|
return h?`${h} h ${m} min`:m?`${m} min`:`${secs} s`;
|
||||||
|
}
|
||||||
async function api(url: string, opts?: RequestInit): Promise<any>{
|
async function api(url: string, opts?: RequestInit): Promise<any>{
|
||||||
const r = await fetch(url,{headers:{'Content-Type':'application/json'},...opts});
|
const r = await fetch(url,{headers:{'Content-Type':'application/json'},...opts});
|
||||||
if(r.status===401){ location.href='/login'; throw new Error('signed out'); }
|
if(r.status===401){ location.href='/login'; throw new Error('signed out'); }
|
||||||
|
|||||||
Reference in New Issue
Block a user