66 Commits
v0.9.0 ... main

Author SHA1 Message Date
d238681ec1 Name an item without a title from its own text, not "(untitled)" (#116)
RSS 2.0 makes an item's title optional, and some blogs leave it out on purpose: Scripting News
titles almost none of its posts. Fifty rows of "(untitled)" said nothing about any of them.

entryName gives an item its title, or the opening of its text (HTML read through DOMParser, an
inert document that loads nothing; cut at a word near 120 characters), or its file's name, or
its show and date, with a flag for a name that is not a title. The list sets that one in the
regular weight, as the text it is rather than a heading; the reader leaves out the heading so
the post starts with itself; the player, the lock screen, Currently Listening, the native shell,
Share and the queued toast use the same name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:46:47 +00:00
0395717c14 Put the bytecode cache's ignore line on a line of its own
The previous commit appended it to a file without a final newline, joining it to
.claude/settings.local.json and un-ignoring both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:27:19 +00:00
8ce68a881a Ignore Python's bytecode cache from the prod-check script
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:27:07 +00:00
e7c59489ee Announce a followed move as a feed_moved event (#115)
A feed moved to its new address was only logged by follow_move. It is now an event, feed_moved
with the feed and its old and new addresses, so it goes where every other event goes: the log,
with from and to as fields, the admin page's Scans view, `ipx fetch`, and the page, which gets
the feed's new row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:22:28 +00:00
f7f4b466dc Follow a feed that has moved for good to its new address (#115)
A feed whose address answered with a permanent redirect was read through it on every check,
and the catalogue kept the old address: 28 of 147 feeds in production, most http to https, some
to a new path or domain.

Feeds are now fetched with a client of their own that follows no redirects (Ctx::feed_client),
and feed::fetch follows them itself, up to 10 hops, so it sees each one. When every hop was
permanent (301 or 308) it says where the feed ended up, and the scan moves the feed there in the
catalogue (follow_move). A temporary hop (302, 307) anywhere moves nothing. A feed an OPML lists
is left alone, as the OPML would put the old address back, and so is a move onto an address
another feed has. A password goes only to the feed's own host, never to a redirect elsewhere;
reqwest's own following dropped it the same way. Ten hops is a loop, worded as reqwest worded
it so it still reads as redirect_loop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:05:43 +00:00
ec8fd5dd86 Sleep until the next feed is due instead of scanning every minute (#114)
The daemon ticked every 60 s and ran a scan pass each time: the sweep, then a check-state query
per feed (about 180) to find which were due. In the six hours before, 293 of 362 passes found
nothing due. Now, after each pass, it works out when the earliest feed is due (due_at, shared
with the scan's own check, over Db::http_states, one query) and sleeps until then: at least
30 s, so a feed that never gets a check time cannot spin it, and at most 10 minutes, so what no
command announces, ipx add or a shorter schedule, is picked up. Commands still wake it at once,
and the first pass after starting runs straight away, as the tick's did. The scan reads every
feed's state in one query too.

The prod-check skill says what to expect now: tens of scans in six hours, and pending as the
real queue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 19:56:13 +00:00
a387012c69 Count as queued only what a scan will download on its own (#113)
Every file in state 'pending' counted as waiting to download: 4420 in production, across 12
shows. Since #97 a scan only downloads among a feed's newest max_new_per_check items, so those
were back-catalogue episodes no scan would take; the real queue was 0.

A new state, 'held': listed and downloadable by hand, but outside the feed's newest items, or of
a feed nothing downloads automatically. Db::hold_back moves a feed's waiting files between
'pending' and 'held' each time the feed is due, changed or not, and again after its items are
stored, so a new episode, a raised limit or auto-download turned on or off moves them. A held
file keeps its item's place among the newest, as a downloaded one does. 'pending' now means
queued, so ipx status, /api/status and the dashboard read true without changing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 19:47:52 +00:00
a00516687a Keep artwork on disk and serve every image from iPX (#111)
The page loaded artwork from each publisher's server, or through /api/art, fetched every time,
for http-only hosts. Nothing was kept, every visit asked every publisher, and artwork went when
a publisher's server did.

- The page draws every image a feed or item names from /api/art. The first time, iPX fetches
  it (only an address a feed or item names, only an image, up to 5 MB, within the feed timeout)
  and keeps it in art/ beside the database, under a hash of its address with its type beside
  it (src/art.rs). Later it comes from disk, which marks it as used.
- art_cache_mb, a server setting on the admin page, 500 by default, caps what is kept: the
  sweep before each scan drops the least recently shown until it fits. 0 keeps nothing, and
  artwork is fetched through iPX each time. Settings saved before it get the default.
- Served from iPX's own address, someone else's image must stay an image: nosniff, and a CSP
  with sandbox, so an SVG opened on its own runs no script as iPX.
- The fixture server sends .jpg as image/jpeg, which /api/art requires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:50:46 +00:00
38ebc7b02b Move old items' http artwork to https too, for hosts the feed no longer names (#110)
The first pass only asked the hosts the feed's current body names. IGN's feed kept five 2009
items with artwork on assets1/assets2.ignimgs.com, which its feed no longer mentions, so they
stayed on http. A scan now also asks, once per host, the hosts of the feed's stored http
artwork (Db::http_images).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:35:51 +00:00
54d827f655 Time out a hung feed, serve the precomposed touch icon, and store http artwork on https (#108, #109, #110)
#108: the HTTP client had no timeout, and scans handle feeds in order, so a hung server held
every scan. Dreamwidth answered 504 after 60-67 s for a day and each scan took 70-80 s instead of
15. A feed fetch, and a Patreon creator's show list, now gets 30 s from connecting to the last
byte (feed::FEED_TIMEOUT); the client gets a 10 s connect timeout, which bounds a download's
start but not a long download.

#109: iOS asks for /apple-touch-icon-precomposed.png first when the site is added to a home
screen; it was a 404 and the only non-feed warning in the log. It serves the same icon.

#110: the page is https and loads no http. Artwork on http came through /api/art (#90) even
when its host serves https too. A scan now tries each http artwork host on https once per feed
(feed::prefer_https) and stores the https address where the host answers with an image,
rewriting that feed's stored items from the same host (Db::secure_images). 4 of the 5 hosts in
production do; cdn.thesecretcabal.com presents another name's certificate and stays on
/api/art.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:31:50 +00:00
e2593eaa50 Put a pinned feed's pin on the corner of its artwork
The pin was a small accent-coloured icon before the feed's name. It is now a disc on the
artwork's corner, where a failing feed's mark is, in the accent and the ink the theme already
pairs with it for primary buttons (checked by tests/contrast.js), so no palette changes. A feed
both pinned and failing keeps the error mark at the bottom corner and the pin at the top.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:23:40 +00:00
d52ecfcbd7 A listed feed nobody subscribes to downloads nothing (#107)
With no subscribers a feed falls back to its own settings, where auto_download is on, so a
listed feed with audio would have downloaded files for no one. The seeded news feeds carry
only images, which media_types already skips, so nothing was downloaded. Files skipped for it
are judged again, by the new subscriber's settings, on the next scan after someone subscribes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:20:09 +00:00
142610e8b8 ipx add --list or --category updates a feed the catalogue already has (#107)
It refused one already there ("already subscribed as ..."), so a feed added before listing
existed, such as CBC's, could not be put in the Directory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:17:49 +00:00
43acc62259 List feeds in the Directory before anyone subscribes, and clear out dead ones (#107)
The Directory showed a catalogue feed only once someone subscribed, and a feed left the
catalogue with its last subscriber, so nothing could be put there for others to find.

- A feed has a listed flag, set by ipx add --list (with --category for the Directory's chip).
  The web page keeps a listed feed in the catalogue when its last subscriber leaves.
- The Directory lists every catalogue feed; Popular still only what people subscribe to.
  popular() reads titles, artwork and categories through Db::feed_list, not three queries a
  feed. Subscribing from the Directory scans the feed at once.
- A feed nobody subscribes to is checked once a day at most.
- clean_directory, in the sweep before each scan, removes from the catalogue and the database
  a feed nobody subscribes to, with no file on disk and not from an OPML, that has failed for
  30 days or published nothing in a year. Run against production first: it removes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:13:13 +00:00
65fdab8b13 Between releases, the version says a release is in progress: 0.9.2-dev
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:52:00 +00:00
4d312a87ec Release 0.9.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:48:16 +00:00
ef5bdb4244 Say what guards the web UI, and warn only without Cloudflare Access (#106)
Every start logged WARN "web ui is reachable off this machine; the token is all that guards
it". A container has to bind 0.0.0.0 for its port to be published, so it fired on every start
of production, and it was out of date: signing in takes an account's password or the admin
token, and through the tunnel Cloudflare Access. It was the only warning in a healthy log. Now
it names what guards it, at info when Access is configured and a warning otherwise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:46:43 +00:00
c7f13eea2f Send a changed feed's row to the page instead of it reloading the list (#105)
After a feed was checked, failed or downloaded a file, and after every item read, the page
fetched /api/feeds whole, about 60 ms for 160 rows, though one row had changed. The live event
stream now knows who is connected and, after an event that changes a feed, sends that person
its row (feed_row), built by the same code as the list (feed_rows, with Db::feed_list asked for
one feed). Marking an item read answers with the feed's row. The page puts the row in place
and redraws once a frame. A routine skip of a feed not due sends nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:41:43 +00:00
7a134b810f Fetch feeds six ahead in a scan; reload the list only after a scan that checked something (#103, #104)
Fetching was 65-90% of a scan, each feed waiting for the one before: 13 s of fetches in a 20 s
refresh of 32 feeds. The scan now works out which feeds are due, fetches their bodies up to six
ahead in tasks of their own, and handles each in order as before, so database writes,
downloads and OPML syncs stay one at a time. A Patreon creator still fetches in scan_one.

The page reloaded /api/feeds, and /api/settings with it, on every scan_done: the scheduler
scans every minute, so each open page reloaded the list once a minute, 169 times an hour. It
now reloads only when the scan checked a feed, and asks for settings once, on first load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:27:51 +00:00
79bc006a30 Add only what is a feed or links one; refuse the rest (#102)
Adding an address looked for the feed a web page links and, finding none, added the address
as it was: every check then failed, and the sidebar called it a feed that had moved. cnn.com
is one; its page links no feed. find_feed replaces feed_behind_page: the address is added if
it is a feed or an OPML list, the feed its page links if it is a web page that links one (and
that is a feed), and otherwise the add is refused with the reason, from the web page (400, the
dialog stays open) and from `ipx add`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:06:04 +00:00
9084b61bb6 Read an address typed without a scheme as https (#101)
cnn-com was added as 'cnn.com', stored as typed, and every check failed with "relative URL
without a base" before it reached the site to look for its feed. expand_input, which both the
web page and `ipx add` pass the address through, now makes one without a scheme https, and a
protocol-relative //host/path https too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 19:46:10 +00:00
0cc956b7c6 Forget a failing feed once nobody subscribes to it (#100)
Unsubscribing leaves a feed's row and history, which suits one that worked. One that never did
stayed with its error for good and was never scanned again: cnn-com, added as a bare 'cnn.com'
(#101), sat there failing with no subscriber. The reaper, before each scan, now deletes a feed
that is failing, has no subscriber, is not in the catalogue and has no file on disk, with its
items, file rows, read state and block list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 19:40:18 +00:00
db87bc0f42 Back off a failing feed exponentially, up to a day (#99)
A feed that failed was tried again on its usual schedule however long it had been failing:
gizmodo's 404, pelgrane's 403, daily-quests' 503 and toddstashwick's redirect loop every hour,
each a request to a site that had said no, a warning and scan time. A failing feed now waits as
long as it has been failing, from error_since to its last check, never less than its usual
interval and never more than a day: 1h, 1h, 2h, 4h, 8h, 16h, then daily on an hourly schedule.
No new column: error_since already marks the run's start and the first success clears it. A
forced refresh skips the due check, so it still tries at once. The feed list's next check
follows the backoff.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 19:33:57 +00:00
527777efdf A download limit means a show's newest episodes, not a pace (#97)
pending() took the newest files still pending, up to the limit, so once a show's latest three
were down, each full read of its feed took the three before them, working back through its
whole history. In production 4420 files (about 310 GB) were queued this way across 12 shows,
all on the default limit of 3, which is meant as "the latest three". It now takes only from the
feed's newest `limit` items with a file. 0, unlimited, still takes the whole back catalogue:
that is how the shows kept as an archive are set, along with limits of 100 and 10000.

The settings' wording followed the old behaviour ("The rest wait for the next scan"); the field
is now "Newest episodes to download", and says what 0 does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 18:50:11 +00:00
4a26c73b82 Clamp an unlimited download queue's LIMIT for Postgres (#98)
With max_new_per_check at 0 and no per-subscription limit, the budget is usize::MAX, and
pending() bound it `as i64`: -1. SQLite reads LIMIT -1 as no limit; Postgres refuses it, so a
feed's downloads failed. The new test fails with "LIMIT must not be negative" on Postgres
without the clamp and passes with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 18:37:39 +00:00
98ed9b6498 ignore local claude settings 2026-09-29 18:26:33 +00:00
57ab419718 Insert only a feed's new items and files on a scan (#96)
The spans added in 2799704 showed it: in a full scan of 134 feeds (trace da9a419b...,
2026-09-29 17:31, 315 s), storing items took 117 s, fetching 44 s and every other database call
about 2 s together. A scan inserted every item and file the feed listed, stored or not, one
round trip of about 10 ms each; Clarkesworld's 1200 items took 13 s. It now reads the feed's
stored guids and file URLs once (Db::stored_items) and inserts only the rest. A file URL not
among the feed's own may still be another feed's, so that one still goes to the insert, which
finds it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:40:52 +00:00
2799704d30 Look at a feed's artwork only when it may have changed, and trace a scan's database work (#95, #96)
A feed read in full checked its own artwork and, without one, asked its website for an icon,
every time; a feed without validators is read in full every scan, so looking-for-group spent
2 s of every scan loading lfg.co's home page. Now the check runs when the feed names different
artwork from what is stored, or the scan was asked for, which keeps #80's point: a refresh
still picks up an icon the site changes or fixes.

Feed spans ran seconds past their fetch with nothing to say where (#96). The artwork lookup,
the loop that stores each item, and the per-feed database calls (feed_summary, record_feed,
subscribers, adopt, skipped_by_filter, rehide, pending) now have spans of their own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:27:31 +00:00
e8fd3fe9ed Load the feed list in five queries, not six per feed (#94)
GET /api/feeds called feed_summary, http_state, blocklist and unread_count for every feed:
about 950 round trips to Postgres for 160 feeds, 320 ms on every page load. Db::feed_list asks
for the feed rows, entry counts, download counts, the person's unread counts and block lists
once each, and the handler reads from that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:24:09 +00:00
448e557272 Trace ids, failure kinds and one line per event in the JSON log (#91)
From Dash0's structured logging guide, what applies here:

- Each JSON line inside a traced span ends with its trace_id and span_id, so a line in Loki leads
  to its trace in Tempo; the access log is written inside its request's span so it has one too.
  The JSON formatter takes no extra fields, so WithTrace appends them to the object it writes.
- A feed or download failure carries error.type (the HTTP status, or dns, redirect_loop,
  timeout, ...) and http.response.status_code, from failure_kind beside explain_failure, so
  failures group by kind without a regex over msg.
- Each event was logged twice: words under ipx::scan and fields under ipx::io. It is now one
  line under ipx::scan with both; the wire copy is at debug, for the admin page's Daemon I/O tab,
  and out of production's log. The healthcheck's status reply stays under ipx::io.
- The access log's ms is duration_ms. The dashboard and the prod-check skill follow.
- error fields are Display with the anyhow chain everywhere, not a mix of Debug and Display.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:25:53 +00:00
36b16c7f5d Fetch artwork offered only over http for the https page (#90)
Through ipodderx.sdf1.net the page is https, the browser upgrades an http:// image to https,
and a host with no https, such as The Secret Cabal's CDN, answers nothing, so no artwork. On an
https page, the page now asks /api/art for those, and ipx fetches them. It only fetches an
address some feed or entry names as its artwork, and only an image, up to 5 MB, so the route
cannot be pointed at anything else on the network.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:19:06 +00:00
928cbaf8f4 Show each feed's id labelled in ipx list (#82)
The id led the title's line unlabelled, so antirez.com's, "feed" with no title beside it, read
as a heading; 'ipx fetch antirez' was tried instead and failed. The title now heads the entry and
the id has its own row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:16:25 +00:00
0945f3a9f4 Remove ipx copy-db (#85)
It was the one-off copy from SQLite to Postgres (#18), run once on 2026-09-18. Production has run
on Postgres since; rolling back needs only the old state.db, which is kept, not this command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:15:51 +00:00
e9f2832e1e Show a failing feed on its artwork, in words, and grey (#93)
The mark was a 12px "!" in the sidebar's margin, told apart by --bad alone; a dark theme's --bad
is a pale pink, and at that size it vanished. It is now a solid disc on the artwork's corner, the
subtitle says what is wrong in place of the counts, and a feed failing for a day or more has its
artwork greyed out. Lightness and words carry it, so no theme's palette changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 15:17:46 +00:00
d4e00be085 A feed's own artwork has to be there before it is used (#89)
A feed's itunes:image or <image><url> was stored without being asked
for, so a dead one stood in the way of the site's icon. Ken and Robin
Talk About Stuff names http://kenandrobin.wpengine.com/.../kartas_podcast.png,
a 404, while its site's apple-touch-icon works. The feed's artwork now has
to answer as an image, as the site icon already did, when the feed is
read in full; otherwise the site's icon is looked for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:11:16 +00:00
66e40e3c71 A skill for checking production from Loki and Tempo
.claude/skills/ipx-prod-check: what production's JSON log and traces
carry, the queries that find trouble (warnings grouped, failing feeds and
downloads, 5xx and slow routes, whether the worker keeps up, slow and
failed traces), how to tell a publisher's dead feed from an ipx bug, and
filing what is found as issues per CLAUDE.md. query.py beside it runs the
LogQL and TraceQL through a throwaway container on the monitoring
network, since Loki and Tempo publish no query port.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:07:24 +00:00
4f8b3d6a1d Log as JSON when IPX_LOG_FORMAT=json (#91)
The log was text, so the Grafana dashboard picked lines apart with
regular expressions, and a change of wording would have blanked its
panels. With IPX_LOG_FORMAT=json each line is one JSON object: the
access log carries method, path, route, status and ms as fields (the
route passed from the routing layer in the response's extensions), and
each wire event its ev, feed, new, downloaded, failed, bytes, msg and
the rest (log_wire), beside the old message. The two startup lines that
were println! are logged, so no line breaks the JSON. Text stays the
default, for a terminal. The dashboard reads the fields with Loki's json
parser, and groups requests by route rather than path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:55:50 +00:00
8ce0a4cb27 A Grafana dashboard for iPX
Built on what the monitoring project already collects: the container's
log in Loki (through Alloy) and the traces in Tempo. It parses the
access log and the event log's wire JSON, so there are no metrics to
add to ipx: what is waiting and downloaded (from the healthcheck's
status), new items, downloads and bytes, failing feeds and downloads,
requests by status and response time, the slowest and busiest paths,
recent and slow traces, and the log. Provisioned from a file, so it is
regenerated here, not edited in Grafana.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:38:29 +00:00
549ae33f06 Keep test daemons out of production's traces and database (#87)
The browser suite's daemon took its environment from the shell running
it, so a shell with OTEL_EXPORTER_OTLP_ENDPOINT set would have sent its
fixture scans to production's Tempo, and one with IPX_DATABASE_URL set
would have run the suite against production's database. Both are now
blanked for it. Production's traces carry
deployment.environment.name=production, which the dashboard filters on,
so a daemon run by hand stays out as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:35:43 +00:00
9e7c93e149 Name request traces by route, and no colour codes off a terminal (#87, #88)
A request's trace was named by its path, so every item's GUID in
POST /api/entries/{feed_id}/{guid}/flags made a trace name of its own and
nothing grouped in Tempo. A route layer now renames it once routing has
matched. It renames the OpenTelemetry span directly: tracing-opentelemetry
drops a recorded otel.name once the span has been entered, and access_log
enters it before routing runs.

tracing-subscriber's fmt layer writes ANSI colour by default, so docker
logs and Loki (through Alloy) carried escape codes on every line, which
each query had to strip. Colour is now for a terminal only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:33:11 +00:00
1724346da7 Send OpenTelemetry traces over OTLP (#87)
ipx had no spans, only log lines, so there was no way to see where a
slow scan, download or request spent its time. With
OTEL_EXPORTER_OTLP_ENDPOINT set, the daemon now exports traces over
OTLP/HTTP (Tempo on Tower): a scan, each feed in it, the feed fetch and
site icon lookup, downloads, torrents, reaps, and web requests. Log lines
inside a span ride along as its events.

Only the daemon exports: the healthcheck runs ipx status every 30s and
would bury everything else. The web event stream and the log view's
polling get no span, for the same reason. The exporter shares ipx's
reqwest 0.13, so no second HTTP stack comes in.

The stderr log now prefixes lines inside a span with it, as
tracing-subscriber's fmt layer does (scan{only=None force=false}: ...).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:22:45 +00:00
8c5eddd783 Drop the start-up pass that folds files WordPress listed twice (#83)
Before 0.6.0 the parser took WordPress's numbered player URLs (?_=2) for
separate files and downloaded some episodes twice. Since then it drops
the repeats while reading (same_file_key), and merge_repeated_enclosures
cleaned up what was already stored. Production has run it; on every
start since it has only cost a query that finds nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 13:12:10 +00:00
469467bf04 A site icon is looked up again when the feed is read in full (#80)
The icon standing in for a feed's missing artwork was looked up once and
kept, so a site that changed or fixed its icon, or a feed that dropped
its own artwork, kept whatever was found first. A dead icon stored
before #79 would have stayed dead. It is now looked up whenever the
feed is read in full: when it has changed, or on a refresh someone asks
for, which reads in full since #77.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 12:51:19 +00:00
95a8633877 A site icon that is missing is not used (#79)
site_icon took the icon a site's page names in its <link> tags without
asking for it, so a dead one was stored and /favicon.ico never tried.
antirez.com names /images/favicon.png, which is a 404, while its
/favicon.ico is there; the feed showed no artwork, and since the lookup
happens once, never would. The named icon now has to answer with an
image, as /favicon.ico already did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 12:41:57 +00:00
00f6293b95 The refresh button turns while its feed is checked (#78)
The feed events already put a spinner on the sidebar row, which a phone
hides. The same state now sets scan-this (the open feed, or a feed in the
open folder) and scan-any (any of your feeds) on <body>, and the refresh
icons turn under them. On <body> because the feed page is redrawn as items
come in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:50:41 +00:00
bae22e553e A refresh someone asks for reads the feeds in full (#77)
Check every feed, a feed's refresh, pull to refresh and ipx fetch --force
all send force, which only skipped the not-due wait: the request still
carried the stored ETag and Last-Modified, so an unchanged feed answered
304 and was not read. anil-dash got no site icon from a refresh for this
reason. A forced scan now drops the validators; the scheduled scan keeps
them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:47:08 +00:00
7d55d99fac A feed's refresh button looks like its neighbours (#76)
The check-now button on a feed's page, a folder's page and All
Subscriptions was class primary, drawn filled in the accent colour among
plain buttons. Primary stays for a dialog's confirm button; the rules
that only the feed pages used go with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:42:58 +00:00
a5de4ae06c Toggle state in the icon's shape, not the theme's colours (#74)
4ed2d59 drew a pressed toggle in each theme's accent colour; the themes'
colours were not to change. Back as they were, pinned rows included. The
read button carries its state in its shape instead, as the pin does with
outline and solid: a tick when read, the envelope when not, where before
it showed the action (the envelope on a read item).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:38:59 +00:00
4ed2d59311 Toggles show their state the same way everywhere (#74, #75)
A sweep of all 24 theme palettes, measuring each icon's drawn colour,
found pinned in three colours: accent in the feed list, the text colour
on an item's row, and uncoloured on the toolbar, beside the title and on
the feed page. The read button showed the action (an envelope on a read
item) beside a pin showing the state. Now each toggle shows what is, with
aria-pressed, and a pressed one is the accent colour; Classic needs its
own rule, as its buttons set their colour at higher specificity. The
contrast test checks the accent on the button grounds, where it now draws.

Directory's Subscribe button carried the Subscribed tick; it is a plus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:36:23 +00:00
2004da3459 Feeds from before site icons get one, without waiting for a post (#73)
The icon lookup ran only when a scan got the feed's body, and most feeds
answer 304 to their stored validators, so anildash.com and 68 others
stayed blank until their next post. A feed whose image was never looked
for is now refetched once without validators, as an empty one already
was. A miss is stored as "" (drawn as no art), so neither the refetch nor
the site lookup repeats on every scan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:27:00 +00:00
c6980c355d Adding a site's address subscribes to the feed it links (#71, #72)
Both add paths, the CLI's and the web's, look behind the URL first: a web
page that names its feed with <link rel="alternate"> is swapped for that
feed, before the duplicate check so it finds a feed someone already has.
Before, the page itself was added and every scan failed on it.

alternate_feed_link found tags in a to_lowercase() copy and sliced the
original at those offsets; Unicode lowercasing changes some characters'
length, so a page with one before its <link> tags lost the href or
panicked off a char boundary. ASCII lowercasing keeps offsets aligned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:21:23 +00:00
ce221cee18 A feed with no artwork takes its site's icon (#70)
When a feed names no image and none is stored, the scan fetches the
channel's site link (RSS <link>, Atom rel=alternate) and uses the
apple-touch-icon or icon it names, falling back to /favicon.ico when that
answers with an image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:18:46 +00:00
f57f824535 Each browser keeps its own theme, in a cookie (#69)
The theme was kept on the account, so every browser signed in as the
same person got the same one: no Glass on the phone with Dracula on the
desktop. It is now the ipx_theme cookie (<theme>.<mode>), written by
theme.ts, and read by the server to draw the page in it from the first
frame as before. /api/me no longer reports or takes a theme, and
set_theme is gone.

A browser with no cookie yet is sent the theme the account kept, and
takes it as its cookie on that first load, so nobody loses their choice
in the move. users.theme and theme_mode are only read now, for that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:27:20 +00:00
7490a3a9ac A spinner after pulling to refresh (#68)
Letting go of a pull removed its note at once and showed nothing else;
the sidebar's scanning spinner is hidden on a phone. With no sign the
check had started, people pulled again, and again. A "Checking for new
items" pill with a spinner now sits under the top bar until the request
is sent and two seconds have passed, and a pull meanwhile does nothing.
It lives outside #list, which a feed's render rebuilds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:23:31 +00:00
5be629427a /api/status, for Homepage's dashboard (#67)
The iPX tile on Homepage was a bare link: nothing in ipx gave a summary a
customapi widget could read. /api/status serves what `ipx status` prints
(feeds, items pending, files downloaded), from the same function the
control socket answers with, plus the version. It sits behind sign-in
like the rest of /api; Homepage sends the shared [web] token as the
ipx_token cookie.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 19:10:05 +00:00
bf785299b0 No logo in the phone's top bar (#66)
The logo moved into the top bar beside the add-feed button (#60). On a
phone that bar is tight: the logo squeezed the search box down to a few
letters, and with no hover there its version tooltip showed nothing.
Below the phone breakpoint it is left out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:53:34 +00:00
5967aa1e57 Drop Outline from the SSO notes; it is gone
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:21:39 +00:00
9e238bfc75 Say in the SSO notes that Authentik's tile cache needs clearing after an edit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:20:14 +00:00
10b7ccd424 Name the Authentik tile iPX in the SSO notes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:18:32 +00:00
d8db785681 The tab's icon follows light and dark mode (#64)
The logo on the page switched with the mode (#63), but the tab's icon
was always favicon.png, the light logo. web/favicon-dark.png is
logo-dark.svg at 128px, served beside it, and the theme script points
the icon link at whichever matches data-mode, so it follows the theme
the account chose, not only the system. The sign-in page, with no
account, picks by the system's with two media-bound links.
/favicon.ico, which a browser asks for on its own, stays the light one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:12:11 +00:00
f9c9c2b7cc Modern in the logo's colours, and the logo in the page's mode (#63)
Modern's palette was sampled from the 2004 iPodderX icon: a neutral navy,
its screen blue and amber EQ bars. It now takes the new logo's colours,
the dark half from logo-dark.svg (navy ground, #8fc2ea scale, #ff6a1a
needle) and the light half from logo.svg (sky ground, #2f6aa0 scale, the
needle taken down to #c43e00 so white on it clears AA). The pending amber
and the error red moved apart from the needle's orange, and the sign-in
page's copy of the palette follows.

The pages always showed logo.svg, the light variant, even in a dark
theme; logo-dark.svg was never served. It is now, and the app and admin
pages show whichever matches data-mode, dark until the script says light,
as the palette is. The sign-in page, which has no account's theme, picks
by the system's with <picture>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:05:44 +00:00
3cb36ab8b7 Between releases, the version says a release is in progress (#62)
Production ran four commits past v0.9.0 while the logo's tooltip said
0.9.0, because Cargo.toml's version only moved at a release. It is now
0.9.1-dev, and CLAUDE.md's release steps end by moving to the next -dev
version. No commit hash: the name says there is newer work, and git says
which.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:54:26 +00:00
65b5eacdb4 Settings no longer shows the server's download folder (#61)
The Settings dialog ended with the server's download_dir, read-only and
the same for everyone: it is set in config.toml, so nobody can act on it
from there. The admin page still shows it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:49:54 +00:00
4769a2ebe1 The logo beside the add-feed button, with the version on hover (#60)
The logo sat at the top of the feed list with "iPX" written beside it,
and the page showed the version nowhere. It is now in the top bar just
before the feed buttons, alone, and its tooltip names the app and its
version.

The version is filled in by the server as it sends the page, not by
build.mjs: build.rs reruns only when web/ or package-lock.json changes,
so a release that bumped only Cargo.toml would have kept the page naming
the one before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:45:35 +00:00
70e0341348 A more vivid orange for the logo's needle (#59)
The needle was #f7931e (#ff9f2e dark), a soft orange close enough in
lightness to the tallest blue bar that the two ran together where they
touch, and weak at favicon size. It is now #ff5500 (#ff6a1a on the dark
background), fully saturated and pushed towards red, away from the bars'
blue. favicon.png and apple-touch-icon.png are re-rendered from logo.svg.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:32:07 +00:00
e16dace9c0 On the Unread tab, a swipe back goes to the item just read (#50)
selectEntry took each read item out of the list the moment you moved on
from it, so the item was not there for the back swipe (or k) to reach: it
went to the one before, or to the list if the item had been first.

Items read while turning from one to the next (a swipe, j and k) now stay
in the list until the reader closes or another item is picked from the
list, and a background refresh keeps them as it keeps the open one.
Picking a row still drops the item left behind at once, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 15:36:29 +00:00
50 changed files with 2885 additions and 655 deletions

View File

@@ -1,14 +0,0 @@
{
"permissions": {
"allow": [
"Bash(rtk grep *)",
"Bash(rtk read *)",
"Bash(rtk git *)"
],
"additionalDirectories": [
"/config/.claude/skills/security-audit",
"/config/security-audit-skill",
"/config/.cargo/registry"
]
}
}

View File

@@ -0,0 +1,141 @@
---
name: ipx-prod-check
description: Look for problems in production ipx (the iPX container on Tower) from its logs in Loki and its traces in Tempo, and file what is found as Gitea issues. Use when asked to check on production, look for issues or errors in ipx, see why something is slow or failing in production, review the logs, or investigate a report about the live site.
---
# Checking production ipx
Production logs one JSON object a line (`IPX_LOG_FORMAT=json`), which Alloy ships to Loki under
`{container="iPX"}`, and sends traces to Tempo tagged
`resource.deployment.environment.name="production"`. Anything without that tag is a daemon run by
hand, not production. Loki and Tempo publish no query port, so use the script beside this file:
```sh
Q=.claude/skills/ipx-prod-check/query.py
$Q logs '<LogQL>' [since] # lines, newest first (500 at most)
$Q metric '<LogQL metric>' [since] # $range becomes `since`
$Q traces '<TraceQL>' [since] # slowest first
$Q trace <trace id> # one trace as a tree, with its log lines
```
`since` is `1h`, `24h`, `7d`. Default to `24h`; widen it to see whether something is new.
## What the log carries
Every line has `timestamp`, `level`, `message` and `target`; lines inside a span have `span` (the
innermost: `{"name":"feed","feed":"x"}`). Loki's `| json` flattens it to `span_name`, `span_feed`.
Lines inside a traced span, requests and scans, also carry `trace_id` and `span_id`: give the
`trace_id` to `$Q trace` to see the whole request or scan. (From 2026-09-29 16:30 UTC; before that,
lines had no trace id, the access log's time was `ms`, and each event was logged twice, words under
`ipx::scan` and fields under `ipx::io`.)
| target | fields | what |
|---|---|---|
| `ipx::http` | `method`, `path`, `route`, `status`, `duration_ms` | one per web request; `route` is the pattern, empty for an unrouted path |
| `ipx::scan` | `ev` and the event's own: `feed`, `new`, `downloaded`, `failed`, `bytes`, `msg`, `url`, `feeds`, `reason`, `from`, `to`; on a failure `error.type` and, from an HTTP error, `http.response.status_code` | the daemon's events, one line each, in words; warnings are feed and download failures |
| `ipx::io` | `ev`, `feeds`, `pending`, `downloaded` on the `status` reply | commands arriving (`-> {...}`) and the healthcheck's answer |
| `ipx` | message, sometimes fields | start-up, shutdown, account and config messages |
Events (`ev`): `feed_start`, `feed_done` (new, downloaded, failed, torrents), `feed_skip` (not due,
routine), `feed_error` (msg), `feed_moved` (from, to: a permanent redirect followed, the address
updated), `download_done` (bytes), `download_error` (msg, url),
`torrent_deferred`, `reaped`, `scan_done` (feeds checked), `reap_done`, `status` (feeds, pending,
downloaded: the healthcheck's, every 30s), `error` (msg).
`error.type` is the HTTP status (`404`, `503`) or one of `dns`, `redirect_loop`, `timeout`, `tls`,
`not_a_feed`, `site_message`, `connect`, `parse`, `other`; Loki's `| json` names it `error_type`.
Filter on the text before `| json` where you can (`|= "\"ev\":\"feed_error\""`): it is much
cheaper than parsing every line. Lines before 2026-09-29 14:00 UTC are text, not JSON, and
`| json | __error__=""` drops them.
## The checks
Run these, then read the lines behind whatever stands out. Most of the time is in the reading:
a count says something happened, the lines and traces say why.
1. **Warnings and errors, grouped.** What went wrong, how often, and since when.
```
$Q metric 'sum by (target, message) (count_over_time({container="iPX"} | json | __error__="" | level=~"WARN|ERROR" [$range]))'
```
Feed and download failures name the feed in the message; group them in the next check instead.
2. **Failing feeds and downloads.**
```
$Q metric 'sum by (feed, error_type) (count_over_time({container="iPX"} |= "\"ev\":\"feed_error\"" | json | __error__="" [$range]))' 7d
$Q metric 'sum by (feed, error_type) (count_over_time({container="iPX"} |= "\"ev\":\"download_error\"" | json | __error__="" [$range]))' 7d
```
Tell the publisher's problems from ipx's. A 404, 410, DNS failure or 503 from the feed's own
server is the publisher (worth saying, since the feed may have moved; one issue for a feed
that has been dead for days, not for a 503 once). A parse error on a feed that loads in a
browser, a redirect loop ipx should follow, or the same failure on many feeds at once is ipx.
3. **Server errors and slow requests.**
```
$Q metric 'sum by (method, route, status) (count_over_time({container="iPX"} |= "\"target\":\"ipx::http\"" | json | __error__="" | status >= 500 [$range]))'
$Q metric 'topk(10, quantile_over_time(0.95, {container="iPX"} |= "\"target\":\"ipx::http\"" | json | __error__="" | route != "" | route != "/api/events" | unwrap duration_ms [$range]) by (method, route))'
```
Any 5xx is worth a look. 401s are people signing in, not a problem unless one address is
hammering. For a slow route, find its traces (check 5) and see which span holds the time.
4. **Is the worker keeping up?** Scans should finish regularly, the queue should drain, and the
daemon should not be restarting on its own.
```
$Q metric 'sum(count_over_time({container="iPX"} |= "\"ev\":\"scan_done\"" [$range]))' 6h
$Q logs '{container="iPX"} |= "\"ev\":\"status\"" | json | line_format "{{.timestamp}} pending={{.pending}} downloaded={{.downloaded}}"' 6h
$Q logs '{container="iPX"} |= "daemon started"' 7d
```
A `daemon started` not matched by a deploy (see `git log` and the image's build time) is a
crash or an OOM kill: check `docker inspect iPX -f '{{.State.OOMKilled}} {{.RestartCount}}'`
and the lines just before it. A pending count that only grows means downloads are not
keeping up or not running.
Since 2026-10-02 the daemon sleeps until the next feed is due (at most 10 minutes) instead of
scanning every minute (#114), so expect tens of scans in 6 hours, not 360, nearly all with
`feeds` above 0; none at all for over 10 minutes means the worker is stuck. And `pending` is
the real queue (#113): files a scan will download on its own. Back-catalogue files are
`held`, listed but not counted, so it is usually 0 or a handful.
5. **Slow and failed traces.**
```
$Q traces '{resource.deployment.environment.name="production" && duration > 5s}'
$Q traces '{resource.deployment.environment.name="production" && status = error}'
$Q trace <id>
```
Scans (`scan`) are long by nature, since they fetch many feeds one after another: look for one
`feed` or `fetch` span holding most of it, or a `download` far slower than its size explains.
A web request over a second is worth a look; the trace shows whether the time is in the
handler or a scan it waited on.
Also check the container itself, since Loki cannot see a daemon that is not running:
```sh
docker ps --filter name=iPX --format '{{.Status}}'
docker logs --since 10m iPX 2>&1 | tail -5
```
`docker logs` and `docker exec iPX ipx ...` are fine. Do not query production's Postgres
directly: ask the user if a question needs the database.
## What to do with what you find
Follow the repository's rules in CLAUDE.md: **every problem found gets a Gitea issue**, with a
closed stdin and a timeout on `tea`. Before filing, list the open issues and do not file one
twice; comment on the existing issue with the new evidence instead.
```sh
R="--login git.sdf1.net --repo rays/ipx"
t() { timeout 30 /src/tea "$@" < /dev/null; }
t issues list $R --state open
t issues create $R -t "<what is wrong, as the user would notice it>" -L bug -d "<what, where, since when, how often, the query or trace id that shows it>"
```
Put in each issue what would let someone pick it up cold: the LogQL or TraceQL that shows it, a
trace id, the first time it was seen and how often. A publisher's dead feed is worth one issue
saying so (the user may want to unsubscribe or find its new address); a single 503 is not.
Finish with a short report to the user: what is healthy, what is wrong (with the issue numbers),
and anything you could not tell from logs and traces alone. Do not fix things unless asked; the
check is for finding them.
## When the checks come back empty
Check that there is data before concluding all is well: `$Q metric 'sum(count_over_time({container="iPX"} [1h]))' 1h`
should be in the hundreds or more. Nothing at all means Alloy is not shipping (it can take a few
minutes to pick up a container after a deploy), or the container is down.

View File

@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""Ask production's Loki or Tempo a question, from anywhere that can run docker on Tower.
query.py logs '<LogQL log query>' [since] lines, newest first
query.py metric '<LogQL metric query>' [since] one value per series; $range is `since`
query.py traces '<TraceQL query>' [since] matching traces, slowest first
query.py trace <trace id> one trace's spans, as a tree
`since` is 1h, 24h, 7d and the like (default 24h). Loki and Tempo publish no query port on the
host, so each call runs a throwaway alpine container on the monitoring project's network.
"""
import json, subprocess, sys, time, urllib.parse
NET = "monitoring_default"
def fetch(url):
out = subprocess.run(["docker", "run", "--rm", "--network", NET, "alpine", "wget", "-qO-", url],
capture_output=True, text=True, timeout=120)
if out.returncode:
sys.exit(f"query failed: {out.stderr.strip() or out.stdout.strip()}\n{url}")
return json.loads(out.stdout)
def seconds(since):
return int(since[:-1]) * {"m": 60, "h": 3600, "d": 86400}[since[-1]]
def main():
if len(sys.argv) < 3:
sys.exit(__doc__)
kind, q = sys.argv[1], sys.argv[2]
since = sys.argv[3] if len(sys.argv) > 3 else "24h"
now = time.time()
start = now - seconds(since)
enc = urllib.parse.quote
if kind == "logs":
r = fetch(f"http://loki:3100/loki/api/v1/query_range?query={enc(q)}&limit=500"
f"&start={int(start * 1e9)}&end={int(now * 1e9)}&direction=backward")
lines = [(ts, line) for s in r["data"]["result"] for ts, line in s["values"]]
for ts, line in sorted(lines, reverse=True):
print(line)
print(f"-- {len(lines)} line(s){' (limit reached)' if len(lines) >= 500 else ''}", file=sys.stderr)
elif kind == "metric":
r = fetch(f"http://loki:3100/loki/api/v1/query?query={enc(q.replace('$range', since))}&time={int(now * 1e9)}")
rows = sorted(r["data"]["result"], key=lambda s: -float(s["value"][1]))
for s in rows:
labels = {k: v for k, v in s["metric"].items() if k not in ("container", "compose_project", "service_name")}
print(f"{s['value'][1]:>12} {json.dumps(labels) if labels else ''}")
print(f"-- {len(rows)} series", file=sys.stderr)
elif kind == "traces":
r = fetch(f"http://tempo:3200/api/search?q={enc(q)}&start={int(start)}&end={int(now)}&limit=100")
traces = sorted(r.get("traces", []), key=lambda t: -t.get("durationMs", 0))
for t in traces:
when = time.strftime("%m-%d %H:%M:%S", time.gmtime(int(t["startTimeUnixNano"]) / 1e9))
print(f"{t.get('durationMs', 0):>8}ms {when}Z {t['traceID']} {t.get('rootTraceName', '')}")
print(f"-- {len(traces)} trace(s)", file=sys.stderr)
elif kind == "trace":
r = fetch(f"http://tempo:3200/api/traces/{q}")
spans = [sp for b in r.get("batches", r.get("resourceSpans", []))
for ss in b.get("scopeSpans", b.get("instrumentationLibrarySpans", [])) for sp in ss["spans"]]
kids = {}
for sp in spans:
kids.setdefault(sp.get("parentSpanId", ""), []).append(sp)
def show(sp, depth):
ms = (int(sp["endTimeUnixNano"]) - int(sp["startTimeUnixNano"])) / 1e6
attrs = {a["key"]: next(iter(a["value"].values()), None) for a in sp.get("attributes", [])
if not a["key"].startswith(("code.", "thread.")) and a["key"] not in ("busy_ns", "idle_ns", "target")}
err = " ERROR" if sp.get("status", {}).get("code") in (2, "STATUS_CODE_ERROR") else ""
print(f"{' ' * depth}{sp['name']} {ms:.0f}ms{err} {json.dumps(attrs) if attrs else ''}")
for e in sp.get("events", []):
msg = next((a["value"].get("stringValue") for a in e.get("attributes", []) if a["key"] == "message"), e.get("name"))
# A scan logs a skip for every feed not due; they bury what happened.
if '"ev":"feed_skip"' in (msg or ""):
continue
print(f"{' ' * depth} - {msg}")
for k in sorted(kids.get(sp["spanId"], []), key=lambda s: int(s["startTimeUnixNano"])):
show(k, depth + 1)
ids = {sp["spanId"] for sp in spans}
for root in [sp for sp in spans if sp.get("parentSpanId", "") not in ids]:
show(root, 0)
else:
sys.exit(__doc__)
if __name__ == "__main__":
main()

3
.gitignore vendored
View File

@@ -3,3 +3,6 @@
/test-results /test-results
/playwright-report /playwright-report
/web/dist /web/dist
.claude/settings.local.json
__pycache__/

View File

@@ -7,6 +7,112 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added
- Artwork is kept on disk once shown and loads from iPX, not from each publisher's server: fast after the first time, and still there when the publisher's server is not. The admin page sets how much is kept (500 MB by default).
- `ipx add --list --category News <url>` puts a feed in the Directory for anyone to subscribe to,
and it stays there when its last subscriber leaves. Run for a feed already in the catalogue,
it lists it or sets its category.
- Feeds nobody subscribes to that are dead for a month or quiet for a year are removed, so the
Directory lists feeds worth taking.
### Changed
- An item published without a title shows its opening words, in plain text rather than bold, instead of "(untitled)"; one with no text either shows its file's name, or its show and date. Opened, it starts with its text.
- A feed that has moved for good (a permanent redirect) is followed to its new address, which iPX then reads from, and says so in the log as `feed_moved`. A temporary redirect changes nothing.
- The daemon sleeps until the next feed is due, at most ten minutes, instead of looking every minute; refreshing or adding a feed still wakes it at once.
- A pinned feed's pin sits on the corner of its artwork, as a failing feed's mark does, instead of before its name.
- The Directory lists feeds nobody subscribes to yet; Popular still lists what people subscribe to.
A feed nobody subscribes to is checked once a day, and at once when someone subscribes. A
listed feed downloads nothing until someone subscribes.
- The Directory loads faster: it asked the database three questions per feed.
### Fixed
- The download queue, in `ipx status`, `/api/status` and the dashboard, counts only what iPX will download on its own: older episodes beyond a show's limit, and files of feeds that do not download automatically, are listed but no longer counted as waiting.
- Artwork published on http is stored on https when its host serves it there, so the page loads it directly; the rest still comes through iPX.
- An iPhone adding the site to its home screen finds the icon at the first address it tries.
- A feed whose server hangs no longer holds up every scan: a feed gets 30 seconds, and connecting anywhere 10.
## [0.9.1] - 2026-09-29
### Added
- `IPX_LOG_FORMAT=json` logs one JSON object a line, with a request's method, route, status and time
and a scan's or download's details as fields, for Loki and the like.
- A Grafana dashboard for iPX, from its log in Loki and its traces in Tempo (`grafana/dashboard.py`).
- With `OTEL_EXPORTER_OTLP_ENDPOINT` set, the daemon sends traces of its scans, downloads and web
requests to a collector such as Tempo.
- A feed that has no artwork of its own shows its website's icon instead.
- The refresh button turns while its feed is being checked, and the check-every-feed buttons
while any of yours is.
- Adding a website's address subscribes to the feed that site links, instead of failing on every scan.
- `/api/status` gives the number of feeds, items waiting to download and files downloaded, and
the version, for a dashboard such as Homepage.
### Changed
- The start-up line about the web UI being reachable off the machine says what guards it, and is a warning only when there is no Cloudflare Access in front of it.
- The feed list updates just the feed that changed, as a scan checks it or you read an item, instead of reloading the whole list.
- A scan fetches several feeds at once, so a refresh no longer waits on every site in turn.
- An open page reloads the feed list only when a scan has checked something, not every minute.
- Adding an address checks it first: a feed is added, a web page adds the feed it links, and anything else is refused with the reason, instead of being added and failing on every check.
- A feed that was failing when its last subscriber left is forgotten, rather than kept with its error for good. One that worked, or has files on disk, is kept as before.
- A feed that keeps failing is checked less and less often, waiting as long as it has been failing, up to once a day; it goes back to its schedule as soon as it works. Refreshing it still checks it at once.
- A scan's trace shows the time a feed spends on its artwork and in the database after the fetch.
- The JSON log carries each line's `trace_id` and `span_id`, logs each scan event once instead of
twice, names a failure's kind in `error.type` (and its HTTP status in
`http.response.status_code`), and calls a request's time `duration_ms` instead of `ms`.
- `ipx list` shows each feed's id on a line of its own, labelled, under its title.
- Each browser keeps its own theme, so a phone and a desktop can differ. A browser that has not
chosen one yet starts from the theme your account had.
- The Modern theme takes its colours from the new logo: its navy, the blue of its bars and the
orange of its needle.
- The logo follows the page: the dark version in a dark theme, the light one in a light theme.
- The browser tab's icon follows the page's light or dark mode too.
- On a phone, the top bar leaves out the logo, giving its room to the search box.
- Between releases, the version on the logo says so: 0.9.1-dev, not 0.9.0.
- The logo's needle is a brighter, redder orange that stands apart from the blue bars.
- The logo is in the top bar beside the add-feed button, in place of the name at the top of the
feed list. Hovering it shows iPX's version.
### Removed
- `ipx copy-db`, the one-off move from SQLite to Postgres. Going back needs only the old `state.db`.
- Starting up no longer looks for files downloaded twice before 0.6.0 read WordPress's player
links correctly; that clean-up has run.
### Fixed
- Adding a site without `https://`, such as `cnn.com`, works; it failed on every check.
- A limit on new downloads per check means a show's newest episodes: with it set to 3, ipx no longer works back through the show's history three at a time. Set to 0, it still takes the whole back catalogue.
- With no limit on new downloads per check (`max_new_per_check = 0`), downloads work on Postgres; they failed.
- Checking a feed with a long history is much quicker: items already stored are no longer written again on every check.
- A scan no longer asks a feed's website for its icon every time; it asks again when the artwork changes or you refresh the feed.
- The feed list loads several times faster: it was asking the database six questions per feed.
- Artwork a feed offers only over plain http shows on the https site too.
- A failing feed is easy to spot in any theme: a mark on its artwork, what is wrong in place of
its counts, and its artwork greyed out once it has failed for a day.
- A feed whose own artwork is missing shows its website's icon instead of nothing.
- The log has no terminal colour codes when it is not going to a terminal, as in `docker logs`.
- A feed whose website names an icon that is missing shows the site's `/favicon.ico` instead of
no artwork.
- A website's icon standing in for a feed's artwork follows the site when it changes, and a
feed that drops its own artwork gets the site's instead. Refreshing a feed checks again.
- The read button shows whether an item is read, as the pin beside it shows whether it is
pinned: a tick when read, an envelope when not. It used to show the opposite.
- In Directory and Popular, Subscribe is a plus again, not the tick that marks a feed you have.
- A feed's refresh button looks like the buttons beside it, instead of standing out filled in.
- Refreshing reads your feeds in full, instead of only asking each one whether it changed, so
a feed that has not changed is still read again.
- A web page with some non-ASCII characters no longer hides the feed it links, or crashes looking for it.
- Pulling the item list down to check for new items shows a spinner for a couple of seconds, and
a second pull meanwhile does nothing, instead of no sign at all that the check started.
- Settings no longer lists the server's download folder, which only an admin can change, on
the admin page.
- On the Unread tab, a swipe back (or k) goes to the item you just read, instead of past it or
back to the list. The items read on the way leave the Unread tab once you close the reader.
## [0.9.0] - 2026-09-28 ## [0.9.0] - 2026-09-28
### Added ### Added
@@ -614,6 +720,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `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.9.0...main [unreleased]: https://git.sdf1.net/rays/ipx/compare/v0.9.0...main
[0.9.1]: https://git.sdf1.net/rays/ipx/compare/v0.9.0...v0.9.1
[0.9.0]: https://git.sdf1.net/rays/ipx/compare/v0.8.4...v0.9.0 [0.9.0]: https://git.sdf1.net/rays/ipx/compare/v0.8.4...v0.9.0
[0.8.4]: https://git.sdf1.net/rays/ipx/compare/v0.8.3...v0.8.4 [0.8.4]: https://git.sdf1.net/rays/ipx/compare/v0.8.3...v0.8.4
[0.8.3]: https://git.sdf1.net/rays/ipx/compare/v0.8.2...v0.8.3 [0.8.3]: https://git.sdf1.net/rays/ipx/compare/v0.8.2...v0.8.3

View File

@@ -170,6 +170,10 @@ Non-trivial logic leaves one runnable check behind. Pure functions (`merge_polic
watch the shutdown channel itself; the daemon ignored SIGTERM for exactly this reason. watch the shutdown channel itself; the daemon ignored SIGTERM for exactly this reason.
* Only one daemon per socket. Removing the socket file defeats the guard and you get two daemons * Only one daemon per socket. Removing the socket file defeats the guard and you get two daemons
fighting over the database, with the stale one still holding the port. fighting over the database, with the stale one still holding the port.
* **The Grafana dashboard reads the log's fields** (`grafana/dashboard.py`). Production logs JSON
(`IPX_LOG_FORMAT=json`); the access log's `method`, `path`, `route`, `status`, `duration_ms` and
the events' `ev`, `feed`, `new`, `bytes`, `msg` (`log_event` in ipc.rs) are what the panels query.
Rename one and its panels go blank without an error; regenerate the dashboard to match.
* `/api/settings` answering `200` does **not** mean the daemon is well — the web server is a * `/api/settings` answering `200` does **not** mean the daemon is well — the web server is a
different task. `ipx status` checks the control socket and the database; to see the worker different task. `ipx status` checks the control socket and the database; to see the worker
getting through its jobs, watch for `scan complete` in the log. getting through its jobs, watch for `scan complete` in the log.
@@ -198,8 +202,11 @@ body, where `git log` and `git blame` find it beside the change. (There was a lo
`docs/history.md` until 0.7.0; it grew too large to be useful and was removed. It is in git.) `docs/history.md` until 0.7.0; it grew too large to be useful and was removed. It is in git.)
Cutting a release: rename `[Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD` and open a new empty Cutting a release: rename `[Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD` and open a new empty
`[Unreleased]` above it, bump `version` in `Cargo.toml`, tag the commit `vX.Y.Z`, and update the `[Unreleased]` above it, set `version` in `Cargo.toml` to `X.Y.Z` (dropping `-dev`), tag the commit
compare links at the bottom of the changelog. `vX.Y.Z`, and update the compare links at the bottom of the changelog. Then, in the next commit,
set `version` to the next patch with `-dev` (after 0.9.0, `0.9.1-dev`), so a build between releases says so
in the logo's tooltip instead of claiming to be the last release. The release that follows can
still be a minor or major one; `-dev` only says the work comes after `X.Y.Z`.
Deliberate simplifications get a `ponytail:` comment naming the ceiling and the upgrade path, e.g. Deliberate simplifications get a `ponytail:` comment naming the ceiling and the upgrade path, e.g.
`// ponytail: global connection mutex, move to a pool if feed count makes it contend`. `// ponytail: global connection mutex, move to a pool if feed count makes it contend`.

127
Cargo.lock generated
View File

@@ -1820,7 +1820,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
[[package]] [[package]]
name = "ipx" name = "ipx"
version = "0.9.0" version = "0.9.2-dev"
dependencies = [ dependencies = [
"ammonia", "ammonia",
"anyhow", "anyhow",
@@ -1832,6 +1832,9 @@ dependencies = [
"futures-util", "futures-util",
"jsonwebtoken", "jsonwebtoken",
"librqbit", "librqbit",
"opentelemetry",
"opentelemetry-otlp",
"opentelemetry_sdk",
"opml", "opml",
"percent-encoding", "percent-encoding",
"quick-xml 0.42.0", "quick-xml 0.42.0",
@@ -1845,6 +1848,7 @@ dependencies = [
"tower", "tower",
"tower-http 0.7.1", "tower-http 0.7.1",
"tracing", "tracing",
"tracing-opentelemetry",
"tracing-subscriber", "tracing-subscriber",
"url", "url",
] ]
@@ -2710,6 +2714,76 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "opentelemetry"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cdb0b1b267eb9db3331b434ed9ddab10d50e280a9adf9d13e5233e2002b61b5"
dependencies = [
"futures-core",
"futures-sink",
"js-sys",
"pin-project-lite",
"thiserror 2.0.20",
]
[[package]]
name = "opentelemetry-http"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee2c3625b8aa04209f7e01e513bc8044da9687fa38a9eacf59628ca5f3b87300"
dependencies = [
"async-trait",
"bytes",
"http",
"opentelemetry",
"reqwest",
]
[[package]]
name = "opentelemetry-otlp"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "699a67345e21962a231955b9059a157e428b219fa5df8efe11f08346fb23a344"
dependencies = [
"http",
"httpdate",
"opentelemetry",
"opentelemetry-http",
"opentelemetry-proto",
"opentelemetry_sdk",
"prost",
"reqwest",
"thiserror 2.0.20",
]
[[package]]
name = "opentelemetry-proto"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25da1ac11a0aeccf38d7f77ee0348715adaf8340f65ad46c94a02c6b20e2f65d"
dependencies = [
"opentelemetry",
"opentelemetry_sdk",
"prost",
]
[[package]]
name = "opentelemetry_sdk"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb39533d9d1c912123efd7d41d7e0c29d16917b60ce15b4c8d87cb1af7f67520"
dependencies = [
"futures-channel",
"futures-executor",
"futures-util",
"opentelemetry",
"percent-encoding",
"portable-atomic",
"rand 0.9.5",
"thiserror 2.0.20",
]
[[package]] [[package]]
name = "opml" name = "opml"
version = "1.1.6" version = "1.1.6"
@@ -2925,6 +2999,29 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "prost"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1"
dependencies = [
"bytes",
"prost-derive",
]
[[package]]
name = "prost-derive"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
dependencies = [
"anyhow",
"itertools 0.14.0",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "quanta" name = "quanta"
version = "0.12.6" version = "0.12.6"
@@ -3225,6 +3322,7 @@ dependencies = [
"base64 0.23.1", "base64 0.23.1",
"bytes", "bytes",
"encoding_rs", "encoding_rs",
"futures-channel",
"futures-core", "futures-core",
"futures-util", "futures-util",
"h2", "h2",
@@ -4563,6 +4661,30 @@ dependencies = [
"tracing-core", "tracing-core",
] ]
[[package]]
name = "tracing-opentelemetry"
version = "0.34.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a904802a1b902f43638b677ff2a650847e3b4404101b6c586d648e8c1e3e8fe"
dependencies = [
"js-sys",
"opentelemetry",
"tracing",
"tracing-core",
"tracing-subscriber",
"web-time",
]
[[package]]
name = "tracing-serde"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1"
dependencies = [
"serde",
"tracing-core",
]
[[package]] [[package]]
name = "tracing-subscriber" name = "tracing-subscriber"
version = "0.3.23" version = "0.3.23"
@@ -4573,12 +4695,15 @@ dependencies = [
"nu-ansi-term", "nu-ansi-term",
"once_cell", "once_cell",
"regex-automata", "regex-automata",
"serde",
"serde_json",
"sharded-slab", "sharded-slab",
"smallvec", "smallvec",
"thread_local", "thread_local",
"tracing", "tracing",
"tracing-core", "tracing-core",
"tracing-log", "tracing-log",
"tracing-serde",
] ]
[[package]] [[package]]

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "ipx" name = "ipx"
version = "0.9.0" version = "0.9.2-dev"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
@@ -14,6 +14,9 @@ clap = { version = "4.6.6", features = ["derive"] }
futures-util = { version = "0.3.34", default-features = false, features = ["std"] } futures-util = { version = "0.3.34", default-features = false, features = ["std"] }
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["aws_lc_rs"] } jsonwebtoken = { version = "11.1.0", default-features = false, features = ["aws_lc_rs"] }
librqbit = { version = "9.0.1", default-features = false, features = ["rust-tls", "http-api-client"] } librqbit = { version = "9.0.1", default-features = false, features = ["rust-tls", "http-api-client"] }
opentelemetry = { version = "0.33", default-features = false, features = ["trace"] }
opentelemetry-otlp = { version = "0.33", default-features = false, features = ["trace", "http-proto", "reqwest-blocking-client"] }
opentelemetry_sdk = { version = "0.33", default-features = false, features = ["trace"] }
opml = "1.1.6" opml = "1.1.6"
percent-encoding = "2.3.2" percent-encoding = "2.3.2"
quick-xml = { version = "0.42.0", features = ["escape-html"] } quick-xml = { version = "0.42.0", features = ["escape-html"] }
@@ -27,5 +30,6 @@ 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"] }
tracing = "0.1.44" tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } tracing-opentelemetry = { version = "0.34", default-features = false }
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] }
url = "2.5.8" url = "2.5.8"

View File

@@ -17,8 +17,8 @@ behind iPodderX (2004-2008, Ray Slakinski & August Trometer).
sign in with a password or through a proxy (Cloudflare Zero Trust or Authentik), and admins sign in with a password or through a proxy (Cloudflare Zero Trust or Authentik), and admins
manage accounts and settings. manage accounts and settings.
- **Scanning.** Feeds are checked on a schedule, globally or per feed, and a feed's own TTL is - **Scanning.** Feeds are checked on a schedule, globally or per feed, and a feed's own TTL is
honoured. Keyword, explicit-content and media-type filters decide what is downloaded, with a cap honoured. Keyword, explicit-content and media-type filters decide what is downloaded, with a limit
on new downloads per scan. on how many of a feed's newest episodes are downloaded.
- **Downloads.** Files come over HTTP or BitTorrent and are filed into a folder per feed. - **Downloads.** Files come over HTTP or BitTorrent and are filed into a folder per feed.
Retention deletes the oldest files to stay under a disk quota or an age limit, and never touches Retention deletes the oldest files to stay under a disk quota or an age limit, and never touches
an item someone has pinned. an item someone has pinned.

View File

@@ -8,6 +8,12 @@ services:
PGID: "100" PGID: "100"
TZ: "America/Toronto" TZ: "America/Toronto"
IPX_LOG: "ipx=info" IPX_LOG: "ipx=info"
# One JSON object a line, which Loki (through Alloy) and the Grafana dashboard read.
IPX_LOG_FORMAT: "json"
# Traces to Tempo, in the monitoring project; ipx sends none without it.
OTEL_EXPORTER_OTLP_ENDPOINT: "http://192.168.1.130:4318"
# What the dashboard filters on, so a daemon run by hand for testing stays out of it.
OTEL_RESOURCE_ATTRIBUTES: "deployment.environment.name=production"
# IPX_DATABASE_URL=postgres://... to use Postgres; without it, /data/state.db (SQLite). # IPX_DATABASE_URL=postgres://... to use Postgres; without it, /data/state.db (SQLite).
env_file: env_file:
- ipx.env # relative: Arcane resolves it inside its own container - ipx.env # relative: Arcane resolves it inside its own container

View File

@@ -4,7 +4,7 @@ Two places. **config.toml** holds what ipx needs before it reaches its database,
who gets in: where things are (`download_dir`, `socket`, `organize`), `[torrent]` and `[web]`. who gets in: where things are (`download_dir`, `socket`, `organize`), `[torrent]` and `[web]`.
**The database** holds the catalogue of feeds (`[feeds.<id>]` below) and the server settings the **The database** holds the catalogue of feeds (`[feeds.<id>]` below) and the server settings the
admin page edits (`schedule`, `max_total_gb`, `max_age_days`, `max_new_per_check`, admin page edits (`schedule`, `max_total_gb`, `max_age_days`, `max_new_per_check`,
`media_types`). Change those in the web UI, or with `ipx add`, `ipx rm` and `ipx import`; they `media_types`, `art_cache_mb`). Change those in the web UI, or with `ipx add`, `ipx rm` and `ipx import`; they
take effect without a restart. take effect without a restart.
The first time ipx meets a database that holds no catalogue, it takes the feeds and those The first time ipx meets a database that holds no catalogue, it takes the feeds and those
@@ -25,8 +25,7 @@ config.toml's default location is `$XDG_CONFIG_HOME/ipx/config.toml`
`~` is expanded in paths. The database is SQLite in WAL mode unless `IPX_DATABASE_URL` names a `~` is expanded in paths. The database is SQLite in WAL mode unless `IPX_DATABASE_URL` names a
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`. `ipx copy-db <state.db>` copies a with `sqlite3 state.db .backup`; back Postgres up with `pg_dump`.
SQLite database into the empty Postgres one `IPX_DATABASE_URL` names.
## `[general]` ## `[general]`
@@ -40,21 +39,30 @@ max_total_gb = 50 # 0 = unlimited
max_age_days = 30 # 0 = keep forever max_age_days = 30 # 0 = keep forever
max_new_per_check = 3 # per feed, per scan. 0 = unlimited max_new_per_check = 3 # per feed, per scan. 0 = unlimited
media_types = ["audio", "video"] media_types = ["audio", "video"]
art_cache_mb = 500 # artwork kept on disk. 0 = none
``` ```
`schedule`, `max_total_gb`, `max_age_days`, `max_new_per_check` and `media_types` move into the `schedule`, `max_total_gb`, `max_age_days`, `max_new_per_check`, `media_types` and `art_cache_mb` move into the
database as described above; `download_dir`, `socket` and `organize` stay in config.toml. database as described above; `download_dir`, `socket` and `organize` stay in config.toml.
* **`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. Admin-only from the UI. 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
after its first success. 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
read it. `0` disables it entirely. read it. `0` disables it entirely.
* **`max_age_days`** — items older than this with no file on disk are pruned from the database. * **`max_age_days`** — items older than this with no file on disk are pruned from the database.
Kept ones stay. `0` disables it. Kept ones stay. `0` disables it.
* **`max_new_per_check`** — the cap that stops a new subscription pulling a whole back catalogue. * **`art_cache_mb`** — how much show and episode artwork iPX keeps on disk, in `art/` beside the
`0` means unlimited, which is rarely what you want: subscribing to an OPML of 80 feeds with no cap database, so the page loads it from iPX rather than from every publisher's server. Over this,
what has gone longest unshown goes first. `0` keeps none: artwork still comes through iPX, fetched
each time. 500 by default.
* **`max_new_per_check`** — how many of a feed's newest episodes are downloaded; older ones stay
listed to download by hand. It stops a new subscription pulling a whole back catalogue.
`0` means every episode, for an archive; set it on the feeds you want archived, since on the
global default it applies to every feed: subscribing to an OPML of 80 feeds with no limit
fetched 216 files and 22 GB in one scan. fetched 216 files and 22 GB in one scan.
* **`media_types`** — top-level MIME types taken automatically. Anything else is still listed and * **`media_types`** — top-level MIME types taken automatically. Anything else is still listed and
can be fetched by hand; blog feeds put each article's header image in an `<enclosure>`, and can be fetched by hand; blog feeds put each article's header image in an `<enclosure>`, and
@@ -121,6 +129,7 @@ folder = "Accidental Tech Podcast" # default: the feed title
schedule = "every 6h" # overrides [general] for this feed schedule = "every 6h" # overrides [general] for this feed
media_types = ["audio"] # overrides [general] for this feed media_types = ["audio"] # overrides [general] for this feed
category = "Technology" # the Directory's, if the feed names none category = "Technology" # the Directory's, if the feed names none
listed = true # in the Directory with no subscribers
username = "ray" # HTTP basic auth username = "ray" # HTTP basic auth
password_env = "IPX_ATP_PASS" # preferred over a literal `password` password_env = "IPX_ATP_PASS" # preferred over a literal `password`
``` ```
@@ -130,6 +139,12 @@ With more than one account, **`keywords`, `auto_download`, `allow_explicit` and
config.toml are the fallback for a feed nobody has claimed. The keys above describe the feed itself config.toml are the fallback for a feed nobody has claimed. The keys above describe the feed itself
and are the same for everyone. See [users.md](users.md). and are the same for everyone. See [users.md](users.md).
**The Directory** lists every feed in the catalogue, whether or not anyone subscribes to it yet.
To put one there for others to find, `ipx add --list --category News <url>`: it subscribes no one,
and a listed feed stays when its last subscriber leaves, where another is dropped. A feed nobody
subscribes to is checked once a day. One that is dead (failing for 30 days) or quiet (nothing
new in a year), with nobody subscribed and no file on disk, is removed from the catalogue.
Feeds derived from a subscribed OPML are **not** in the catalogue: the OPML is the source of truth Feeds derived from a subscribed OPML are **not** in the catalogue: the OPML is the source of truth
and they are re-derived on every scan. Editing one in the UI promotes it to a catalogue entry. and they are re-derived on every scan. Editing one in the UI promotes it to a catalogue entry.
@@ -142,5 +157,7 @@ and they are re-derived on every scan. Editing one in the UI promotes it to a ca
| `IPX_DATABASE_URL` | A `postgres://user:password@host:port/database` URL: use that database instead of `state.db` | | `IPX_DATABASE_URL` | A `postgres://user:password@host:port/database` URL: use that database instead of `state.db` |
| `IPX_TEST_DATABASE_URL` | For `cargo test`: run the database tests on this Postgres database too, each in a schema of its own | | `IPX_TEST_DATABASE_URL` | For `cargo test`: run the database tests on this Postgres database too, each in a schema of its own |
| `IPX_LOG` | What reaches stderr (`ipx=debug`, `ipx::scan=debug`, …) | | `IPX_LOG` | What reaches stderr (`ipx=debug`, `ipx::scan=debug`, …) |
| `IPX_LOG_FORMAT` | `json` for one JSON object a line, with each request's and event's fields as its own (for Loki and the like); text otherwise |
| `IPX_UI_LOG` | What the in-process log buffer captures for the UI's Log view | | `IPX_UI_LOG` | What the in-process log buffer captures for the UI's Log view |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | An OTLP/HTTP collector, such as Tempo at `http://host:4318`: the daemon sends it traces of scans, downloads and web requests. The other `OTEL_EXPORTER_OTLP_*` variables apply too |
| `http_proxy` / `https_proxy` | Honoured for feed and enclosure fetches | | `http_proxy` / `https_proxy` | Honoured for feed and enclosure fetches |

View File

@@ -111,9 +111,13 @@ feeds.
Authentik's library lists Authentik's own applications, and ipx signs in through the one Authentik's library lists Authentik's own applications, and ipx signs in through the one
called `Cloudflare Access`, so ipx needs a bookmark of its own to show up there. It is called `Cloudflare Access`, so ipx needs a bookmark of its own to show up there. It is
Applications → Applications → `ipodderx`: no provider, launch URL `https://ipodderx.sdf1.net`, and Applications → Applications → `iPX` (slug `ipodderx`): no provider, launch URL
the ipx logo. Like Outline's, it has no policy bindings, so everyone in Authentik sees the `https://ipodderx.sdf1.net`, and web/logo.svg at 512px as its icon, uploaded again when the logo
tile. Who actually gets in is still up to the Access policy. changes. The library keeps each person's list of tiles in a cache that an edit to the application
does not clear, so after one, clear it (System → Policies → Clear cache, or
`POST /api/v3/policies/all/cache_clear/`) or the old name and icon stay up. It has no policy
bindings, so everyone in Authentik sees the tile. Who actually gets in is still up to the Access
policy.
### Check it ### Check it

174
grafana/dashboard.py Normal file
View File

@@ -0,0 +1,174 @@
"""The iPX dashboard in Grafana, from Loki (the container's log, shipped by Alloy) and Tempo.
python3 grafana/dashboard.py > /mnt/fast/arcane/projects/monitoring/grafana-provisioning/dashboards/ipx.json
Grafana reads that file on its own within a minute; edits made in Grafana are refused. The panels
read the fields of ipx's JSON log (IPX_LOG_FORMAT=json): renaming a field in web.rs access_log or
ipc.rs log_event has to be matched here. `dashboard.py queries` prints each
query, to try against Loki.
"""
import json, sys
LOKI = {"type": "loki", "uid": "${loki}"}
TEMPO = {"type": "tempo", "uid": "${tempo}"}
SEL = '{container="iPX"}'
# ipx logs one JSON object a line (IPX_LOG_FORMAT=json). Each scan and download event carries its
# fields (ev, feed, new, bytes, msg, error.type, ...); each request its method, path, route, status
# and duration_ms. Events are logged under ipx::scan, the healthcheck's status under ipx::io, so
# the filter is on the ev field, not the target.
EV = SEL + ' |= "\\"ev\\":\\"" | json | __error__="" | ev != ""'
HTTP = SEL + ' |= "\\"target\\":\\"ipx::http\\"" | json | __error__="" | path != "/api/events"'
BAD = SEL + ' | json | __error__="" | level =~ "WARN|ERROR"'
TEXT = ' | line_format "{{.level}} {{.target}}: {{.message}}"'
TRACES = '{resource.service.name="ipx" && resource.deployment.environment.name="production"'
def status(field):
return f'max(max_over_time({EV} | ev = "status" | unwrap {field} [10m]))'
QUERIES = {}
panels, y = [], 0
pid = 0
def panel(kind, title, w, h, x, targets, **extra):
global pid
pid += 1
p = {"id": pid, "type": kind, "title": title, "gridPos": {"x": x, "y": y, "w": w, "h": h},
"datasource": targets[0].get("datasource", LOKI), "targets": targets}
p.update(extra)
panels.append(p)
return p
def loki(expr, ref="A", legend=None, instant=False, kind=None):
QUERIES[expr] = instant
t = {"refId": ref, "datasource": LOKI, "expr": expr, "queryType": "instant" if instant else "range"}
if legend:
t["legendFormat"] = legend
return t
def row(title):
global y, pid
pid += 1
panels.append({"id": pid, "type": "row", "title": title, "collapsed": False,
"gridPos": {"x": 0, "y": y, "w": 24, "h": 1}, "panels": []})
y += 1
def stat(title, expr, x, unit="short", color="blue", thresholds=None, desc=None):
steps = thresholds or [{"color": color, "value": None}]
return panel("stat", title, 4, 4, x, [loki(expr, instant=True)], description=desc or "",
fieldConfig={"defaults": {"unit": unit, "color": {"mode": "thresholds"},
"thresholds": {"mode": "absolute", "steps": steps}}, "overrides": []},
options={"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": False},
"colorMode": "value", "graphMode": "none", "textMode": "value"})
def ts(title, targets, x, w=12, h=8, unit="short", bars=False, stack=False, desc=""):
custom = {"drawStyle": "bars" if bars else "line", "fillOpacity": 60 if bars else 10,
"lineWidth": 1, "showPoints": "never", "stacking": {"mode": "normal" if stack else "none"}}
return panel("timeseries", title, w, h, x, targets, description=desc,
fieldConfig={"defaults": {"unit": unit, "custom": custom}, "overrides": []},
options={"legend": {"displayMode": "list", "placement": "bottom"},
"tooltip": {"mode": "multi", "sort": "desc"}})
def table(title, targets, x, w=12, h=8, rename=None, sort=None, desc=""):
return panel("table", title, w, h, x, targets, description=desc,
transformations=[{"id": "labelsToFields", "options": {"mode": "columns"}},
{"id": "organize", "options": {
"excludeByName": {"Time": True, "container": True, "compose_project": True,
"service_name": True},
"renameByName": rename or {}}}],
options={"showHeader": True, "sortBy": sort or []},
fieldConfig={"defaults": {}, "overrides": []})
# ---- Now
row("Now")
stat("Feeds", status("feeds"), 0, desc="From the healthcheck's status answer, every 30 seconds.")
stat("Waiting to download", status("pending"), 4)
stat("Downloaded", status("downloaded"), 8, color="green")
stat("Feed failures", f'sum(count_over_time({EV} | ev="feed_error" [$__range])) or vector(0)', 12,
thresholds=[{"color": "green", "value": None}, {"color": "orange", "value": 1}],
desc="Failed feed checks in the time range.")
stat("Download failures", f'sum(count_over_time({EV} | ev="download_error" [$__range])) or vector(0)', 16,
thresholds=[{"color": "green", "value": None}, {"color": "orange", "value": 1}])
stat("Warnings and errors", f'sum(count_over_time({BAD} [$__range])) or vector(0)', 20,
thresholds=[{"color": "green", "value": None}, {"color": "orange", "value": 1}, {"color": "red", "value": 50}])
y += 4
# ---- Scans
row("Scans and downloads")
ts("New items found", [loki(f'sum(sum_over_time({EV} | ev="feed_done" | unwrap new [$__interval]))', legend="new items")],
0, bars=True)
ts("Downloads", [loki(f'sum(count_over_time({EV} | ev="download_done" [$__interval]))', legend="saved"),
loki(f'sum(count_over_time({EV} | ev="download_error" [$__interval]))', ref="B", legend="failed")],
12, bars=True)
y += 8
ts("Bytes downloaded", [loki(f'sum(sum_over_time({EV} | ev="download_done" | unwrap bytes [$__interval]))', legend="bytes")],
0, unit="bytes", bars=True)
ts("Feeds checked per scan", [loki(f'sum(sum_over_time({EV} | ev="scan_done" | unwrap feeds [$__interval]))', legend="feeds checked")],
12, bars=True, desc="Feeds that were due and fetched; the rest were skipped as not due.")
y += 8
table("Failing feeds", [loki(f'sum by (feed, msg) (count_over_time({EV} | ev="feed_error" [$__range]))', instant=True)],
0, rename={"feed": "Feed", "msg": "Error", "Value": "Failures"}, sort=[{"displayName": "Failures", "desc": True}])
table("Failed downloads", [loki(f'sum by (feed, msg) (count_over_time({EV} | ev="download_error" [$__range]))', instant=True)],
12, rename={"feed": "Feed", "msg": "Error", "Value": "Failures"}, sort=[{"displayName": "Failures", "desc": True}])
y += 8
# ---- Web
row("Web")
ts("Requests by status", [loki(f'sum by (status) (count_over_time({HTTP} [$__interval]))', legend="{{status}}")],
0, bars=True, stack=True, desc="The event stream the page keeps open is left out.")
ts("Response time", [loki(f'quantile_over_time(0.5, {HTTP} | unwrap duration_ms [$__interval]) by ()', legend="median"),
loki(f'quantile_over_time(0.95, {HTTP} | unwrap duration_ms [$__interval]) by ()', ref="B", legend="95th percentile"),
loki(f'max_over_time({HTTP} | unwrap duration_ms [$__interval]) by ()', ref="C", legend="slowest")],
12, unit="ms")
y += 8
table("Slowest routes", [loki(f'topk(15, avg_over_time({HTTP} | route != "" | unwrap duration_ms [$__range]) by (method, route))', instant=True)],
0, rename={"method": "Method", "route": "Route", "Value": "Average ms"}, sort=[{"displayName": "Average ms", "desc": True}])
table("Busiest routes", [loki(f'topk(15, sum by (method, route) (count_over_time({HTTP} | route != "" [$__range])))', instant=True)],
12, rename={"method": "Method", "route": "Route", "Value": "Requests"}, sort=[{"displayName": "Requests", "desc": True}])
y += 8
# ---- Traces
row("Traces")
for x, title, q in [(0, "Recent traces", TRACES + "}"),
(12, "Slow traces (over 2s)", TRACES + " && duration > 2s}")]:
panel("table", title, 12, 10, x,
[{"refId": "A", "datasource": TEMPO, "queryType": "traceql", "query": q, "limit": 50,
"tableType": "traces"}],
fieldConfig={"defaults": {}, "overrides": []})
y += 10
# ---- Log
row("Log")
panel("logs", "Warnings and errors", 24, 10, 0, [loki(BAD + TEXT)],
options={"showTime": True, "wrapLogMessage": True, "sortOrder": "Descending", "enableLogDetails": True})
y += 10
panel("logs", "Log", 24, 12, 0,
[loki(SEL + ' | json | __error__="" | path != "/api/events" | ev != "feed_skip" | ev != "status"'
' | message != "-> {\\"cmd\\":\\"status\\"}"' + TEXT)],
description="Without the event stream's requests, not-due skips and healthcheck status calls.",
options={"showTime": True, "wrapLogMessage": True, "sortOrder": "Descending", "enableLogDetails": True})
dash = {
"uid": "ipx", "title": "iPX", "tags": ["ipx"], "timezone": "browser", "schemaVersion": 39,
"time": {"from": "now-24h", "to": "now"}, "refresh": "1m", "editable": True,
"templating": {"list": [
{"name": "loki", "label": "Logs", "type": "datasource", "query": "loki", "current": {}, "hide": 0},
{"name": "tempo", "label": "Traces", "type": "datasource", "query": "tempo", "current": {}, "hide": 0},
]},
"links": [{"title": "iPX", "type": "link", "url": "https://ipodderx.sdf1.net", "targetBlank": True}],
"panels": panels,
}
if sys.argv[1:] == ["queries"]:
for q, instant in QUERIES.items():
print(json.dumps([q, instant]))
else:
print(json.dumps(dash, indent=2))

View File

@@ -37,6 +37,10 @@ module.exports = defineConfig({
IPX_CONFIG: `${setup.root}/config/config.toml`, IPX_CONFIG: `${setup.root}/config/config.toml`,
IPX_DATA_DIR: `${setup.root}/data`, IPX_DATA_DIR: `${setup.root}/data`,
IPX_LOG: 'ipx=info', IPX_LOG: 'ipx=info',
// Blank, whatever the shell running the suite has: the test daemon sends no traces to
// production's Tempo and never opens production's database.
OTEL_EXPORTER_OTLP_ENDPOINT: '',
IPX_DATABASE_URL: '',
}, },
}, },
], ],

92
src/art.rs Normal file
View File

@@ -0,0 +1,92 @@
//! Artwork kept on disk, so the page loads it from iPX: fast after the first time, nothing asked
//! of each publisher's server for every visit, and still there when a publisher is not.
use std::path::{Path, PathBuf};
pub fn dir() -> PathBuf {
crate::config::data_dir().join("art")
}
/// Where an image's bytes are kept; its content type is beside it, in `<name>.type`.
// ponytail: std's hasher, 64 bits, keyed by the URL. Its algorithm may change with Rust, which
// only makes the cache miss once and refill; a collision among a few thousand images is about
// one in 10^12. Move to a SHA if either ever matters.
pub fn path(url: &str) -> PathBuf {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
url.hash(&mut h);
dir().join(format!("{:016x}", h.finish()))
}
/// A kept image and its type, marked as just used, which is what keeps it from being trimmed.
pub fn read(file: &Path) -> Option<(String, Vec<u8>)> {
let kind = std::fs::read_to_string(file.with_extension("type")).ok()?;
let body = std::fs::read(file).ok()?;
if let Ok(f) = std::fs::File::options().write(true).open(file) {
let _ = f.set_modified(std::time::SystemTime::now());
}
Some((kind, body))
}
/// Keeps an image, written aside and renamed into place, so a page asking for it meanwhile
/// never reads half of one.
pub fn write(file: &Path, kind: &str, body: &[u8]) -> std::io::Result<()> {
std::fs::create_dir_all(file.parent().unwrap_or(Path::new(".")))?;
let tmp = file.with_extension("part");
std::fs::write(&tmp, body)?;
std::fs::write(file.with_extension("type"), kind)?;
std::fs::rename(&tmp, file)
}
/// Removes the least recently used images until what is kept fits in `limit` bytes; 0 empties it.
/// Returns how many went.
pub fn trim(dir: &Path, limit: u64) -> usize {
let Ok(entries) = std::fs::read_dir(dir) else { return 0 };
let mut kept: Vec<(std::time::SystemTime, u64, PathBuf)> = entries
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_none())
.filter_map(|p| {
let m = p.metadata().ok()?;
Some((m.modified().ok()?, m.len(), p))
})
.collect();
let mut total: u64 = kept.iter().map(|(_, n, _)| n).sum();
kept.sort();
let mut gone = 0;
for (_, n, p) in kept {
if total <= limit {
break;
}
let _ = std::fs::remove_file(p.with_extension("type"));
if std::fs::remove_file(&p).is_ok() {
total -= n;
gone += 1;
}
}
gone
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_least_recently_used_go_first_until_it_fits() {
let d = std::env::temp_dir().join(format!("ipx-art-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
for (name, age) in [("old", 300), ("mid", 200), ("new", 100)] {
let f = d.join(name);
write(&f, "image/png", &[0; 1000]).unwrap();
let t = std::time::SystemTime::now() - std::time::Duration::from_secs(age);
std::fs::File::options().write(true).open(&f).unwrap().set_modified(t).unwrap();
}
// Shown just now: no longer the oldest.
assert_eq!(read(&d.join("old")).unwrap().0, "image/png");
assert_eq!(trim(&d, 2000), 1);
assert!(!d.join("mid").exists() && !d.join("mid.type").exists());
assert!(d.join("old").exists() && d.join("new").exists());
assert_eq!(trim(&d, 0), 2);
let _ = std::fs::remove_dir_all(&d);
}
}

View File

@@ -39,6 +39,9 @@ pub struct General {
/// image in an <enclosure>, so taking everything filled the disk with artwork and /// image in an <enclosure>, so taking everything filled the disk with artwork and
/// counted it as episodes. Empty means take anything. /// counted it as episodes. Empty means take anything.
pub media_types: Vec<String>, pub media_types: Vec<String>,
/// How much artwork iPX keeps on disk, in MB, so the page loads it from iPX rather than from
/// every publisher; the least recently shown goes first. 0 keeps none.
pub art_cache_mb: u64,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
@@ -163,6 +166,10 @@ pub struct Feed {
/// Preferred over `password`: name of an env var holding the password. /// Preferred over `password`: name of an env var holding the password.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub password_env: Option<String>, pub password_env: Option<String>,
/// Put in the Directory by an admin (`ipx add --list`): listed with no subscribers, and kept
/// in the catalogue when the last one leaves, where anyone else's feed is dropped.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub listed: bool,
} }
fn yes() -> bool { fn yes() -> bool {
@@ -180,6 +187,7 @@ impl Default for General {
max_age_days: 0, max_age_days: 0,
max_new_per_check: 3, max_new_per_check: 3,
media_types: vec!["audio".into(), "video".into()], media_types: vec!["audio".into(), "video".into()],
art_cache_mb: 500,
} }
} }
} }
@@ -290,10 +298,18 @@ pub struct Stored {
pub max_age_days: u64, pub max_age_days: u64,
pub max_new_per_check: usize, pub max_new_per_check: usize,
pub media_types: Vec<String>, pub media_types: Vec<String>,
/// Settings saved before it existed have none: they get the default.
#[serde(default = "art_cache_mb")]
pub art_cache_mb: u64,
}
fn art_cache_mb() -> u64 {
General::default().art_cache_mb
} }
/// `[general]` keys that live in the database once it holds the configuration. /// `[general]` keys that live in the database once it holds the configuration.
const STORED_KEYS: [&str; 5] = ["schedule", "max_total_gb", "max_age_days", "max_new_per_check", "media_types"]; const STORED_KEYS: [&str; 6] =
["schedule", "max_total_gb", "max_age_days", "max_new_per_check", "media_types", "art_cache_mb"];
impl Stored { impl Stored {
pub fn of(cfg: &Config) -> Self { pub fn of(cfg: &Config) -> Self {
@@ -304,6 +320,7 @@ impl Stored {
max_age_days: g.max_age_days, max_age_days: g.max_age_days,
max_new_per_check: g.max_new_per_check, max_new_per_check: g.max_new_per_check,
media_types: g.media_types.clone(), media_types: g.media_types.clone(),
art_cache_mb: g.art_cache_mb,
} }
} }
@@ -314,6 +331,7 @@ impl Stored {
g.max_age_days = self.max_age_days; g.max_age_days = self.max_age_days;
g.max_new_per_check = self.max_new_per_check; g.max_new_per_check = self.max_new_per_check;
g.media_types = self.media_types; g.media_types = self.media_types;
g.art_cache_mb = self.art_cache_mb;
} }
} }
@@ -571,6 +589,12 @@ mod tests {
assert!(wanted_media(Some("image/jpeg"), &["image/jpeg".to_string()])); assert!(wanted_media(Some("image/jpeg"), &["image/jpeg".to_string()]));
} }
#[test]
fn settings_saved_before_the_art_cache_keep_the_default() {
let old = r#"{"schedule":"every 1h","max_total_gb":0.0,"max_age_days":0,"max_new_per_check":3,"media_types":["audio"]}"#;
assert_eq!(serde_json::from_str::<Stored>(old).unwrap().art_cache_mb, 500);
}
#[test] #[test]
fn slugs_are_readable_and_unique() { fn slugs_are_readable_and_unique() {
assert_eq!(slug("Accidental Tech Podcast"), "accidental-tech-podcast"); assert_eq!(slug("Accidental Tech Podcast"), "accidental-tech-podcast");
@@ -584,7 +608,7 @@ mod tests {
taken.insert("the-daily".to_string(), Feed { taken.insert("the-daily".to_string(), Feed {
url: "u".into(), folder: None, group: None, media_types: None, schedule: None, keywords: vec![], allow_explicit: false, url: "u".into(), folder: None, group: None, media_types: None, schedule: None, keywords: vec![], allow_explicit: false,
auto_download: true, max_new_per_check: None, username: None, auto_download: true, max_new_per_check: None, username: None,
password: None, password_env: None, category: None, password: None, password_env: None, category: None, listed: false,
}); });
assert_eq!(unique_slug("The Daily", &taken), "the-daily-2"); assert_eq!(unique_slug("The Daily", &taken), "the-daily-2");
} }
@@ -605,6 +629,7 @@ mod tests {
password: Some("literal".into()), password: Some("literal".into()),
password_env: None, password_env: None,
category: None, category: None,
listed: false,
}; };
assert_eq!(f.password().as_deref(), Some("literal")); assert_eq!(f.password().as_deref(), Some("literal"));

664
src/db.rs
View File

@@ -71,30 +71,6 @@ async fn create_missing(orm: &sea_orm::DatabaseConnection) -> Result<()> {
Ok(()) Ok(())
} }
/// One table's rows, a page at a time in primary-key order, from one database to another.
async fn copy_table<E>(from: &sea_orm::DatabaseConnection, to: &impl ConnectionTrait) -> Result<u64>
where
E: EntityTrait,
E::Model: sea_orm::IntoActiveModel<E::ActiveModel> + Send + Sync,
E::ActiveModel: ActiveModelTrait<Entity = E> + Send,
{
use sea_orm::{IntoActiveModel, Iterable, PrimaryKeyToColumn};
let mut query = E::find();
for key in E::PrimaryKey::iter() {
query = query.order_by_asc(key.into_column());
}
let mut pages = query.paginate(from, 1000);
let mut n = 0;
while let Some(rows) = pages.fetch_and_next().await? {
n += rows.len() as u64;
// reset_all: every column written, the primary key included, not just the changed ones.
E::insert_many(rows.into_iter().map(|m| m.into_active_model().reset_all()))
.exec_without_returning(to)
.await?;
}
Ok(n)
}
/// Where the database is: IPX_DATABASE_URL, a postgres:// URL, when it is set; otherwise the /// Where the database is: IPX_DATABASE_URL, a postgres:// URL, when it is set; otherwise the
/// SQLite file in the data directory, as it has always been. /// SQLite file in the data directory, as it has always been.
pub fn location() -> String { pub fn location() -> String {
@@ -337,6 +313,7 @@ impl Db {
Ok(()) Ok(())
} }
#[tracing::instrument(skip_all)]
pub async fn feed_summary(&self, feed_id: &str) -> Result<FeedSummary> { pub async fn feed_summary(&self, feed_id: &str) -> Result<FeedSummary> {
let mut sum = feeds::Entity::find_by_id(feed_id.to_owned()) let mut sum = feeds::Entity::find_by_id(feed_id.to_owned())
.one(&self.orm) .one(&self.orm)
@@ -357,6 +334,96 @@ impl Db {
sum.downloaded = self.downloaded_count(feed_id).await?; sum.downloaded = self.downloaded_count(feed_id).await?;
Ok(sum) Ok(sum)
} }
/// What the feed list shows of every feed, for one person, in five queries whatever the
/// number of feeds. Asked feed by feed (feed_summary, http_state, blocklist, unread_count)
/// it was six round trips a feed, about 950 for 160 feeds and 320 ms a page load (#94).
pub async fn feed_list(
&self,
user_id: i64,
// One feed only, for the row a live update sends (`web::feed_rows`).
only: Option<&str>,
) -> Result<std::collections::HashMap<String, FeedListing>> {
use std::collections::HashMap;
let one = || sea_orm::Value::from(only.map(str::to_owned));
let counts = |sql: &'static str, args: Vec<sea_orm::Value>| async move {
self.rows(sql, args)
.await?
.iter()
.map(|r| Ok((r.try_get::<String>("", "feed_id")?, r.try_get::<i64>("", "n")?)))
.collect::<Result<HashMap<_, _>>>()
};
let entries = counts(
"SELECT feed_id, count(*) AS n FROM entries
WHERE (CAST($1 AS TEXT) IS NULL OR feed_id = $1) GROUP BY feed_id",
vec![one()],
)
.await?;
let downloaded = counts(
"SELECT feed_id, count(*) AS n FROM enclosures
WHERE path IS NOT NULL AND (CAST($1 AS TEXT) IS NULL OR feed_id = $1) GROUP BY feed_id",
vec![one()],
)
.await?;
let unread = counts(
"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
LEFT JOIN entry_state s
ON s.user_id = $1 AND s.feed_id = e.feed_id AND s.guid = e.guid
WHERE NOT coalesce(s.read, false)
AND (CAST($2 AS TEXT) IS NULL OR e.feed_id = $2)
AND NOT EXISTS (SELECT 1 FROM hidden h
WHERE h.user_id = $1 AND h.feed_id = e.feed_id AND h.guid = e.guid)
GROUP BY e.feed_id",
vec![user_id.into(), one()],
)
.await?;
let mut blocked: HashMap<String, Vec<String>> = blocklists::Entity::find()
.filter(blocklists::Column::UserId.eq(user_id))
.all(&self.orm)
.await?
.into_iter()
.map(|b| (b.feed_id, keywords(Some(b.words)).unwrap_or_default()))
.collect();
let mut rows = feeds::Entity::find();
if let Some(id) = only {
rows = rows.filter(feeds::Column::Id.eq(id));
}
Ok(rows
.all(&self.orm)
.await?
.into_iter()
.map(|f| {
let id = f.id.clone();
let listing = FeedListing {
summary: FeedSummary {
entries: entries.get(&id).copied().unwrap_or(0),
downloaded: downloaded.get(&id).copied().unwrap_or(0),
title: f.title,
image: f.image,
last_checked: f.last_checked,
last_error: f.last_error,
orphaned: f.orphaned,
error_since: f.error_since,
category: f.category,
},
ttl_mins: f.ttl_mins.map(|t| t.max(0) as u64),
blocked: blocked.remove(&id).unwrap_or_default(),
unread: unread.get(&id).copied().unwrap_or(0),
};
(id, listing)
})
.collect())
}
}
/// One feed as the feed list shows it; see `Db::feed_list`.
#[derive(Debug, Default)]
pub struct FeedListing {
pub summary: FeedSummary,
pub ttl_mins: Option<u64>,
pub blocked: Vec<String>,
pub unread: i64,
} }
@@ -367,24 +434,36 @@ pub struct HttpState {
pub last_modified: Option<String>, pub last_modified: Option<String>,
pub last_checked: Option<i64>, pub last_checked: Option<i64>,
pub ttl_mins: Option<u64>, pub ttl_mins: Option<u64>,
/// When the feed's current run of failures began, for backing off (`due_after`).
pub error_since: Option<i64>,
} }
impl Db { impl From<feeds::Model> for HttpState {
pub async fn http_state(&self, feed_id: &str) -> Result<HttpState> { fn from(f: feeds::Model) -> Self {
Ok(feeds::Entity::find_by_id(feed_id.to_owned()) HttpState {
.one(&self.orm)
.await?
.map(|f| HttpState {
etag: f.etag, etag: f.etag,
last_modified: f.last_modified, last_modified: f.last_modified,
last_checked: f.last_checked, last_checked: f.last_checked,
ttl_mins: f.ttl_mins.map(|t| t.max(0) as u64), ttl_mins: f.ttl_mins.map(|t| t.max(0) as u64),
}) error_since: f.error_since,
.unwrap_or_default()) }
}
}
impl Db {
pub async fn http_state(&self, feed_id: &str) -> Result<HttpState> {
Ok(feeds::Entity::find_by_id(feed_id.to_owned()).one(&self.orm).await?.map(HttpState::from).unwrap_or_default())
}
/// Every feed's, in one query: a scan, and the daemon working out when the next is due, asked
/// feed by feed, 180 queries a minute (#114).
pub async fn http_states(&self) -> Result<std::collections::HashMap<String, HttpState>> {
Ok(feeds::Entity::find().all(&self.orm).await?.into_iter().map(|f| (f.id.clone(), HttpState::from(f))).collect())
} }
/// Upsert after a successful poll. Clears any previous error. /// Upsert after a successful poll. Clears any previous error.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
#[tracing::instrument(skip_all)]
pub async fn record_feed( pub async fn record_feed(
&self, &self,
feed_id: &str, feed_id: &str,
@@ -574,18 +653,60 @@ pub struct Pending {
} }
impl Db { impl Db {
/// The download queue is the table, not the parse result: an enclosure held back by /// The download queue is the table, not the parse result: what a scan could not finish is
/// `max_new_per_check` is simply picked up by the next scan, in feed order. /// picked up by the next. Only from the feed's `limit` newest items with a file, though: a
/// limit of 3 means the three latest episodes. Taking the newest three still pending, each
/// full read of a feed took the three before the ones already downloaded, working back
/// through every show's history: 4420 files queued across 12 shows on the default of 3
/// (#97). Unlimited, 0 in the settings, is the whole back catalogue, for an archive. An item
/// whose files were all skipped by a filter does not hold one of the places.
/// Puts a feed's waiting files in the queue or out of it: 'pending' for those among its
/// `limit` newest items, which the scan downloads on its own, and 'held' for the rest, listed
/// to download by hand. With all of them 'pending', the queue counted 4420 back-catalogue
/// files no scan would ever take (#97, #113). 0 holds them all, for a feed nobody downloads
/// automatically. A held file still holds its item's place among the newest.
#[tracing::instrument(skip_all)]
pub async fn hold_back(&self, feed_id: &str, limit: usize) -> Result<()> {
let newest = "SELECT n.guid FROM entries n
WHERE n.feed_id = $1
AND EXISTS (SELECT 1 FROM enclosures y
WHERE y.feed_id = n.feed_id AND y.guid = n.guid
AND y.state <> 'skipped')
ORDER BY coalesce(n.published, n.first_seen) DESC, n.guid DESC
LIMIT $2";
let limit: sea_orm::Value = (limit.min(i64::MAX as usize) as i64).into();
self.exec(
&format!("UPDATE enclosures SET state = 'held' WHERE feed_id = $1 AND state = 'pending' AND guid NOT IN ({newest})"),
vec![feed_id.into(), limit.clone()],
)
.await?;
// And back, when a limit is raised or a feed downloads again.
self.exec(
&format!("UPDATE enclosures SET state = 'pending' WHERE feed_id = $1 AND state = 'held' AND guid IN ({newest})"),
vec![feed_id.into(), limit],
)
.await?;
Ok(())
}
#[tracing::instrument(skip_all)]
pub async fn pending(&self, feed_id: &str, limit: usize) -> Result<Vec<Pending>> { pub async fn pending(&self, feed_id: &str, limit: usize) -> Result<Vec<Pending>> {
// Newest first: a cap of 3 should mean the three latest episodes, not the three that
// happen to have been recorded first.
self.rows( self.rows(
"SELECT x.id, x.url, x.mime FROM enclosures x "SELECT x.id, x.url, x.mime FROM enclosures x
JOIN entries e ON e.feed_id = x.feed_id AND e.guid = x.guid JOIN entries e ON e.feed_id = x.feed_id AND e.guid = x.guid
WHERE x.feed_id = $1 AND x.state = 'pending' WHERE x.feed_id = $1 AND x.state = 'pending'
AND e.guid IN (SELECT n.guid FROM entries n
WHERE n.feed_id = $1
AND EXISTS (SELECT 1 FROM enclosures y
WHERE y.feed_id = n.feed_id AND y.guid = n.guid
AND y.state <> 'skipped')
ORDER BY coalesce(n.published, n.first_seen) DESC, n.guid DESC
LIMIT $2)
ORDER BY coalesce(e.published, e.first_seen) DESC, x.id DESC ORDER BY coalesce(e.published, e.first_seen) DESC, x.id DESC
LIMIT $2", LIMIT $2",
vec![feed_id.into(), (limit as i64).into()], // Unlimited is usize::MAX, which `as i64` makes -1: SQLite reads LIMIT -1 as no
// limit, Postgres refuses it, and the feed's downloads failed (#98).
vec![feed_id.into(), (limit.min(i64::MAX as usize) as i64).into()],
) )
.await? .await?
.iter() .iter()
@@ -674,6 +795,96 @@ impl Db {
/// Old entries that never had a file, or no longer have one. Enclosure rows stay -- /// Old entries that never had a file, or no longer have one. Enclosure rows stay --
/// they are the dedupe history. /// they are the dedupe history.
/// Forgets feeds nobody subscribes to that were failing when the last subscriber left: not in
/// the catalogue, no file on disk. Unsubscribing leaves a feed's row and history behind, which
/// is right for one that worked; one that never did, like cnn-com added without its https://,
/// sat in the database with its error for good. Returns the ids forgotten.
pub async fn prune_abandoned_failures(&self) -> Result<Vec<String>> {
let gone: Vec<String> = self
.rows(
"DELETE FROM feeds
WHERE last_error IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.feed_id = feeds.id)
AND NOT EXISTS (SELECT 1 FROM catalogue c WHERE c.id = feeds.id)
AND NOT EXISTS (SELECT 1 FROM enclosures x WHERE x.feed_id = feeds.id AND x.path IS NOT NULL)
RETURNING id",
vec![],
)
.await?
.iter()
.map(|r| Ok(r.try_get("", "id")?))
.collect::<Result<_>>()?;
for id in &gone {
self.forget_rows(id).await?;
}
Ok(gone)
}
/// Everything stored about a feed but its row: items, file rows, read state, block list.
async fn forget_rows(&self, id: &str) -> Result<()> {
for table in ["enclosures", "entry_state", "hidden", "blocklists", "entries"] {
self.exec(&format!("DELETE FROM {table} WHERE feed_id = $1"), vec![id.into()]).await?;
}
Ok(())
}
/// A feed's stored artwork still on http, items' and its own: hosts a feed no longer names
/// keep their old items' artwork on http otherwise (IGN's 2009 items, #110).
pub async fn http_images(&self, feed_id: &str) -> Result<Vec<String>> {
self.rows(
"SELECT DISTINCT image FROM entries WHERE feed_id = $1 AND image LIKE 'http://%'
UNION SELECT image FROM feeds WHERE id = $1 AND image LIKE 'http://%'",
vec![feed_id.into()],
)
.await?
.iter()
.map(|r| Ok(r.try_get("", "image")?))
.collect()
}
/// Rewrites a feed's stored artwork on `host` from http to https, once the host is known to
/// serve it there (`feed::prefer_https`), for the items a scan does not write again.
pub async fn secure_images(&self, feed_id: &str, host: &str) -> Result<()> {
let like = format!("http://{host}/%");
for table in ["entries", "feeds"] {
let col = if table == "feeds" { "id" } else { "feed_id" };
self.exec(
&format!("UPDATE {table} SET image = 'https://' || substr(image, 8) WHERE {col} = $1 AND image LIKE $2"),
vec![feed_id.into(), like.clone().into()],
)
.await?;
}
Ok(())
}
/// Forgets a feed and everything stored about it.
pub async fn forget_feed(&self, id: &str) -> Result<()> {
self.forget_rows(id).await?;
self.exec("DELETE FROM feeds WHERE id = $1", vec![id.into()]).await?;
Ok(())
}
/// Feeds nobody subscribes to that are dead, failing since before `dead_before`, or quiet,
/// their newest item from before `quiet_before`: what keeps the Directory to feeds worth
/// taking. One with a file on disk stays, and so does one an OPML subscription lists, which
/// comes back with the OPML anyway. A feed with no items yet is not quiet, only new.
pub async fn stale_unsubscribed(&self, dead_before: i64, quiet_before: i64) -> Result<Vec<String>> {
self.rows(
"SELECT f.id FROM feeds f
WHERE NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.feed_id = f.id)
AND NOT EXISTS (SELECT 1 FROM enclosures x WHERE x.feed_id = f.id AND x.path IS NOT NULL)
AND f.group_id IS NULL AND NOT coalesce(f.managed, false)
AND ((f.error_since IS NOT NULL AND f.error_since < $1)
OR (SELECT max(coalesce(e.published, e.first_seen)) FROM entries e
WHERE e.feed_id = f.id) < $2)",
vec![dead_before.into(), quiet_before.into()],
)
.await?
.iter()
.map(|r| Ok(r.try_get("", "id")?))
.collect()
}
pub async fn prune_entries(&self, older_than: i64) -> Result<usize> { pub async fn prune_entries(&self, older_than: i64) -> Result<usize> {
let n = self let n = self
.exec( .exec(
@@ -1079,48 +1290,6 @@ impl Db {
Ok(()) Ok(())
} }
// ---- moving to another database ----
/// Copies every row of `from` into this database, which must be empty: the move from the
/// SQLite file to Postgres (issue #18). One transaction, so a copy that fails part-way leaves
/// nothing behind and can simply be run again. Returns each table's row count.
pub async fn copy_from(&self, from: &Db) -> Result<Vec<(&'static str, u64)>> {
use crate::entity::*;
use sea_orm::TransactionTrait;
let held = users::Entity::find().count(&self.orm).await?
+ feeds::Entity::find().count(&self.orm).await?
+ entries::Entity::find().count(&self.orm).await?;
anyhow::ensure!(held == 0, "the database being copied into already holds rows; it has to be empty");
let tx = self.orm.begin().await?;
// Users first: the tables that belong to a person refer to them.
let counts = vec![
("users", copy_table::<users::Entity>(&from.orm, &tx).await?),
("feeds", copy_table::<feeds::Entity>(&from.orm, &tx).await?),
("entries", copy_table::<entries::Entity>(&from.orm, &tx).await?),
("enclosures", copy_table::<enclosures::Entity>(&from.orm, &tx).await?),
("subscriptions", copy_table::<subscriptions::Entity>(&from.orm, &tx).await?),
("entry_state", copy_table::<entry_state::Entity>(&from.orm, &tx).await?),
("sessions", copy_table::<sessions::Entity>(&from.orm, &tx).await?),
("catalogue", copy_table::<catalogue::Entity>(&from.orm, &tx).await?),
("settings", copy_table::<settings::Entity>(&from.orm, &tx).await?),
("blocklists", copy_table::<blocklists::Entity>(&from.orm, &tx).await?),
("hidden", copy_table::<hidden::Entity>(&from.orm, &tx).await?),
];
if self.orm.get_database_backend() == sea_orm::DbBackend::Postgres {
// The copied ids came with the rows; the counters that hand out new ones start past
// them, or the next account or file would collide with one copied.
for table in ["users", "enclosures"] {
tx.execute_unprepared(&format!(
"SELECT setval(pg_get_serial_sequence('{table}', 'id'), \
coalesce((SELECT max(id) FROM {table}), 0) + 1, false)"
))
.await?;
}
}
tx.commit().await?;
Ok(counts)
}
// ---- hand-written SQL ---- // ---- hand-written SQL ----
// //
// For what reads better as SQL than as a query builder: joins, sums, upserts. Written to // For what reads better as SQL than as a query builder: joins, sums, upserts. Written to
@@ -1186,6 +1355,7 @@ impl Db {
/// and downloads, since one file serves the lot. /// and downloads, since one file serves the lot.
/// In a group, whatever someone has not set on the feed itself comes from their /// In a group, whatever someone has not set on the feed itself comes from their
/// subscription to the group, as the group's settings dialog has always said it does. /// subscription to the group, as the group's settings dialog has always said it does.
#[tracing::instrument(skip_all)]
pub async fn subscribers(&self, feed_id: &str, group: Option<&str>) -> Result<Vec<Sub>> { pub async fn subscribers(&self, feed_id: &str, group: Option<&str>) -> Result<Vec<Sub>> {
let rows = self let rows = self
.rows( .rows(
@@ -1319,6 +1489,7 @@ impl Db {
/// ponytail: every item of the feed for every subscriber, each time the feed is scanned with /// ponytail: every item of the feed for every subscriber, each time the feed is scanned with
/// something new or a list changes. Fine at hundreds of items a feed; look only at new items /// something new or a list changes. Fine at hundreds of items a feed; look only at new items
/// on a scan if a feed with thousands makes scans slow. /// on a scan if a feed with thousands makes scans slow.
#[tracing::instrument(skip_all)]
pub async fn rehide(&self, feed_id: &str) -> Result<()> { pub async fn rehide(&self, feed_id: &str) -> Result<()> {
let lists: Vec<(i64, Vec<String>)> = self let lists: Vec<(i64, Vec<String>)> = self
.rows( .rows(
@@ -1379,6 +1550,14 @@ impl Db {
} }
/// False when they do not subscribe to it, since there is then no row in their list to pin. /// False when they do not subscribe to it, since there is then no row in their list to pin.
/// Whether a feed or an entry names `url` as its artwork: the only addresses /api/art will
/// fetch, so the web server cannot be pointed at anything else.
// ponytail: entries.image is unindexed, a scan per http:// image; index it if that shows up.
pub async fn names_image(&self, url: &str) -> Result<bool> {
Ok(feeds::Entity::find().filter(feeds::Column::Image.eq(url)).count(&self.orm).await? > 0
|| entries::Entity::find().filter(entries::Column::Image.eq(url)).count(&self.orm).await? > 0)
}
pub async fn set_pinned(&self, user_id: i64, feed_id: &str, on: bool) -> Result<bool> { pub async fn set_pinned(&self, user_id: i64, feed_id: &str, on: bool) -> Result<bool> {
let r = subscriptions::Entity::update_many() let r = subscriptions::Entity::update_many()
.col_expr(subscriptions::Column::Pinned, Expr::val(on).into()) .col_expr(subscriptions::Column::Pinned, Expr::val(on).into())
@@ -1517,7 +1696,11 @@ impl Db {
Ok(()) Ok(())
} }
/// The theme this person chose, and light, dark or auto; None for either until they choose. /// The theme this person chose when it was kept on the account, and light, dark or auto; None
/// for either if they never did. Only read now, to give a browser with no theme cookie its
/// first one (issue #69).
// ponytail: users.theme and theme_mode are never written any more; drop them once every
// browser in use has its own cookie.
pub async fn theme(&self, user_id: i64) -> Result<(Option<String>, Option<String>)> { pub async fn theme(&self, user_id: i64) -> Result<(Option<String>, Option<String>)> {
Ok(users::Entity::find_by_id(user_id) Ok(users::Entity::find_by_id(user_id)
.one(&self.orm) .one(&self.orm)
@@ -1526,18 +1709,6 @@ impl Db {
.unwrap_or_default()) .unwrap_or_default())
} }
pub async fn set_theme(&self, user_id: i64, theme: &str, mode: &str) -> Result<()> {
self.update_user(
user_id,
users::ActiveModel {
theme: Set(Some(theme.to_owned())),
theme_mode: Set(Some(mode.to_owned())),
..Default::default()
},
)
.await
}
/// The user behind a session cookie, if it is still live. Idle sessions expire after /// The user behind a session cookie, if it is still live. Idle sessions expire after
/// `max_idle_secs`; touching `seen` is what keeps a session in daily use alive. /// `max_idle_secs`; touching `seen` is what keeps a session in daily use alive.
pub async fn session_user(&self, token: &str, max_idle_secs: i64) -> Result<Option<User>> { pub async fn session_user(&self, token: &str, max_idle_secs: i64) -> Result<Option<User>> {
@@ -1704,76 +1875,6 @@ impl Db {
Ok(()) Ok(())
} }
/// Folds enclosures of one item that `key` says are the same file into the first of them, for
/// WordPress's numbered player URLs (`feed::same_file_key`). The first is the one the parser
/// keeps, so it keeps its row, taking a repeat's file if it has none of its own; the repeats'
/// rows go. Returns how many went and the copies left spare, for the caller to delete.
pub async fn merge_repeated_enclosures(&self, key: impl Fn(&str) -> String) -> Result<(usize, Vec<String>)> {
use sea_orm::TransactionTrait;
use std::collections::hash_map::Entry;
let backend = self.orm.get_database_backend();
let tx = self.orm.begin().await?;
// Items with a URL carrying WordPress's `_=` parameter. A LIKE, with the underscore
// escaped, where SQLite had GLOB '*[?&]_=[0-9]*', which Postgres lacks. It lets through
// `_=` without a number too, which is harmless: `key` only folds `_=` and digits.
let rows = tx
.query_all_raw(Statement::from_string(
backend,
r"SELECT id, feed_id, guid, url, path FROM enclosures
WHERE (feed_id, guid) IN
(SELECT feed_id, guid FROM enclosures
WHERE url LIKE '%?\_=%' ESCAPE '\' OR url LIKE '%&\_=%' ESCAPE '\')
ORDER BY id",
))
.await?;
// The first row of each file, and whether it has the file on disk yet.
let mut first: std::collections::HashMap<(String, String, String), (i64, bool)> = Default::default();
let (mut gone, mut spare) = (0, vec![]);
for r in rows {
let (id, feed, guid, url, path): (i64, String, String, String, Option<String>) = (
r.try_get("", "id")?,
r.try_get("", "feed_id")?,
r.try_get("", "guid")?,
r.try_get("", "url")?,
r.try_get("", "path")?,
);
match first.entry((feed, guid, key(&url))) {
Entry::Vacant(v) => {
v.insert((id, path.is_some()));
}
Entry::Occupied(mut o) => {
let (keep, has) = o.get_mut();
if let Some(p) = path {
if *has {
spare.push(p);
} else {
// The only copy is the repeat's: Rands' episode 97 was downloaded
// under its ?_=2 URL alone.
tx.execute_raw(Statement::from_sql_and_values(
backend,
"UPDATE enclosures SET (path, state, bytes_done, downloaded_at) =
(SELECT path, state, bytes_done, downloaded_at FROM enclosures WHERE id = $2)
WHERE id = $1",
vec![(*keep).into(), id.into()],
))
.await?;
*has = true;
}
}
tx.execute_raw(Statement::from_sql_and_values(
backend,
"DELETE FROM enclosures WHERE id = $1",
vec![id.into()],
))
.await?;
gone += 1;
}
}
}
tx.commit().await?;
Ok((gone, spare))
}
/// Stops treating a feed as derived, because it now has its own config entry. /// Stops treating a feed as derived, because it now has its own config entry.
pub async fn unmanage(&self, id: &str) -> Result<()> { pub async fn unmanage(&self, id: &str) -> Result<()> {
self.exec("UPDATE feeds SET managed = false WHERE id = $1", vec![id.into()]).await?; self.exec("UPDATE feeds SET managed = false WHERE id = $1", vec![id.into()]).await?;
@@ -1795,6 +1896,7 @@ impl Db {
/// read state for them. A Patreon creator read as one feed before it was split into shows /// read state for them. A Patreon creator read as one feed before it was split into shows
/// owns every show's files, and `enclosures.url` is unique, so without this each show would /// owns every show's files, and `enclosures.url` is unique, so without this each show would
/// list its items with nothing to play. /// list its items with nothing to play.
#[tracing::instrument(skip_all)]
pub async fn adopt(&self, parent: &str, child: &str, listed: &[(&str, &str)]) -> Result<()> { pub async fn adopt(&self, parent: &str, child: &str, listed: &[(&str, &str)]) -> Result<()> {
use sea_orm::TransactionTrait; use sea_orm::TransactionTrait;
let holds = enclosures::Entity::find() let holds = enclosures::Entity::find()
@@ -1838,6 +1940,28 @@ impl Db {
/// A feed's enclosures skipped by one of its filters, by URL, with the reason: the verdicts a /// A feed's enclosures skipped by one of its filters, by URL, with the reason: the verdicts a
/// change of settings can overturn. A torrent held back while torrents are off is not a /// change of settings can overturn. A torrent held back while torrents are off is not a
/// filter's call. /// filter's call.
/// The guids of a feed's stored items and the URLs of its files: a scan inserts only what is
/// not among them. Inserting every item it read, stored or not, cost a round trip each, about
/// 10 ms: 13 s a scan for Clarkesworld's 1200 items, and 117 s of a full 315 s scan (#96).
#[tracing::instrument(skip_all)]
pub async fn stored_items(
&self,
feed_id: &str,
) -> Result<(std::collections::HashSet<String>, std::collections::HashSet<String>)> {
let col = |sql: &'static str, name: &'static str| async move {
self.rows(sql, vec![feed_id.into()])
.await?
.iter()
.map(|r| Ok(r.try_get::<String>("", name)?))
.collect::<Result<std::collections::HashSet<_>>>()
};
Ok((
col("SELECT guid FROM entries WHERE feed_id = $1", "guid").await?,
col("SELECT url FROM enclosures WHERE feed_id = $1", "url").await?,
))
}
#[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(
"SELECT url, last_error FROM enclosures "SELECT url, last_error FROM enclosures
@@ -1900,36 +2024,6 @@ pub fn now() -> i64 {
mod tests { mod tests {
use super::*; use super::*;
#[tokio::test]
async fn a_file_wordpress_listed_twice_is_folded_into_one() {
let db = Db::memory().await.unwrap();
db.exec_for_test(
"INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
(1,'f','a','https://x/a.mp3','/d/a-2.mp3','done'),
(2,'f','a','https://x/a.mp3?_=2','/d/a.mp3','done'),
(3,'f','b','https://x/b.mp3',NULL,'reaped'),
(4,'f','b','https://x/b.mp3?_=2','/d/b.mp3','done'),
(5,'f','c','https://x/c.mp3?_=1','/d/c.mp3','done'),
(6,'f','d','https://x/d1.mp3?_=1','/d/d1.mp3','done'),
(7,'f','d','https://x/d2.mp3?_=2','/d/d2.mp3','done');",
).await
.unwrap();
let key = crate::feed::same_file_key;
assert_eq!(db.merge_repeated_enclosures(key).await.unwrap(), (2, vec!["/d/a.mp3".to_string()]));
assert_eq!(
db.i64s_for_test("SELECT id FROM enclosures ORDER BY id").await,
[1, 3, 5, 6, 7],
"a lone ?_=1 and two different files stay"
);
assert_eq!(
db.strings_for_test("SELECT path FROM enclosures WHERE id = 3 UNION ALL SELECT state FROM enclosures WHERE id = 3")
.await,
["/d/b.mp3", "done"],
"the only copy moves, not deleted"
);
assert_eq!(db.merge_repeated_enclosures(key).await.unwrap(), (0, vec![]), "and only once");
}
#[tokio::test] #[tokio::test]
async fn every_sort_column_runs_and_orders_both_ways() { async fn every_sort_column_runs_and_orders_both_ways() {
let db = Db::memory().await.unwrap(); let db = Db::memory().await.unwrap();
@@ -1974,6 +2068,192 @@ mod tests {
assert!(!order_sql("x'; --", "asc").contains("x'")); assert!(!order_sql("x'; --", "asc").contains("x'"));
} }
#[tokio::test]
async fn the_feed_list_in_one_go_matches_asking_feed_by_feed() {
let db = Db::memory().await.unwrap();
db.exec_for_test(
"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 feeds (id, url, title, ttl_mins) VALUES ('f','u','F',30),('g','v','G',null);
INSERT INTO entries (feed_id, guid, first_seen) VALUES ('f','a',0),('f','b',0),('f','c',0),('g','d',0);
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 enclosures (id, feed_id, guid, url, path, state) VALUES
(1,'f','a','u1','/tmp/a','done'),(2,'f','b','u2',null,'pending');
INSERT INTO blocklists (user_id, feed_id, words) VALUES (1,'g','[\"spoiler\"]');",
).await
.unwrap();
let list = db.feed_list(1, None).await.unwrap();
let g = db.feed_list(1, Some("g")).await.unwrap();
assert_eq!(g.keys().collect::<Vec<_>>(), ["g"]);
assert_eq!((g["g"].unread, g["g"].summary.entries, g["g"].blocked.clone()), (1, 1, vec!["spoiler".to_string()]));
for id in ["f", "g"] {
let one = &list[id];
let s = db.feed_summary(id).await.unwrap();
assert_eq!((one.summary.entries, one.summary.downloaded), (s.entries, s.downloaded), "{id}");
assert_eq!(one.unread, db.unread_count(1, id).await.unwrap(), "{id}");
assert_eq!(one.blocked, db.blocklist(1, id).await.unwrap(), "{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
}
#[tokio::test]
async fn a_hosts_artwork_moves_to_https_for_one_feed() {
let db = Db::memory().await.unwrap();
db.exec_for_test(
"INSERT INTO feeds (id, url, image) VALUES ('f','u','http://a.example/logo.png'),('g','v','http://a.example/g.png');
INSERT INTO entries (feed_id, guid, first_seen, image) VALUES
('f','1',0,'http://a.example/1.jpg'),('f','2',0,'http://b.example/2.jpg'),('g','3',0,'http://a.example/3.jpg');",
).await
.unwrap();
let mut old = db.http_images("f").await.unwrap();
old.sort();
assert_eq!(old, ["http://a.example/1.jpg", "http://a.example/logo.png", "http://b.example/2.jpg"]);
db.secure_images("f", "a.example").await.unwrap();
assert_eq!(db.feed_summary("f").await.unwrap().image.as_deref(), Some("https://a.example/logo.png"));
assert!(db.names_image("https://a.example/1.jpg").await.unwrap());
assert!(db.names_image("http://b.example/2.jpg").await.unwrap()); // another host
assert!(db.names_image("http://a.example/3.jpg").await.unwrap()); // another feed
assert_eq!(db.feed_summary("g").await.unwrap().image.as_deref(), Some("http://a.example/g.png"));
}
#[tokio::test]
async fn stale_feeds_nobody_subscribes_to_are_found_and_the_rest_left() {
let db = Db::memory().await.unwrap();
db.exec_for_test(
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',true);
INSERT INTO feeds (id, url, error_since, managed, group_id) VALUES
('dead','a',100,false,null),('quiet','b',null,false,null),('news','c',null,false,null),
('new','d',null,false,null),('wanted','e',100,false,null),('kept','f',100,false,null),
('child','g',100,true,'opml'),('lately','h',900,false,null);
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'wanted');
INSERT INTO entries (feed_id, guid, first_seen, published) VALUES
('quiet','q',0,100),('news','n',0,950),('dead','d',0,950),('kept','k',0,100);
INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES (1,'kept','k','u','/tmp/k','done');",
).await
.unwrap();
// Dead before 500, or nothing newer than 500: dead fails since 100, quiet's newest is 100.
// news published lately, new has no items yet, wanted has a subscriber, kept a file,
// child comes from an OPML, lately began failing after the cutoff.
let mut stale = db.stale_unsubscribed(500, 500).await.unwrap();
stale.sort();
assert_eq!(stale, ["dead", "quiet"]);
db.forget_feed("dead").await.unwrap();
assert_eq!(db.feed_summary("dead").await.unwrap().entries, 0);
assert!(!db.feed_urls().await.unwrap().iter().any(|(id, _)| id == "dead"));
}
#[tokio::test]
async fn a_failing_feed_nobody_subscribes_to_is_forgotten() {
let db = Db::memory().await.unwrap();
db.exec_for_test(
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',true);
INSERT INTO feeds (id, url, last_error) VALUES
('gone','cnn.com','relative URL'),('wanted','u','HTTP 503'),
('kept','v','HTTP 404'),('fine','w',null),('listed','x','HTTP 500');
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'wanted');
INSERT INTO catalogue (id, spec) VALUES ('listed','{}');
INSERT INTO entries (feed_id, guid, first_seen) VALUES ('gone','a',0),('kept','b',0);
INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
(1,'gone','a','u1',null,'pending'),(2,'kept','b','u2','/tmp/b','done');",
).await
.unwrap();
// Subscribed, holding a file, working, or in the catalogue: all stay.
assert_eq!(db.prune_abandoned_failures().await.unwrap(), vec!["gone".to_string()]);
assert_eq!(db.feed_summary("gone").await.unwrap().entries, 0);
assert!(db.enclosure(1).await.unwrap().is_none());
assert_eq!(db.feed_summary("kept").await.unwrap().entries, 1);
assert!(db.prune_abandoned_failures().await.unwrap().is_empty());
}
#[tokio::test]
async fn only_the_newest_files_stay_queued_and_the_rest_are_held() {
let db = Db::memory().await.unwrap();
db.exec_for_test(
"INSERT INTO entries (feed_id, guid, first_seen, published) VALUES
('f','e1',0,100),('f','e2',0,200),('f','e3',0,300),('f','e4',0,400);
INSERT INTO enclosures (id, feed_id, guid, url, state, path) VALUES
(1,'f','e1','u1','pending',null),(2,'f','e2','u2','pending',null),
(3,'f','e3','u3','done','/tmp/3'),(4,'f','e4','u4','pending',null);",
).await
.unwrap();
let states = || async {
let mut v: Vec<(i64, String)> = db.rows("SELECT id, state FROM enclosures ORDER BY id", vec![]).await.unwrap()
.iter().map(|r| (r.try_get("", "id").unwrap(), r.try_get("", "state").unwrap())).collect();
v.sort();
v.into_iter().map(|(_, s)| s).collect::<Vec<_>>()
};
db.hold_back("f", 2).await.unwrap(); // newest two: e4, e3
assert_eq!(states().await, ["held", "held", "done", "pending"]);
db.hold_back("f", 3).await.unwrap(); // a limit raised brings e2 back
assert_eq!(states().await, ["held", "pending", "done", "pending"]);
db.hold_back("f", 0).await.unwrap(); // a feed nobody downloads: all held
assert_eq!(states().await, ["held", "held", "done", "held"]);
db.hold_back("f", usize::MAX).await.unwrap(); // unlimited, an archive: all queued
assert_eq!(states().await, ["pending", "pending", "done", "pending"]);
}
#[tokio::test]
async fn a_limit_takes_the_newest_episodes_not_the_back_catalogue() {
let db = Db::memory().await.unwrap();
db.exec_for_test(
"INSERT INTO entries (feed_id, guid, first_seen, published) VALUES
('f','e1',0,100),('f','e2',0,200),('f','e3',0,300),('f','e4',0,400),('f','e5',0,500);
INSERT INTO enclosures (id, feed_id, guid, url, state, path) VALUES
(1,'f','e1','u1','pending',null),(2,'f','e2','u2','pending',null),
(3,'f','e3','u3','done','/tmp/3'),(4,'f','e4','u4','done','/tmp/4'),
(5,'f','e5','u5','skipped',null);",
).await
.unwrap();
let ids = |p: Vec<Pending>| p.into_iter().map(|p| p.id).collect::<Vec<_>>();
// The newest three with a file worth having are e4, e3 and e2 (e5's was skipped): only
// e2 is waiting. e1 is back catalogue.
assert_eq!(ids(db.pending("f", 3).await.unwrap()), vec![2]);
// Once e2 is down, nothing: before #97 the next scan took e1.
db.exec_for_test("UPDATE enclosures SET state = 'done', path = '/tmp/2' WHERE id = 2").await.unwrap();
assert!(db.pending("f", 3).await.unwrap().is_empty());
// Unlimited is the archive: everything still waiting.
assert_eq!(ids(db.pending("f", usize::MAX).await.unwrap()), vec![1]);
}
#[tokio::test]
async fn an_unlimited_queue_takes_everything_waiting() {
let db = Db::memory().await.unwrap();
db.exec_for_test(
"INSERT INTO entries (feed_id, guid, first_seen) VALUES ('f','a',0),('f','b',0);
INSERT INTO enclosures (id, feed_id, guid, url, state) VALUES (1,'f','a','u1','pending'),(2,'f','b','u2','pending');",
).await
.unwrap();
assert_eq!(db.pending("f", usize::MAX).await.unwrap().len(), 2);
}
#[tokio::test]
async fn a_scan_knows_a_feeds_stored_items_and_files() {
let db = Db::memory().await.unwrap();
db.exec_for_test(
"INSERT INTO entries (feed_id, guid, first_seen) VALUES ('f','a',0),('f','b',0),('g','c',0);
INSERT INTO enclosures (id, feed_id, guid, url, state) VALUES (1,'f','a','u1','pending'),(2,'g','c','u2','pending');",
).await
.unwrap();
let (items, files) = db.stored_items("f").await.unwrap();
assert_eq!(items, ["a", "b"].map(String::from).into());
assert_eq!(files, ["u1"].map(String::from).into()); // u2 is g's: left to the insert to find
}
#[tokio::test]
async fn only_artwork_a_feed_names_is_fetched_for_the_page() {
let db = Db::memory().await.unwrap();
db.exec_for_test(
"INSERT INTO feeds (id, url, image) VALUES ('f','u','http://cdn.example/show.jpg');
INSERT INTO entries (feed_id, guid, first_seen, image) VALUES ('f','a',0,'http://cdn.example/ep.jpg');",
).await
.unwrap();
assert!(db.names_image("http://cdn.example/show.jpg").await.unwrap());
assert!(db.names_image("http://cdn.example/ep.jpg").await.unwrap());
assert!(!db.names_image("http://192.168.1.1/admin").await.unwrap());
}
#[tokio::test] #[tokio::test]
async fn deleting_a_shared_file_asks_about_everyone_else() { async fn deleting_a_shared_file_asks_about_everyone_else() {
let db = Db::memory().await.unwrap(); let db = Db::memory().await.unwrap();

View File

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

View File

@@ -102,6 +102,10 @@ pub mod enclosures {
pub length: Option<i64>, pub length: Option<i64>,
#[sea_orm(column_type = "Text", nullable)] #[sea_orm(column_type = "Text", nullable)]
pub path: Option<String>, pub path: Option<String>,
/// 'pending' is queued: a scan downloads it on its own. 'held' is listed but outside the
/// feed's newest max_new_per_check, or its feed downloads nothing automatically; it can
/// be downloaded by hand (`Db::hold_back`). 'skipped' is filtered out ('last_error' says
/// why), then 'downloading', 'done', 'error', 'reaped'.
#[sea_orm(column_type = "Text")] #[sea_orm(column_type = "Text")]
pub state: String, pub state: String,
#[sea_orm(default_value = 0)] #[sea_orm(default_value = 0)]

View File

@@ -11,6 +11,8 @@ pub struct ParsedFeed {
pub title: Option<String>, pub title: Option<String>,
pub ttl_mins: Option<u64>, pub ttl_mins: Option<u64>,
pub image: Option<String>, pub image: Option<String>,
/// The site the feed belongs to, where a favicon can stand in for missing artwork.
pub site: Option<String>,
/// The channel's first `<itunes:category>`, for the Directory's chips. /// The channel's first `<itunes:category>`, for the Directory's chips.
pub category: Option<String>, pub category: Option<String>,
pub entries: Vec<Entry>, pub entries: Vec<Entry>,
@@ -51,42 +53,72 @@ pub enum Fetched {
}, },
} }
/// Conditional GET. reqwest handles gzip and redirects; the original's hand-rolled /// The longest a feed may take, connecting to the last byte: a scan handles feeds in order, and
/// CONNECT/socket.ssl proxy path is gone -- `system-proxy` reads http_proxy/https_proxy. /// with no limit one hung server held every scan for as long as it did. Dreamwidth answered 504
/// after 60-67 s for a day, and each scan took 70-80 s instead of 15 (#108). A feed is small;
/// downloads, which are not, have no such limit.
pub const FEED_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
/// Conditional GET, following redirects itself: `client` must follow none (`feed_client`), so
/// each hop is seen. The second value is where the feed now is when every hop said so for good
/// (301 or 308): a publisher that moved its feed, which the catalogue should follow rather
/// than be redirected on every read. A temporary redirect (302, 307) moves nothing.
/// `system-proxy` reads http_proxy/https_proxy.
#[tracing::instrument(skip_all, fields(url = %cfg.url))]
pub async fn fetch( pub async fn fetch(
client: &reqwest::Client, client: &reqwest::Client,
cfg: &FeedCfg, cfg: &FeedCfg,
etag: Option<&str>, etag: Option<&str>,
last_modified: Option<&str>, last_modified: Option<&str>,
) -> Result<Fetched> { ) -> Result<(Fetched, Option<String>)> {
let mut req = client.get(&cfg.url); let start = reqwest::Url::parse(&cfg.url).context("the feed's address")?;
let mut url = start.clone();
let mut permanent = true;
for _ in 0..10 {
let mut req = client.get(url.clone()).timeout(FEED_TIMEOUT);
if let Some(tag) = etag { if let Some(tag) = etag {
req = req.header(IF_NONE_MATCH, tag); req = req.header(IF_NONE_MATCH, tag);
} }
if let Some(lm) = last_modified { if let Some(lm) = last_modified {
req = req.header(IF_MODIFIED_SINCE, lm); req = req.header(IF_MODIFIED_SINCE, lm);
} }
if let Some(user) = &cfg.username { // The feed's own host only: a redirect elsewhere must not be handed the password.
if let Some(user) = &cfg.username
&& url.host_str() == start.host_str()
{
req = req.basic_auth(user, cfg.password()); req = req.basic_auth(user, cfg.password());
} }
let resp = req.send().await.context("connecting")?; let resp = req.send().await.context("connecting")?;
if resp.status() == StatusCode::NOT_MODIFIED {
return Ok(Fetched::NotModified);
}
let status = resp.status(); let status = resp.status();
if status.is_redirection() && status != StatusCode::NOT_MODIFIED {
let to = resp
.headers()
.get(reqwest::header::LOCATION)
.and_then(|v| v.to_str().ok())
.ok_or_else(|| anyhow!("HTTP {status} without a Location to go to"))?;
url = url.join(to).with_context(|| format!("redirected to {to:?}, which is not an address"))?;
permanent &= matches!(status, StatusCode::MOVED_PERMANENTLY | StatusCode::PERMANENT_REDIRECT);
continue;
}
let moved = (permanent && url != start).then(|| url.to_string());
if status == StatusCode::NOT_MODIFIED {
return Ok((Fetched::NotModified, moved));
}
if !status.is_success() { if !status.is_success() {
// The original surfaced 401/407 specially; the code is enough for a UI to switch on. // The original surfaced 401/407 specially; the code is enough for a UI to switch on.
return Err(anyhow!("HTTP {status}")); return Err(anyhow!("HTTP {status}"));
} }
let header = |h: reqwest::header::HeaderName| { let header = |h: reqwest::header::HeaderName| {
resp.headers().get(&h).and_then(|v| v.to_str().ok()).map(str::to_owned) resp.headers().get(&h).and_then(|v| v.to_str().ok()).map(str::to_owned)
}; };
let etag = header(ETAG); let etag = header(ETAG);
let last_modified = header(LAST_MODIFIED); let last_modified = header(LAST_MODIFIED);
let bytes = resp.bytes().await.context("reading body")?.to_vec(); let bytes = resp.bytes().await.context("reading body")?.to_vec();
Ok(Fetched::Body { bytes, etag, last_modified }) return Ok((Fetched::Body { bytes, etag, last_modified }, moved));
}
// Worded as reqwest worded it, which failure_kind reads as a redirect loop.
Err(anyhow!("error following redirect for url ({url}): too many redirects"))
} }
/// A stored `last_error`, translated into plain words for whoever subscribes: whose problem /// A stored `last_error`, translated into plain words for whoever subscribes: whose problem
@@ -96,6 +128,37 @@ pub struct Failure {
pub new_url: Option<String>, pub new_url: Option<String>,
} }
/// A failure's kind, for the log's `error.type` (#91): the HTTP status where there is one, as
/// OpenTelemetry names an HTTP error, and otherwise a word for what went wrong. Matches the
/// same wording as `explain_failure`; a message it does not know is "other", never a wrong kind.
pub fn failure_kind(msg: &str) -> (String, Option<u16>) {
let low = msg.to_ascii_lowercase();
let code = low.split("http ").skip(1).find_map(|r| r.get(..3)?.parse::<u16>().ok());
if let Some(c) = code.filter(|c| (100..600).contains(c)) {
return (c.to_string(), Some(c));
}
let kind = if low.contains("dns error") || low.contains("failed to lookup address") || low.contains("no address associated") {
"dns"
} else if low.contains("too many redirects") {
"redirect_loop"
} else if low.contains("timed out") || low.contains("timeout") {
"timeout"
} else if low.contains("certificate") || low.contains("tls") {
"tls"
} else if low.contains("got a web page") {
"not_a_feed"
} else if low.contains("the site sent ") {
"site_message"
} else if low.contains("connect") {
"connect"
} else if low.contains("pars") {
"parse"
} else {
"other"
};
(kind.into(), None)
}
/// Reads a `last_error` the same way `set_feed_error` received it (`format!("{e:#}")` on the /// Reads a `last_error` the same way `set_feed_error` received it (`format!("{e:#}")` on the
/// anyhow chain from `fetch` or `parse`) and says what it means, for the errors worth telling /// anyhow chain from `fetch` or `parse`) and says what it means, for the errors worth telling
/// someone about. Everything else -- a timeout, a 5xx, a 429, a feed that is simply garbled -- /// someone about. Everything else -- a timeout, a 5xx, a 429, a feed that is simply garbled --
@@ -185,11 +248,20 @@ pub fn is_patreon_creator(url: &str) -> bool {
} }
/// What was typed into Add feed, as a URL. A bare Patreon token is taken as its creator's /// What was typed into Add feed, as a URL. A bare Patreon token is taken as its creator's
/// feed, since the token alone says whose it is. /// feed, since the token alone says whose it is. An address with no scheme is https: 'cnn.com'
/// was stored as typed and every check failed with "relative URL without a base" (#101).
pub fn expand_input(input: &str) -> String { pub fn expand_input(input: &str) -> String {
let s = input.trim(); let s = input.trim();
let token = s.len() >= 20 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'); let token = s.len() >= 20 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
if token { format!("https://www.patreon.com/rss?auth={s}") } else { s.to_owned() } if token {
format!("https://www.patreon.com/rss?auth={s}")
} else if let Some(rest) = s.strip_prefix("//") {
format!("https://{rest}")
} else if !s.contains("://") {
format!("https://{s}")
} else {
s.to_owned()
}
} }
/// Whether two URLs are the same feed. One Patreon show has several spellings -- by the /// Whether two URLs are the same feed. One Patreon show has several spellings -- by the
@@ -210,7 +282,7 @@ pub async fn patreon_shows(
) -> Result<(Option<String>, Vec<(String, String)>)> { ) -> Result<(Option<String>, Vec<(String, String)>)> {
// The creator feed names its campaign by number in its self link, a few hundred bytes in. // The creator feed names its campaign by number in its self link, a few hundred bytes in.
// The whole feed runs to megabytes and Patreon ignores Range, so read until it turns up. // The whole feed runs to megabytes and Patreon ignores Range, so read until it turns up.
let mut resp = client.get(url).send().await.context("connecting")?; let mut resp = client.get(url).timeout(FEED_TIMEOUT).send().await.context("connecting")?;
if !resp.status().is_success() { if !resp.status().is_success() {
return Err(anyhow!("Patreon refused the feed: HTTP {}", resp.status())); return Err(anyhow!("Patreon refused the feed: HTTP {}", resp.status()));
} }
@@ -330,7 +402,9 @@ fn plain_text(bytes: &[u8]) -> Option<String> {
/// Letters of Note, the Daily Dot, Hell Gate, The Frame Lab and Daily Kos. /// Letters of Note, the Daily Dot, Hell Gate, The Frame Lab and Daily Kos.
fn alternate_feed_link(bytes: &[u8]) -> Option<String> { fn alternate_feed_link(bytes: &[u8]) -> Option<String> {
let text = String::from_utf8_lossy(bytes); let text = String::from_utf8_lossy(bytes);
let lower = text.to_lowercase(); // ASCII only: to_lowercase changes some characters' length (U+0130 grows a byte), and the
// offsets found in the lowered copy then sliced the original off a char boundary.
let lower = text.to_ascii_lowercase();
let mut pos = 0; let mut pos = 0;
while let Some(rel) = lower[pos..].find("<link") { while let Some(rel) = lower[pos..].find("<link") {
let start = pos + rel; let start = pos + rel;
@@ -349,6 +423,124 @@ fn alternate_feed_link(bytes: &[u8]) -> Option<String> {
None None
} }
/// What someone asked to add, as a feed: the address itself when it is a feed or an OPML list,
/// otherwise the feed its web page links as its own, otherwise an error and nothing is added.
/// A page that linked no feed used to be added as it was and failed on every check, called a
/// feed that moved (#102); cnn.com is one.
pub async fn find_feed(client: &reqwest::Client, url: &str) -> Result<String> {
let read = |u: String| async move {
let resp = client
.get(&u)
.timeout(std::time::Duration::from_secs(30))
.send()
.await
.context("connecting")?;
anyhow::ensure!(resp.status().is_success(), "HTTP {}", resp.status());
let base = resp.url().clone();
anyhow::Ok((base, resp.bytes().await.context("reading")?))
};
let is_feed = |b: &[u8]| is_opml(b) || parse(b).is_ok();
let (base, body) = read(url.to_owned()).await.with_context(|| format!("could not read {url}"))?;
if is_feed(&body) {
return Ok(url.to_owned());
}
if !looks_like_html(&body) {
let why = parse(&body).err().map(|e| format!("{e:#}")).unwrap_or_default();
anyhow::bail!("{url} is not a feed: {why}");
}
let Some(href) = alternate_feed_link(&body) else {
anyhow::bail!("{url} is a web page that links no feed, so there is nothing to subscribe to");
};
let linked = base.join(&href).with_context(|| format!("{url} links {href} as its feed, which is not an address"))?;
let (_, feed) = read(linked.to_string()).await.with_context(|| format!("{url} links {linked} as its feed, but"))?;
anyhow::ensure!(is_feed(&feed), "{url} links {linked} as its feed, but that is not a feed either");
Ok(linked.into())
}
/// Artwork for a feed that has none: the icon its site's page names, or else the site's
/// `/favicon.ico`. None if neither is there.
#[tracing::instrument(skip_all, fields(site = site))]
pub async fn site_icon(client: &reqwest::Client, site: &str) -> Option<String> {
let timeout = std::time::Duration::from_secs(20);
let resp = client.get(site).timeout(timeout).send().await.ok()?;
// Relative to where the page ended up, not where it was asked for: a site that redirects
// to /en/ would otherwise have its icon looked for in the wrong place.
let base = resp.url().clone();
// The icon a page names can be gone: antirez.com names /images/favicon.png, a 404, while its
// /favicon.ico is there. Stored unchecked, it was a broken image that was never looked up again.
if resp.status().is_success()
&& let Ok(page) = resp.bytes().await
&& let Some(href) = page_icon(&page)
&& let Ok(url) = base.join(&href)
&& is_image(client, url.as_str()).await
{
return Some(url.into());
}
let ico = base.join("/favicon.ico").ok()?;
is_image(client, ico.as_str()).await.then(|| ico.into())
}
/// Artwork's address on https when its host serves it there, else as it was. The page is https
/// and must not load http; the host is asked once per `known` (one feed's read), and an http
/// address it does not serve on https stays, for /api/art to fetch (#90). Four of the five
/// hosts the catalogue had on http served the same image on https; The Secret Cabal's CDN
/// presents another name's certificate (#110).
pub async fn prefer_https(
client: &reqwest::Client,
url: &str,
known: &mut std::collections::HashMap<String, bool>,
) -> String {
let Some(rest) = url.strip_prefix("http://") else { return url.to_owned() };
let host = rest.split('/').next().unwrap_or("").to_owned();
let secure = format!("https://{rest}");
let ok = match known.get(&host) {
Some(ok) => *ok,
None => {
let ok = is_image(client, &secure).await;
known.insert(host, ok);
ok
}
};
if ok { secure } else { url.to_owned() }
}
/// Whether `url` answers with an image. A site with no favicon often answers 200 with its home
/// page, which is not an icon.
pub async fn is_image(client: &reqwest::Client, url: &str) -> bool {
let timeout = std::time::Duration::from_secs(20);
let Ok(resp) = client.get(url).timeout(timeout).send().await else { return false };
resp.status().is_success()
&& resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.is_some_and(|t| t.starts_with("image/"))
}
/// The icon a web page names in its `<link>` tags, the larger apple-touch-icon first: a plain
/// `icon` is often 16 pixels, which blurs at the size the list draws artwork.
fn page_icon(bytes: &[u8]) -> Option<String> {
let text = String::from_utf8_lossy(bytes);
// ASCII only, so byte offsets in the lowered copy stay valid in the original.
let lower = text.to_ascii_lowercase();
let (mut touch, mut icon) = (None, None);
let mut pos = 0;
while let Some(at) = lower[pos..].find("<link") {
let start = pos + at;
let Some(end) = lower[start..].find('>').map(|e| start + e) else { break };
pos = end + 1;
let tag = &text[start..end];
let Some(rel) = tag_attr(tag, "rel").map(|r| r.to_ascii_lowercase()) else { continue };
let rels: Vec<&str> = rel.split_whitespace().collect();
if touch.is_none() && rels.iter().any(|r| r.starts_with("apple-touch-icon")) {
touch = tag_attr(tag, "href");
} else if icon.is_none() && rels.contains(&"icon") {
icon = tag_attr(tag, "href");
}
}
touch.or(icon).filter(|h| !h.is_empty())
}
/// The value of one attribute in an HTML/XML start tag, however it is quoted. /// The value of one attribute in an HTML/XML start tag, however it is quoted.
fn tag_attr(tag: &str, name: &str) -> Option<String> { fn tag_attr(tag: &str, name: &str) -> Option<String> {
let key = format!("{name}="); let key = format!("{name}=");
@@ -538,6 +730,7 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
.and_then(|i| i.image()) .and_then(|i| i.image())
.map(str::to_owned) .map(str::to_owned)
.or_else(|| ch.image().map(|i| i.url().to_owned())), .or_else(|| ch.image().map(|i| i.url().to_owned())),
site: non_empty(Some(ch.link().trim())),
// Only the iTunes one: Apple's list is fixed, while a plain <category> is freeform and // Only the iTunes one: Apple's list is fixed, while a plain <category> is freeform and
// would fill the Directory with one-off tags. The subcategory where there is one: Apple // would fill the Directory with one-off tags. The subcategory where there is one: Apple
// files every tabletop and gaming show under Leisure, which says little; Games says it. // files every tabletop and gaming show under Leisure, which says little; Games says it.
@@ -603,6 +796,12 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
title: title_text(Some(feed.title().as_str())), title: title_text(Some(feed.title().as_str())),
ttl_mins: None, ttl_mins: None,
image: feed.logo().or_else(|| feed.icon()).map(str::to_owned), image: feed.logo().or_else(|| feed.icon()).map(str::to_owned),
site: feed
.links()
.iter()
.find(|l| l.rel() == "alternate")
.map(|l| l.href().trim().to_owned())
.filter(|h| !h.is_empty()),
category: None, category: None,
entries, entries,
} }
@@ -811,6 +1010,54 @@ fn parse_date(s: &str) -> Option<i64> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
/// A server answering by path: /old moves for good to /new, /tmp for now, /chain for good
/// to /tmp, /new is the feed.
async fn redirecting_server() -> String {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else { return };
tokio::spawn(async move {
let mut buf = [0u8; 2048];
let n = sock.read(&mut buf).await.unwrap_or(0);
let req = String::from_utf8_lossy(&buf[..n]);
let path = req.split_whitespace().nth(1).unwrap_or("/").to_owned();
let body = "<?xml version=\"1.0\"?><rss version=\"2.0\"><channel><title>T</title></channel></rss>";
let resp = match path.as_str() {
"/old" => "HTTP/1.1 301 Moved Permanently\r\nLocation: /new\r\nContent-Length: 0\r\n\r\n".to_owned(),
"/tmp" => "HTTP/1.1 302 Found\r\nLocation: /new\r\nContent-Length: 0\r\n\r\n".to_owned(),
"/chain" => "HTTP/1.1 308 Permanent Redirect\r\nLocation: /tmp\r\nContent-Length: 0\r\n\r\n".to_owned(),
"/loop" => "HTTP/1.1 301 Moved Permanently\r\nLocation: /loop\r\nContent-Length: 0\r\n\r\n".to_owned(),
_ => format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{body}", body.len()),
};
let _ = sock.write_all(resp.as_bytes()).await;
});
}
});
format!("http://{addr}")
}
#[tokio::test]
async fn a_feed_that_moved_for_good_says_where_and_one_moved_for_now_does_not() {
let base = redirecting_server().await;
let client = reqwest::Client::builder().redirect(reqwest::redirect::Policy::none()).build().unwrap();
let get = |path: &str| {
let cfg: crate::config::Feed = serde_json::from_value(serde_json::json!({ "url": format!("{base}{path}") })).unwrap();
let client = client.clone();
async move { super::fetch(&client, &cfg, None, None).await }
};
let (got, moved) = get("/old").await.unwrap();
assert!(matches!(got, super::Fetched::Body { .. }));
assert_eq!(moved, Some(format!("{base}/new")));
assert_eq!(get("/tmp").await.unwrap().1, None); // 302: for now
assert_eq!(get("/chain").await.unwrap().1, None); // 308 then 302: not for good
assert_eq!(get("/new").await.unwrap().1, None); // never moved
let looped = get("/loop").await.err().unwrap().to_string();
assert_eq!(super::failure_kind(&looped).0, "redirect_loop");
}
use super::*; use super::*;
#[test] #[test]
@@ -928,6 +1175,20 @@ mod tests {
); );
} }
#[test]
fn failures_are_named_by_kind_for_the_log() {
let k = |m: &str| failure_kind(m);
assert_eq!(k("HTTP 404 Not Found"), ("404".into(), Some(404)));
assert_eq!(k("HTTP 503 Service Unavailable"), ("503".into(), Some(503)));
assert_eq!(
k("connecting: error following redirect for url (https://www.toddstashwick.com/): too many redirects").0,
"redirect_loop"
);
assert_eq!(k("connecting: dns error: failed to lookup address information").0, "dns");
assert_eq!(k("operation timed out").0, "timeout");
assert_eq!(k("something new").0, "other");
}
#[test] #[test]
fn explain_failure_translates_the_errors_the_ui_should_flag() { fn explain_failure_translates_the_errors_the_ui_should_flag() {
assert_eq!( assert_eq!(
@@ -1093,6 +1354,10 @@ mod tests {
let tok = "AbCdEfGhIjKlMnOpQrStUvWxYz012_-9"; let tok = "AbCdEfGhIjKlMnOpQrStUvWxYz012_-9";
assert_eq!(expand_input(&format!(" {tok} ")), format!("https://www.patreon.com/rss?auth={tok}")); assert_eq!(expand_input(&format!(" {tok} ")), format!("https://www.patreon.com/rss?auth={tok}"));
assert_eq!(expand_input("https://example.com/rss"), "https://example.com/rss"); assert_eq!(expand_input("https://example.com/rss"), "https://example.com/rss");
assert_eq!(expand_input("http://example.com/rss"), "http://example.com/rss");
assert_eq!(expand_input(" cnn.com "), "https://cnn.com");
assert_eq!(expand_input("example.com/feed.xml"), "https://example.com/feed.xml");
assert_eq!(expand_input("//example.com/rss"), "https://example.com/rss");
assert!(is_patreon_creator(&format!("https://www.patreon.com/rss/glasscannon?auth={tok}"))); assert!(is_patreon_creator(&format!("https://www.patreon.com/rss/glasscannon?auth={tok}")));
assert!(is_patreon_creator(&format!("https://www.patreon.com/rss?auth={tok}"))); assert!(is_patreon_creator(&format!("https://www.patreon.com/rss?auth={tok}")));
@@ -1157,6 +1422,24 @@ mod tests {
assert_eq!(f.entries[2].enclosures.len(), 0, "an item may have none"); assert_eq!(f.entries[2].enclosures.len(), 0, "an item may have none");
} }
#[test]
fn a_feed_link_after_non_ascii_text_is_found() {
// U+0130 lowercases to three bytes from two, which once shifted every offset after it.
let page = "<html><title>\u{130}stanbul \u{130}\u{130}</title>\
<link rel=\"alternate\" type=\"application/rss+xml\" href=\"/feed.xml\">";
assert_eq!(alternate_feed_link(page.as_bytes()).as_deref(), Some("/feed.xml"));
}
#[test]
fn page_icon_prefers_the_touch_icon() {
let page = br#"<head><link rel="stylesheet" href="/a.css">
<link rel="shortcut icon" href="/fav.ico">
<LINK REL="apple-touch-icon-precomposed" sizes="180x180" href='/touch.png'></head>"#;
assert_eq!(page_icon(page).as_deref(), Some("/touch.png"));
assert_eq!(page_icon(br#"<link rel="icon" href="i.svg">"#).as_deref(), Some("i.svg"));
assert_eq!(page_icon(br#"<link rel="stylesheet" href="/a.css">"#), None);
}
#[test] #[test]
fn an_items_picture_comes_from_the_most_deliberate_source() { fn an_items_picture_comes_from_the_most_deliberate_source() {
let xml = br#"<?xml version="1.0"?> let xml = br#"<?xml version="1.0"?>

View File

@@ -15,6 +15,9 @@ pub enum Event {
FeedSkip { feed: String, reason: String }, FeedSkip { feed: String, reason: String },
FeedDone { feed: String, new: usize, downloaded: usize, failed: usize, torrents: usize }, FeedDone { feed: String, new: usize, downloaded: usize, failed: usize, torrents: usize },
FeedError { feed: String, msg: String }, FeedError { feed: String, msg: String },
/// The feed answered from a new address after redirects that all said it moved for good,
/// and the catalogue now has that address (#115).
FeedMoved { feed: String, from: String, to: String },
Progress { Progress {
feed: String, feed: String,
/// Which enclosure this is about. Without it a UI cannot tell one download's /// Which enclosure this is about. Without it a UI cannot tell one download's
@@ -50,6 +53,7 @@ impl Event {
"{feed}: {new} new entries, {downloaded} downloaded, {failed} failed, {torrents} torrents deferred" "{feed}: {new} new entries, {downloaded} downloaded, {failed} failed, {torrents} torrents deferred"
), ),
Event::FeedError { feed, msg } => format!("{feed}: error: {msg}"), Event::FeedError { feed, msg } => format!("{feed}: error: {msg}"),
Event::FeedMoved { feed, from, to } => format!("{feed}: moved for good from {from} to {to}; following it"),
Event::Progress { file, done, total, .. } => match total { Event::Progress { file, done, total, .. } => match total {
Some(t) if *t > 0 => format!( Some(t) if *t > 0 => format!(
" {file}: {:.1}% ({:.1}/{:.1} MB)", " {file}: {:.1}% ({:.1}/{:.1} MB)",
@@ -69,7 +73,7 @@ impl Event {
*bytes as f64 / 1_048_576.0 *bytes as f64 / 1_048_576.0
), ),
Event::Status { feeds, pending, downloaded } => { Event::Status { feeds, pending, downloaded } => {
format!("{feeds} feeds, {pending} pending, {downloaded} downloaded") format!("{feeds} feeds, {pending} queued to download, {downloaded} downloaded")
} }
Event::Error { msg } => format!("error: {msg}"), Event::Error { msg } => format!("error: {msg}"),
// Noise in a terminal; a UI still gets them on the socket. // Noise in a terminal; a UI still gets them on the socket.
@@ -131,38 +135,8 @@ impl Emitter {
// Level by how much it matters. With 80-odd feeds in an OPML subscription, one // Level by how much it matters. With 80-odd feeds in an OPML subscription, one
// line per feed per tick for "not due yet" would push everything worth reading // line per feed per tick for "not due yet" would push everything worth reading
// out of the buffer within a few minutes. // out of the buffer within a few minutes.
let routine = match &e { log_event(&e, self.tx.is_some());
Event::Progress { .. } | Event::FeedSkip { .. } | Event::FeedStart { .. } => true,
Event::FeedDone { new, downloaded, failed, torrents, .. } => {
*new == 0 && *downloaded == 0 && *failed == 0 && *torrents == 0
}
_ => false,
};
let bad = matches!(
&e,
Event::FeedError { .. } | Event::DownloadError { .. } | Event::Error { .. }
);
if let Some(line) = e.human() {
let line = line.trim();
if bad {
tracing::warn!(target: "ipx::scan", "{line}");
} else if routine {
tracing::debug!(target: "ipx::scan", "{line}");
} else {
tracing::info!(target: "ipx::scan", "{line}");
}
}
if let Some(tx) = &self.tx { if let Some(tx) = &self.tx {
// The outbound half of the protocol, as it goes on the wire. Progress is the
// high-volume one, so it sits at debug.
if let Ok(json) = serde_json::to_string(&e) {
if matches!(e, Event::Progress { .. }) {
tracing::debug!(target: "ipx::io", "<- {json}");
} else {
tracing::info!(target: "ipx::io", "<- {json}");
}
}
// An error here only means nobody is listening yet. // An error here only means nobody is listening yet.
let _ = tx.send(e.clone()); let _ = tx.send(e.clone());
} }
@@ -172,6 +146,63 @@ impl Emitter {
} }
} }
/// An event, logged once (#91): its words as the message, and `ev`, `feed`, `new` and the rest as
/// fields, so Loki reads them without parsing the message. A failure also gets `error.type` and,
/// from an HTTP error, `http.response.status_code`, so failures group by kind without a regex.
/// Level by how much it matters: with 80-odd feeds in an OPML subscription, a line per feed per
/// tick for "not due yet" would push everything worth reading out of the log view in minutes,
/// and Progress fires on every whole percent. `wire` also logs the event as it goes on the
/// socket, at debug: the admin page's Daemon I/O tab shows it, production's log leaves it out.
fn log_event(e: &Event, wire: bool) {
let json = serde_json::to_string(e).unwrap_or_default();
let v: serde_json::Value = serde_json::from_str(&json).unwrap_or_default();
let s = |k: &str| v.get(k).and_then(|x| x.as_str());
let n = |k: &str| v.get(k).and_then(|x| x.as_u64());
let (kind, code) = match e {
Event::FeedError { msg, .. } | Event::DownloadError { msg, .. } | Event::Error { msg } => {
let (k, c) = crate::feed::failure_kind(msg);
(Some(k), c)
}
_ => (None, None),
};
let routine = match e {
Event::Progress { .. } | Event::FeedSkip { .. } | Event::FeedStart { .. } => true,
Event::FeedDone { new, downloaded, failed, torrents, .. } => {
*new == 0 && *downloaded == 0 && *failed == 0 && *torrents == 0
}
_ => false,
};
macro_rules! line {
($level:ident, $target:literal, $text:expr) => {
tracing::$level!(
target: $target,
ev = s("ev"), feed = s("feed"), msg = s("msg"), url = s("url"), reason = s("reason"), from = s("from"), to = s("to"),
new = n("new"), downloaded = n("downloaded"), failed = n("failed"),
torrents = n("torrents"), bytes = n("bytes"), feeds = n("feeds"),
pending = n("pending"), enclosure = n("enclosure"), files = n("files"),
"error.type" = kind, "http.response.status_code" = code,
"{}", $text
)
};
}
// The healthcheck's answer, every 30s: a reply on the socket rather than work done, so it
// stays with the rest of the conversation, and the Scans tab stays about scans.
if matches!(e, Event::Status { .. }) {
return line!(info, "ipx::io", format!("<- {json}"));
}
if wire {
tracing::debug!(target: "ipx::io", "<- {json}");
}
let text = e.human().map(|l| l.trim().to_owned()).unwrap_or_else(|| json.clone());
if kind.is_some() {
line!(warn, "ipx::scan", text)
} else if routine {
line!(debug, "ipx::scan", text)
} else {
line!(info, "ipx::scan", text)
}
}
/// True when something is already listening -- i.e. a daemon owns this socket. /// True when something is already listening -- i.e. a daemon owns this socket.
pub async fn daemon_is_live(path: &Path) -> bool { pub async fn daemon_is_live(path: &Path) -> bool {
UnixStream::connect(path).await.is_ok() UnixStream::connect(path).await.is_ok()
@@ -257,9 +288,7 @@ async fn handle(
Ok(Command::Status) => { Ok(Command::Status) => {
tracing::info!(target: "ipx::io", "-> {line}"); tracing::info!(target: "ipx::io", "-> {line}");
let ev = status().await; let ev = status().await;
if let Ok(json) = serde_json::to_string(&ev) { log_event(&ev, true);
tracing::info!(target: "ipx::io", "<- {json}");
}
let _ = reply.send(ev).await; let _ = reply.send(ev).await;
} }
Ok(cmd) => { Ok(cmd) => {
@@ -303,6 +332,14 @@ pub async fn proxy(path: &Path, cmd: &Command) -> Result<()> {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn a_feed_that_moved_says_so_on_the_wire_and_in_words() {
let e = Event::FeedMoved { feed: "x".into(), from: "http://a/f".into(), to: "https://a/f".into() };
let wire = serde_json::to_value(&e).unwrap();
assert_eq!((wire["ev"].as_str(), wire["from"].as_str(), wire["to"].as_str()), (Some("feed_moved"), Some("http://a/f"), Some("https://a/f")));
assert_eq!(e.human().unwrap(), "x: moved for good from http://a/f to https://a/f; following it");
}
#[test] #[test]
fn commands_parse_from_the_wire_form() { fn commands_parse_from_the_wire_form() {
let got: Command = serde_json::from_str(r#"{"cmd":"fetch"}"#).unwrap(); let got: Command = serde_json::from_str(r#"{"cmd":"fetch"}"#).unwrap();

View File

@@ -1,4 +1,5 @@
mod access; mod access;
mod art;
mod auth; mod auth;
mod config; mod config;
mod db; mod db;
@@ -38,17 +39,11 @@ struct Cli {
enum Command { enum Command {
/// Show configured feeds and their state /// Show configured feeds and their state
List, List,
/// Copy everything from a SQLite state.db into the database IPX_DATABASE_URL names, which
/// must be empty: the one-off move to Postgres
CopyDb {
/// The SQLite file to copy from
from: PathBuf,
},
/// Scan feeds for new entries /// Scan feeds for new entries
Fetch { Fetch {
/// Only this feed id /// Only this feed id
feed: Option<String>, feed: Option<String>,
/// Poll even when the feed is not due yet /// Read the feed in full now, even when it is not due and has not changed
#[arg(long)] #[arg(long)]
force: bool, force: bool,
}, },
@@ -69,6 +64,12 @@ enum Command {
/// Only take enclosures matching these keywords /// Only take enclosures matching these keywords
#[arg(long, value_delimiter = ',')] #[arg(long, value_delimiter = ',')]
keywords: Vec<String>, keywords: Vec<String>,
/// The Directory's category, for a feed that names none of its own (News, Technology)
#[arg(long)]
category: Option<String>,
/// Put it in the Directory for anyone to subscribe to, and keep it there when nobody does
#[arg(long)]
list: bool,
}, },
/// Unsubscribe. Downloads and history are left alone. /// Unsubscribe. Downloads and history are left alone.
Rm { feed: String }, Rm { feed: String },
@@ -124,6 +125,9 @@ pub struct Ctx {
pub cfg: std::sync::RwLock<std::sync::Arc<config::Config>>, pub cfg: std::sync::RwLock<std::sync::Arc<config::Config>>,
pub db: db::Db, pub db: db::Db,
pub client: reqwest::Client, pub client: reqwest::Client,
/// For feeds only: follows no redirects, so `feed::fetch` sees each hop and can tell a feed
/// that moved for good from one sent elsewhere for now.
pub feed_client: reqwest::Client,
pub out: Emitter, pub out: Emitter,
/// Started on first use: a BitTorrent session binds ports and starts a DHT, which is /// Started on first use: a BitTorrent session binds ports and starts a DHT, which is
/// rude to do for a config that has never seen a torrent. /// rude to do for a config that has never seen a torrent.
@@ -169,6 +173,12 @@ impl Ctx {
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
// The daemon only: `ipx status` runs every half minute as the healthcheck, and a trace
// for each would bury the ones worth looking at.
let otel = match cli.command {
Command::Daemon { .. } => otel_provider()?,
_ => None,
};
// Everything goes to stderr as before, and is mirrored into a ring the UI can read. // Everything goes to stderr as before, and is mirrored into a ring the UI can read.
{ {
use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::layer::SubscriberExt;
@@ -177,17 +187,39 @@ async fn main() -> Result<()> {
// Two filters, deliberately different. stderr follows IPX_LOG; the in-app buffer // Two filters, deliberately different. stderr follows IPX_LOG; the in-app buffer
// keeps debug as well, so the log view can show protocol traffic and routine // keeps debug as well, so the log view can show protocol traffic and routine
// skips that would be noise on a terminal. IPX_UI_LOG overrides it. // skips that would be noise on a terminal. IPX_UI_LOG overrides it.
let stderr_filter = tracing_subscriber::EnvFilter::try_from_env("IPX_LOG") let stderr_filter = || {
.unwrap_or_else(|_| "ipx=info".into()); tracing_subscriber::EnvFilter::try_from_env("IPX_LOG").unwrap_or_else(|_| "ipx=info".into())
};
// One JSON object a line for Loki (#91), with each event's fields as its own; text
// otherwise, for someone reading a terminal.
let json = std::env::var("IPX_LOG_FORMAT").is_ok_and(|f| f.eq_ignore_ascii_case("json"));
let ui_filter = tracing_subscriber::EnvFilter::try_from_env("IPX_UI_LOG") let ui_filter = tracing_subscriber::EnvFilter::try_from_env("IPX_UI_LOG")
.unwrap_or_else(|_| "ipx=debug".into()); .unwrap_or_else(|_| "ipx=debug".into());
tracing_subscriber::registry() tracing_subscriber::registry()
.with( .with((!json).then(|| {
tracing_subscriber::fmt::layer() tracing_subscriber::fmt::layer()
.with_writer(std::io::stderr) .with_writer(std::io::stderr)
.with_filter(stderr_filter), // Colour for a terminal only: in docker logs and Loki the escapes are noise
) // every query has to strip (#88).
.with_ansi(std::io::IsTerminal::is_terminal(&std::io::stderr()))
.with_filter(stderr_filter())
}))
.with(json.then(|| {
tracing_subscriber::fmt::layer()
.fmt_fields(tracing_subscriber::fmt::format::JsonFields::new())
.event_format(WithTrace(
tracing_subscriber::fmt::format().json().flatten_event(true).with_current_span(true).with_span_list(false),
))
.with_writer(std::io::stderr)
.with_filter(stderr_filter())
}))
.with(logbuf::RingLayer.with_filter(ui_filter)) .with(logbuf::RingLayer.with_filter(ui_filter))
.with(otel.as_ref().map(|p| {
use opentelemetry::trace::TracerProvider;
tracing_opentelemetry::layer()
.with_tracer(p.tracer("ipx"))
.with_filter(tracing_subscriber::EnvFilter::new("ipx=info"))
}))
.init(); .init();
} }
@@ -208,8 +240,7 @@ async fn main() -> Result<()> {
| Command::Add { .. } | Command::Add { .. }
| Command::Rm { .. } | Command::Rm { .. }
| Command::Import { .. } | Command::Import { .. }
| Command::Export { .. } | Command::Export { .. } => None,
| Command::CopyDb { .. } => None,
}; };
if let Some(cmd) = &wire_cmd if let Some(cmd) = &wire_cmd
&& !cli.local && !cli.local
@@ -218,13 +249,7 @@ async fn main() -> Result<()> {
return ipc::proxy(&cfg.general.socket, cmd).await; return ipc::proxy(&cfg.general.socket, cmd).await;
} }
// copy-db fills an empty database from another, configuration included; taking config.toml let cfg = assemble_config(&db, cfg, &config_path).await?;
// into it first would have the copy collide with it.
let cfg = if matches!(cli.command, Command::CopyDb { .. }) {
cfg
} else {
assemble_config(&db, cfg, &config_path).await?
};
let is_daemon = matches!(cli.command, Command::Daemon { .. }); let is_daemon = matches!(cli.command, Command::Daemon { .. });
let (events, _) = broadcast::channel(1024); let (events, _) = broadcast::channel(1024);
@@ -233,6 +258,13 @@ async fn main() -> Result<()> {
db, db,
client: reqwest::Client::builder() client: reqwest::Client::builder()
.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).
.connect_timeout(std::time::Duration::from_secs(10))
.build()?,
feed_client: reqwest::Client::builder()
.user_agent(concat!("ipx/", env!("CARGO_PKG_VERSION")))
.connect_timeout(std::time::Duration::from_secs(10))
.redirect(reqwest::redirect::Policy::none())
.build()?, .build()?,
out: if is_daemon { Emitter::socket(events.clone(), false) } else { Emitter::terminal() }, out: if is_daemon { Emitter::socket(events.clone(), false) } else { Emitter::terminal() },
torrents: tokio::sync::OnceCell::new(), torrents: tokio::sync::OnceCell::new(),
@@ -241,19 +273,73 @@ async fn main() -> Result<()> {
detach_torrents: is_daemon, detach_torrents: is_daemon,
}); });
match cli.command { let result = match cli.command {
Command::List => list(&ctx).await, Command::List => list(&ctx).await,
Command::Daemon { web } => daemon(ctx, config_path, web, events).await, Command::Daemon { web } => daemon(ctx, config_path, web, events).await,
Command::Add { url, folder, keywords } => { Command::Add { url, folder, keywords, category, list } => {
add(&ctx, &url, folder, keywords).await add(&ctx, &url, folder, keywords, category, list).await
} }
Command::Rm { feed } => rm(&ctx, &feed).await, Command::Rm { feed } => rm(&ctx, &feed).await,
Command::User { cmd } => user_cmd(&ctx, cmd).await, Command::User { cmd } => user_cmd(&ctx, cmd).await,
Command::Import { file } => import(&ctx, &file).await, Command::Import { file } => import(&ctx, &file).await,
Command::Export { file } => export(&ctx, &file).await, Command::Export { file } => export(&ctx, &file).await,
Command::CopyDb { from } => copy_db(&ctx, &from).await,
_ => run(&ctx, wire_cmd.expect("only List and Daemon have no wire form")).await, _ => run(&ctx, wire_cmd.expect("only List and Daemon have no wire form")).await,
};
// The batch exporter holds the last few seconds of spans; without this they are lost.
if let Some(p) = otel {
let _ = p.shutdown();
} }
result
}
/// The JSON log line with the trace and span it belongs to (#91), so a line in Loki leads to its
/// trace in Tempo. The JSON formatter cannot take a field of its own, so the ids go on the end of
/// the object it writes. A line outside any traced span, or with no trace exporter, is unchanged.
struct WithTrace<F>(F);
impl<S, N, F> tracing_subscriber::fmt::FormatEvent<S, N> for WithTrace<F>
where
F: tracing_subscriber::fmt::FormatEvent<S, N>,
S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
N: for<'w> tracing_subscriber::fmt::FormatFields<'w> + 'static,
{
fn format_event(
&self,
ctx: &tracing_subscriber::fmt::FmtContext<'_, S, N>,
mut w: tracing_subscriber::fmt::format::Writer<'_>,
ev: &tracing::Event<'_>,
) -> std::fmt::Result {
use opentelemetry::trace::TraceContextExt;
use tracing_opentelemetry::OpenTelemetrySpanExt;
let current = tracing::Span::current();
let sc = if current.is_none() { None } else { Some(current.context().span().span_context().clone()) };
let Some(sc) = sc.filter(|c| c.is_valid()) else {
return self.0.format_event(ctx, w, ev);
};
let mut line = String::new();
self.0.format_event(ctx, tracing_subscriber::fmt::format::Writer::new(&mut line), ev)?;
match line.trim_end().strip_suffix('}') {
Some(body) => writeln!(w, r#"{body},"trace_id":"{}","span_id":"{}"}}"#, sc.trace_id(), sc.span_id()),
None => w.write_str(&line),
}
}
}
/// Traces over OTLP, to Tempo for one, when OTEL_EXPORTER_OTLP_ENDPOINT names a collector
/// (`http://host:4318`: the exporter speaks OTLP over HTTP and adds `/v1/traces`). The exporter
/// reads that and the other `OTEL_` variables itself.
fn otel_provider() -> Result<Option<opentelemetry_sdk::trace::SdkTracerProvider>> {
if std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").unwrap_or_default().is_empty() {
return Ok(None);
}
let exporter = opentelemetry_otlp::SpanExporter::builder().with_http().build()?;
let resource = opentelemetry_sdk::Resource::builder().with_service_name("ipx").build();
Ok(Some(
opentelemetry_sdk::trace::SdkTracerProvider::builder()
.with_batch_exporter(exporter)
.with_resource(resource)
.build(),
))
} }
/// Accounts. Passwords come in on stdin so they never reach a shell history or a `ps` /// Accounts. Passwords come in on stdin so they never reach a shell history or a `ps`
@@ -369,9 +455,9 @@ async fn run(ctx: &Arc<Ctx>, cmd: Cmd) -> Result<()> {
} }
} }
/// The counts `ipx status` prints. A running daemon's socket answers with this directly rather /// The counts `ipx status` prints, and /api/status serves. A running daemon's socket answers with
/// than through the job queue. /// this directly rather than through the job queue.
async fn status(ctx: &Ctx) -> Event { pub(crate) async fn status(ctx: &Ctx) -> Event {
match ctx.db.counts().await { match ctx.db.counts().await {
Ok((pending, downloaded)) => { Ok((pending, downloaded)) => {
let feeds = subscriptions(ctx).await.map(|s| s.len()).unwrap_or(0); let feeds = subscriptions(ctx).await.map(|s| s.len()).unwrap_or(0);
@@ -413,28 +499,13 @@ async fn daemon(
match ctx.db.requeue_interrupted().await { match ctx.db.requeue_interrupted().await {
Ok(n) if n > 0 => tracing::info!(count = n, "requeued downloads interrupted by a restart"), Ok(n) if n > 0 => tracing::info!(count = n, "requeued downloads interrupted by a restart"),
Ok(_) => {} Ok(_) => {}
Err(e) => tracing::warn!(error = ?e, "could not requeue interrupted downloads"), Err(e) => tracing::warn!(error = %format!("{e:#}"), "could not requeue interrupted downloads"),
} }
match retire_stranded(&ctx).await { match retire_stranded(&ctx).await {
Ok(0) => {} Ok(0) => {}
Ok(n) => tracing::info!(feeds = n, "retired feeds whose OPML is no longer in config"), Ok(n) => tracing::info!(feeds = n, "retired feeds whose OPML is no longer in config"),
Err(e) => tracing::warn!(error = ?e, "could not retire feeds whose OPML is no longer in config"), Err(e) => tracing::warn!(error = %format!("{e:#}"), "could not retire feeds whose OPML is no longer in config"),
}
// Before the parser knew WordPress's numbered player URLs, a file it listed twice was
// downloaded twice. The repeats fold into the first, and their spare copies are deleted.
match ctx.db.merge_repeated_enclosures(feed::same_file_key).await {
Ok((0, _)) => {}
Ok((n, spare)) => {
for path in &spare {
if let Err(e) = std::fs::remove_file(path) {
tracing::warn!(path, error = %e, "could not delete a spare copy");
}
}
tracing::info!(enclosures = n, files = spare.len(), "folded files WordPress listed twice");
}
Err(e) => tracing::warn!(error = ?e, "could not fold files WordPress listed twice"),
} }
let (tx_cmd, mut rx_cmd) = mpsc::channel::<Cmd>(64); let (tx_cmd, mut rx_cmd) = mpsc::channel::<Cmd>(64);
@@ -451,8 +522,6 @@ async fn daemon(
let server = tokio::spawn(ipc::serve(socket.clone(), events.clone(), tx_cmd, answer)); let server = tokio::spawn(ipc::serve(socket.clone(), events.clone(), tx_cmd, answer));
// One command at a time: the queue is what keeps two scans from overlapping. // One command at a time: the queue is what keeps two scans from overlapping.
let mut ticker = tokio::time::interval(std::time::Duration::from_secs(60));
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
tracing::info!( tracing::info!(
feeds = subscriptions(&ctx).await.map(|s| s.len()).unwrap_or(0), feeds = subscriptions(&ctx).await.map(|s| s.len()).unwrap_or(0),
"daemon started" "daemon started"
@@ -489,10 +558,13 @@ async fn daemon(
} }
let mut stop = rx_stop.clone(); let mut stop = rx_stop.clone();
// The first pass at once, as the minute's tick did: what came due while it was down.
let mut first = true;
loop { loop {
if *stop.borrow() { if *stop.borrow() {
break; break;
} }
let wait = if std::mem::take(&mut first) { std::time::Duration::ZERO } else { until_next_scan(&ctx).await };
tokio::select! { tokio::select! {
biased; biased;
_ = stop.changed() => break, _ = stop.changed() => break,
@@ -509,7 +581,7 @@ async fn daemon(
break; break;
} }
} }
_ = ticker.tick() => { _ = tokio::time::sleep(wait) => {
// Per-feed schedule and TTL decide what actually gets polled. // Per-feed schedule and TTL decide what actually gets polled.
let job = run(&ctx, Cmd::Fetch { feed: None, force: false, feeds: vec![] }); let job = run(&ctx, Cmd::Fetch { feed: None, force: false, feeds: vec![] });
if !until_stopped(&ctx, &rx_stop, job).await { if !until_stopped(&ctx, &rx_stop, job).await {
@@ -553,19 +625,34 @@ async fn start_web(
ctx.set_cfg(fresh.clone()); ctx.set_cfg(fresh.clone());
// The token signs in as the admin, and whatever reads this process's output (docker logs, // The token signs in as the admin, and whatever reads this process's output (docker logs,
// for one) is wider than who reads config.toml. So say where it is, never what it is. // for one) is wider than who reads config.toml. So say where it is, never what it is.
println!( // Logged, not printed, so a JSON log stays one object a line (#91).
tracing::info!(
"web ui token generated and saved to {} as [web] token. Open http://{bind}/?token=<that token>", "web ui token generated and saved to {} as [web] token. Open http://{bind}/?token=<that token>",
config_path.display() config_path.display()
); );
} else { } else {
println!( tracing::info!(
"web ui at http://{bind}/ (the sign-in token is [web] token in {})", "web ui at http://{bind}/ (the sign-in token is [web] token in {})",
config_path.display() config_path.display()
); );
} }
// Bound beyond localhost, as a container has to be for its port to be published. Worth a
// warning only when nothing but a password stands in front of it: it said "the token is all
// that guards it" on every start of production, behind Cloudflare Access, and was the only
// warning in a healthy log (#106).
if ctx.cfg().web.binds_publicly() { if ctx.cfg().web.binds_publicly() {
tracing::warn!(bind, "web ui is reachable off this machine; the token is all that guards it"); if ctx.cfg().web.access().is_some() {
tracing::info!(
bind,
"web ui is reachable off this machine; signing in takes an account's password or the admin token, or Cloudflare Access through a trusted proxy"
);
} else {
tracing::warn!(
bind,
"web ui is reachable off this machine; an account's password or the admin token is all that guards it"
);
}
} }
let access = Arc::new(access::Keys::default()); let access = Arc::new(access::Keys::default());
@@ -581,7 +668,7 @@ async fn start_web(
}; };
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 {
tracing::error!(error = ?e, "web ui stopped"); tracing::error!(error = %format!("{e:#}"), "web ui stopped");
} }
}))) })))
} }
@@ -604,21 +691,40 @@ async fn add(
url: &str, url: &str,
folder: Option<String>, folder: Option<String>,
keywords: Vec<String>, keywords: Vec<String>,
category: Option<String>,
list: bool,
) -> Result<()> { ) -> Result<()> {
let mut cfg = (*ctx.cfg()).clone(); let mut cfg = (*ctx.cfg()).clone();
let url = &feed::expand_input(url); let url = &feed::find_feed(&ctx.client, &feed::expand_input(url)).await?;
// Includes feeds derived from an OPML, or the same show could be added twice. // Includes feeds derived from an OPML, or the same show could be added twice.
if let Some(existing) = subscriptions(ctx).await?.iter().find(|s| feed::same_feed(&s.cfg.url, url)) { if let Some(existing) = subscriptions(ctx).await?.iter().find(|s| feed::same_feed(&s.cfg.url, url)) {
// Listing a feed the catalogue already has, or giving it a category, is the point of
// asking again: it changes those, and nothing else.
if let Some(f) = cfg.feeds.get_mut(&existing.id)
&& (list || category.is_some())
{
f.listed |= list;
if category.is_some() {
f.category = category;
}
ctx.store_cfg(cfg).await?;
println!("{} is already in the catalogue; updated its Directory listing", existing.id);
return Ok(());
}
anyhow::bail!("already subscribed as {:?}", existing.id); anyhow::bail!("already subscribed as {:?}", existing.id);
} }
let id = add_one(ctx, &mut cfg, url, folder, keywords).await?; 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?; ctx.store_cfg(cfg).await?;
println!("added {id}"); println!("added {id}{}", if list { ", listed in the Directory" } else { "" });
Ok(()) Ok(())
} }
/// Returns the new feed id. The title needs a fetch, so a feed that cannot be reached is /// Returns the new feed id. Adding checks the address is a feed first (`feed::find_feed`); this
/// still added -- under a slug derived from its URL -- rather than refused. /// still names one it cannot read from its URL rather than failing.
pub async fn add_one( pub async fn add_one(
ctx: &Ctx, ctx: &Ctx,
cfg: &mut config::Config, cfg: &mut config::Config,
@@ -640,9 +746,10 @@ pub async fn add_one(
password: None, password: None,
password_env: None, password_env: None,
category: None, category: None,
listed: false,
}; };
let title = match feed::fetch(&ctx.client, &probe, None, None).await { let title = match feed::fetch(&ctx.feed_client, &probe, None, None).await.map(|(got, _)| got) {
// An OPML subscription is named from its own <head><title>, not by trying to // An OPML subscription is named from its own <head><title>, not by trying to
// parse it as a feed and falling back to the hostname. // parse it as a feed and falling back to the hostname.
Ok(feed::Fetched::Body { bytes, .. }) if feed::is_opml(&bytes) => { Ok(feed::Fetched::Body { bytes, .. }) if feed::is_opml(&bytes) => {
@@ -766,6 +873,7 @@ pub async fn subscribe_opml(
password: None, password: None,
password_env: None, password_env: None,
category: None, category: None,
listed: false,
}, },
); );
grew = true; grew = true;
@@ -835,15 +943,6 @@ async fn assemble_config(db: &db::Db, mut cfg: config::Config, path: &std::path:
anyhow::bail!("the database says it holds the configuration and then that it does not") anyhow::bail!("the database says it holds the configuration and then that it does not")
} }
async fn copy_db(ctx: &Ctx, from: &std::path::Path) -> Result<()> {
anyhow::ensure!(from.exists(), "{} does not exist", from.display());
let source = db::Db::open(&from.display().to_string()).await?;
for (table, n) in ctx.db.copy_from(&source).await? {
println!("{table:14} {n}");
}
Ok(())
}
async fn export(ctx: &Ctx, file: &std::path::Path) -> Result<()> { async fn export(ctx: &Ctx, file: &std::path::Path) -> Result<()> {
let mut doc = opml::OPML::default(); let mut doc = opml::OPML::default();
doc.head = Some(opml::Head { doc.head = Some(opml::Head {
@@ -873,7 +972,10 @@ async fn list(ctx: &Ctx) -> Result<()> {
} }
for (id, feed) in &cfg.feeds { for (id, feed) in &cfg.feeds {
let s = ctx.db.feed_summary(id).await?; let s = ctx.db.feed_summary(id).await?;
println!("{id} {}", s.title.as_deref().unwrap_or("-")); // The id on a line of its own, labelled: first on the title's line, antirez.com's id
// "feed" read as a heading and 'ipx fetch antirez' was tried instead (issue #82).
println!("{}", s.title.as_deref().filter(|t| !t.is_empty()).unwrap_or(id));
println!(" id {id}");
println!(" url {}", feed.url); println!(" url {}", feed.url);
println!(" last checked {}", ago(s.last_checked)); println!(" last checked {}", ago(s.last_checked));
println!(" entries {} ({} downloaded)", s.entries, s.downloaded); println!(" entries {} ({} downloaded)", s.entries, s.downloaded);
@@ -884,11 +986,42 @@ async fn list(ctx: &Ctx) -> Result<()> {
Ok(()) Ok(())
} }
/// Removes feeds nobody subscribes to that are dead (failing for 30 days) or quiet (nothing new
/// in a year), from the catalogue and the database, so the Directory lists feeds worth taking.
/// A feed listed in it with no subscribers stays for as long as it works and publishes.
async fn clean_directory(ctx: &Ctx) -> Result<()> {
const DEAD: i64 = 30 * 86_400;
const QUIET: i64 = 365 * 86_400;
let now = db::now();
let stale = ctx.db.stale_unsubscribed(now - DEAD, now - QUIET).await?;
if stale.is_empty() {
return Ok(());
}
let mut cfg = (*ctx.cfg()).clone();
if stale.iter().fold(false, |any, id| cfg.feeds.remove(id).is_some() || any) {
ctx.store_cfg(cfg).await?;
}
for id in &stale {
retire_group(ctx, id).await?;
ctx.db.forget_feed(id).await?;
tracing::info!(feed = id, "removed a feed nobody subscribes to that is dead or has published nothing in a year");
}
Ok(())
}
/// `standalone` false means this is the sweep that runs before a scan: it reports what it /// `standalone` false means this is the sweep that runs before a scan: it reports what it
/// deleted, but must not emit the terminal ReapDone, or a client waiting on its `fetch` /// deleted, but must not emit the terminal ReapDone, or a client waiting on its `fetch`
/// would stop reading before the scan had even started. /// would stop reading before the scan had even started.
#[tracing::instrument(name = "reap", skip_all, fields(dry_run = dry_run))]
async fn reap(ctx: &Ctx, dry_run: bool, standalone: bool) -> Result<()> { async fn reap(ctx: &Ctx, dry_run: bool, standalone: bool) -> Result<()> {
let r = retention::run(&ctx.cfg(), &ctx.db, dry_run).await?; let r = retention::run(&ctx.cfg(), &ctx.db, dry_run).await?;
if !dry_run {
clean_directory(ctx).await?;
let gone = art::trim(&art::dir(), ctx.cfg().general.art_cache_mb * 1_048_576);
if gone > 0 {
tracing::debug!(gone, "trimmed the artwork kept on disk");
}
}
for c in r.aged_out.iter().chain(r.over_quota.iter()) { for c in r.aged_out.iter().chain(r.over_quota.iter()) {
ctx.out.emit(Event::Reaped { ctx.out.emit(Event::Reaped {
path: c.path.clone(), path: c.path.clone(),
@@ -905,6 +1038,7 @@ async fn reap(ctx: &Ctx, dry_run: bool, standalone: bool) -> Result<()> {
} }
/// `scope`, when not empty, narrows the scan to those feeds and the feeds inside any of them. /// `scope`, when not empty, narrows the scan to those feeds and the feeds inside any of them.
#[tracing::instrument(name = "scan", skip_all, fields(only = ?only, force = force))]
async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool, scope: &[String]) -> Result<()> { async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool, scope: &[String]) -> Result<()> {
let cfg = ctx.cfg(); let cfg = ctx.cfg();
let subs = subscriptions(ctx).await?; let subs = subscriptions(ctx).await?;
@@ -914,29 +1048,65 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool, scope: &[String]
anyhow::bail!("no feed with id {id:?}"); anyhow::bail!("no feed with id {id:?}");
} }
let subscribed = ctx.db.subscriber_counts().await?;
let mut scanned = 0; let mut scanned = 0;
let mut fresh: Vec<String> = vec![]; let mut fresh: Vec<String> = vec![];
let in_scope = |s: &Sub| { let in_scope = |s: &Sub| {
scope.is_empty() || scope.contains(&s.id) || s.cfg.group.as_ref().is_some_and(|g| scope.contains(g)) scope.is_empty() || scope.contains(&s.id) || s.cfg.group.as_ref().is_some_and(|g| scope.contains(g))
}; };
let mut states = ctx.db.http_states().await?;
let mut due = vec![];
for sub in subs.iter().filter(|s| only.is_none_or(|o| o == s.id) && in_scope(s)) { for sub in subs.iter().filter(|s| only.is_none_or(|o| o == s.id) && in_scope(s)) {
let (id, feed_cfg) = (&sub.id, &sub.cfg); let id = &sub.id;
let state = ctx.db.http_state(id).await?; let mut state = states.remove(id).unwrap_or_default();
// A scan someone asked for reads the feed in full. With the validators it only skipped the
// wait: a feed that had not changed answered 304 and nothing was read (#77).
if force {
state.etag = None;
state.last_modified = None;
}
if !force && let Some(last) = state.last_checked { if !force && let Some(at) = due_at(&cfg, &sub.cfg, &state, subscribed.contains_key(id)) {
let due = last + due_after(&cfg, feed_cfg, state.ttl_mins) as i64; if at > db::now() {
if due > db::now() {
ctx.out.emit(Event::FeedSkip { ctx.out.emit(Event::FeedSkip {
feed: id.clone(), feed: id.clone(),
reason: format!("not due for {}", duration((due - db::now()) as u64)), reason: format!("not due for {}", duration((at - db::now()) as u64)),
}); });
continue; continue;
} }
} }
due.push((sub, state));
}
// Each feed's body is fetched a few feeds ahead of its turn, in tasks of their own, and the
// feeds are then handled one at a time, in order, as before: database writes, downloads and
// OPML syncs stay one at a time. Fetched one after another, a scan waited on every site in
// turn, 65-90% of its time (#104). A Patreon creator fetches inside scan_one, its own way.
const AHEAD: usize = 6;
let mut bodies = futures_util::StreamExt::buffered(
futures_util::stream::iter(due.iter().map(|(sub, state)| {
let (client, feed_cfg) = (ctx.feed_client.clone(), sub.cfg.clone());
let (etag, modified) = (state.etag.clone(), state.last_modified.clone());
let early = !feed::is_patreon_creator(&feed_cfg.url);
tokio::spawn(tracing::Instrument::instrument(
async move {
if !early {
return None;
}
Some(feed::fetch(&client, &feed_cfg, etag.as_deref(), modified.as_deref()).await)
},
tracing::Span::current(),
))
})),
AHEAD,
);
for (sub, state) in &due {
let (id, feed_cfg) = (&sub.id, &sub.cfg);
// A task that panicked has no body; scan_one then fetches it itself.
let fetched = futures_util::StreamExt::next(&mut bodies).await.and_then(|j| j.ok()).flatten();
scanned += 1; scanned += 1;
ctx.out.emit(Event::FeedStart { feed: id.clone() }); ctx.out.emit(Event::FeedStart { feed: id.clone() });
match scan_one(ctx, id, feed_cfg, &state).await { match scan_one(ctx, id, feed_cfg, state, force, fetched).await {
Ok(Outcome::Feed(s)) => ctx.out.emit(Event::FeedDone { Ok(Outcome::Feed(s)) => ctx.out.emit(Event::FeedDone {
feed: id.clone(), feed: id.clone(),
new: s.new_entries, new: s.new_entries,
@@ -982,7 +1152,7 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool, scope: &[String]
scanned += 1; scanned += 1;
ctx.out.emit(Event::FeedStart { feed: id.clone() }); ctx.out.emit(Event::FeedStart { feed: id.clone() });
let state = ctx.db.http_state(id).await?; let state = ctx.db.http_state(id).await?;
match scan_one(ctx, id, feed_cfg, &state).await { match scan_one(ctx, id, feed_cfg, &state, force, None).await {
Ok(Outcome::Feed(s)) => ctx.out.emit(Event::FeedDone { Ok(Outcome::Feed(s)) => ctx.out.emit(Event::FeedDone {
feed: id.clone(), feed: id.clone(),
new: s.new_entries, new: s.new_entries,
@@ -1056,6 +1226,7 @@ pub async fn subscriptions(ctx: &Ctx) -> Result<Vec<Sub>> {
password: parent.and_then(|p| p.password.clone()), password: parent.and_then(|p| p.password.clone()),
password_env: parent.and_then(|p| p.password_env.clone()), password_env: parent.and_then(|p| p.password_env.clone()),
category: None, category: None,
listed: false,
}, },
managed: true, managed: true,
}); });
@@ -1106,15 +1277,77 @@ async fn retire_stranded(ctx: &Ctx) -> Result<usize> {
/// Seconds to wait before re-checking a feed. /// Seconds to wait before re-checking a feed.
/// ///
/// When a feed is next due, or None for one never checked, which is due now. A feed nobody
/// subscribes to, one listed in the Directory, is read once a day: enough to keep its entry
/// current, without fetching it hourly for no one.
pub fn due_at(cfg: &config::Config, feed: &config::Feed, state: &db::HttpState, subscribed: bool) -> Option<i64> {
let last = state.last_checked?;
let floor = if subscribed { 0 } else { 86_400 };
Some(last + due_after(cfg, feed, state.ttl_mins, state.error_since, last).max(floor) as i64)
}
/// How long the daemon may sleep before a feed is due: until the earliest one, at least 30 s and
/// at most 10 minutes. It ticked every minute and ran a scan pass each time, 80% of them finding
/// nothing due (#114). The floor keeps a feed that never gets a check time from spinning it; the
/// ceiling picks up within ten minutes what no command announces, such as `ipx add` or a
/// shorter schedule. A command, a refresh or a feed added on the page, wakes it at once anyway.
async fn until_next_scan(ctx: &Ctx) -> std::time::Duration {
let next = async {
let cfg = ctx.cfg();
let subscribed = ctx.db.subscriber_counts().await?;
let states = ctx.db.http_states().await?;
anyhow::Ok(
subscriptions(ctx)
.await?
.iter()
// A feed with no row yet has never been checked: due now.
.map(|s| {
states.get(&s.id).and_then(|st| due_at(&cfg, &s.cfg, st, subscribed.contains_key(&s.id))).unwrap_or(0)
})
.min(),
)
};
let wait = match next.await {
Ok(Some(at)) => (at - db::now()).max(0) as u64,
Ok(None) => u64::MAX,
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "could not work out when the next feed is due");
0
}
};
std::time::Duration::from_secs(wait.clamp(30, 600))
}
/// A per-feed schedule is an explicit instruction and wins outright. Without one, the /// A per-feed schedule is an explicit instruction and wins outright. Without one, the
/// global schedule applies, but the feed's own <ttl> raises it when the publisher asks to /// global schedule applies, but the feed's own <ttl> raises it when the publisher asks to
/// be polled less often. /// be polled less often. A feed that is failing backs off (`backoff`), from `error_since`, when
pub fn due_after(cfg: &config::Config, feed: &config::Feed, ttl_mins: Option<u64>) -> u64 { /// its run of failures began, to `last_checked`, when it last failed.
pub fn due_after(
cfg: &config::Config,
feed: &config::Feed,
ttl_mins: Option<u64>,
error_since: Option<i64>,
last_checked: i64,
) -> u64 {
let mins = match feed.schedule.as_deref().and_then(config::parse_interval) { let mins = match feed.schedule.as_deref().and_then(config::parse_interval) {
Some(explicit) => explicit, Some(explicit) => explicit,
None => ttl_mins.unwrap_or(0).max(cfg.general.interval()), None => ttl_mins.unwrap_or(0).max(cfg.general.interval()),
}; };
mins * 60 let failing_for = error_since.map(|since| (last_checked - since).max(0) as u64);
backoff(mins * 60, failing_for)
}
/// A failing feed waits as long as it has been failing, so the wait doubles with each failure
/// (1h, 1h, 2h, 4h, ... on an hourly schedule), never less than its usual interval and, past that,
/// never more than a day. Retried hourly, a feed dead for good cost a request, a warning and scan
/// time every hour (#99). The first success clears error_since, and with it the backoff; a
/// refresh someone asks for is not held back by it.
fn backoff(usual: u64, failing_for: Option<u64>) -> u64 {
const CEILING: u64 = 86_400;
match failing_for {
Some(f) => usual.max(f.min(CEILING)),
None => usual,
}
} }
#[derive(Default)] #[derive(Default)]
@@ -1136,12 +1369,38 @@ enum Outcome {
Opml { added: Vec<String>, removed: usize, kept: usize, total: usize }, Opml { added: Vec<String>, removed: usize, kept: usize, total: usize },
} }
/// A feed that answered from a new address after permanent redirects moves there in the
/// catalogue, so it is read from there and no longer redirected every time. One an OPML lists
/// is left alone, since the OPML would only put the old address back; so is a move onto an
/// address another feed already has.
async fn follow_move(ctx: &Ctx, id: &str, to: &str) -> Result<()> {
// Said in the event below, not logged here: the event is the log line.
if subscriptions(ctx).await?.iter().any(|s| s.id != id && feed::same_feed(&s.cfg.url, to)) {
tracing::info!(feed = id, to, "the feed moved to an address another feed already has; leaving it");
return Ok(());
}
let mut cfg = (*ctx.cfg()).clone();
let Some(f) = cfg.feeds.get_mut(id) else { return Ok(()) };
let from = std::mem::replace(&mut f.url, to.to_owned());
ctx.store_cfg(cfg).await?;
ctx.out.emit(Event::FeedMoved { feed: id.to_owned(), from, to: to.to_owned() });
Ok(())
}
#[tracing::instrument(name = "feed", skip_all, fields(feed = id))]
async fn scan_one( async fn scan_one(
ctx: &Arc<Ctx>, ctx: &Arc<Ctx>,
id: &str, id: &str,
feed_cfg: &config::Feed, feed_cfg: &config::Feed,
state: &db::HttpState, state: &db::HttpState,
force: bool,
// The body the scan already fetched for it, if it did (#104), and where it moved, if it did.
prefetched: Option<Result<(feed::Fetched, Option<String>)>>,
) -> Result<Outcome> { ) -> Result<Outcome> {
// The queue follows the feed's settings each time it is due, changed or not, so files a
// limit or auto-download no longer reaches stop counting as waiting (#113).
let policy = policy_for(ctx, id, feed_cfg).await?;
ctx.db.hold_back(id, if policy.auto_download { policy.budget } else { 0 }).await?;
// A Patreon creator with more than one show is a list of feeds, like an OPML. // A Patreon creator with more than one show is a list of feeds, like an OPML.
if feed::is_patreon_creator(&feed_cfg.url) { if feed::is_patreon_creator(&feed_cfg.url) {
match feed::patreon_shows(&ctx.client, &feed_cfg.url).await { match feed::patreon_shows(&ctx.client, &feed_cfg.url).await {
@@ -1167,22 +1426,24 @@ async fn scan_one(
} }
} }
let mut fetched = feed::fetch( let (mut fetched, moved) = match prefetched {
&ctx.client, Some(got) => got?,
feed_cfg, None => feed::fetch(&ctx.feed_client, feed_cfg, state.etag.as_deref(), state.last_modified.as_deref()).await?,
state.etag.as_deref(), };
state.last_modified.as_deref(), if let Some(to) = moved {
) follow_move(ctx, id, &to).await?;
.await?; }
// A 304 while nothing is stored means the validator has outlived the data -- a restore // A 304 while nothing is stored means the validator has outlived the data -- a restore
// from backup, a manual edit, a cleanup that removed entries. Believe the database over // from backup, a manual edit, a cleanup that removed entries. Believe the database over
// the validator: drop it and ask again, or the feed stays empty until the publisher // the validator: drop it and ask again, or the feed stays empty until the publisher
// happens to change something. // happens to change something. The same for artwork never looked for: a feed from before
if matches!(fetched, feed::Fetched::NotModified) && ctx.db.feed_summary(id).await?.entries == 0 { // site icons (#73) would otherwise wait for its next post to get one.
let stored = ctx.db.feed_summary(id).await?;
if matches!(fetched, feed::Fetched::NotModified) && (stored.entries == 0 || stored.image.is_none()) {
tracing::info!(feed = id, "not modified, but nothing stored; refetching without the validator"); tracing::info!(feed = id, "not modified, but nothing stored; refetching without the validator");
ctx.db.clear_validators(id).await?; ctx.db.clear_validators(id).await?;
fetched = feed::fetch(&ctx.client, feed_cfg, None, None).await?; fetched = feed::fetch(&ctx.feed_client, feed_cfg, None, None).await?.0;
} }
let (bytes, etag, last_modified) = match fetched { let (bytes, etag, last_modified) = match fetched {
@@ -1205,7 +1466,56 @@ async fn scan_one(
return sync_opml(ctx, id, feed_cfg, &bytes).await; return sync_opml(ctx, id, feed_cfg, &bytes).await;
} }
let parsed = feed::parse(&bytes)?; let mut parsed = feed::parse(&bytes)?;
// Artwork on http moves to https where its host serves it (#110), before it is compared
// with what is stored, which is then the https address too.
let mut secure = std::collections::HashMap::new();
if let Some(art) = &parsed.image {
parsed.image = Some(feed::prefer_https(&ctx.client, art, &mut secure).await);
}
for entry in parsed.entries.iter_mut() {
if let Some(art) = &entry.image {
entry.image = Some(feed::prefer_https(&ctx.client, art, &mut secure).await);
}
}
// Artwork is looked at when it may have changed: the feed names different artwork from what
// is stored, or someone asked for a refresh, so an icon the site changes or fixes still
// follows it (#80). Looked at on every full read, a feed without validators asked its site
// on every scan: 2 s a scan for lfg.co (#95). A miss is stored as "", which the page draws
// as no art, and which stops the refetch above.
// A feed's own artwork has to be there too: Ken and Robin's names a 404, and stored unasked
// it stood in the way of the site's icon, which works (#89).
let art_span = tracing::info_span!("artwork");
tracing::Instrument::instrument(async {
if let Some(art) = &parsed.image
&& stored.image.as_deref() != Some(art.as_str())
&& !feed::is_image(&ctx.client, art).await
{
tracing::info!(feed = id, art = %art, "the feed's artwork is not an image; trying its site's icon");
parsed.image = None;
}
if parsed.image.is_none() {
parsed.image = Some(match (&stored.image, &parsed.site) {
(Some(known), _) if !force => known.clone(),
(_, Some(site)) => feed::site_icon(&ctx.client, site).await.unwrap_or_default(),
(_, None) => String::new(),
});
}
}, art_span).await;
// A site's icon comes back on whatever the site is on.
if let Some(art) = &parsed.image {
parsed.image = Some(feed::prefer_https(&ctx.client, art, &mut secure).await);
}
// Items stored before, which a scan does not write again, move with their host, and a host
// only they still name is asked too.
for art in ctx.db.http_images(id).await? {
feed::prefer_https(&ctx.client, &art, &mut secure).await;
}
for (host, ok) in &secure {
if *ok {
ctx.db.secure_images(id, host).await?;
}
}
ctx.db.record_feed( ctx.db.record_feed(
id, id,
&feed_cfg.url, &feed_cfg.url,
@@ -1217,7 +1527,6 @@ async fn scan_one(
parsed.category.as_deref(), parsed.category.as_deref(),
).await?; ).await?;
let policy = policy_for(ctx, id, feed_cfg).await?;
if let Some(parent) = &feed_cfg.group { if let Some(parent) = &feed_cfg.group {
let listed: Vec<(&str, &str)> = parsed let listed: Vec<(&str, &str)> = parsed
.entries .entries
@@ -1231,13 +1540,19 @@ async fn scan_one(
// discovery, it outlived the setting behind it, and allowing explicit items afterwards // discovery, it outlived the setting behind it, and allowing explicit items afterwards
// 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 mut scan = Scan::default(); let mut scan = Scan::default();
// Its own span: the time a feed spends after its fetch was untraced (#96).
let store = tracing::info_span!("store", items = parsed.entries.len());
tracing::Instrument::instrument(async {
for entry in &parsed.entries { for entry in &parsed.entries {
if ctx.db.record_entry(id, entry).await? { // Only what is not stored yet is inserted; the insert would find the rest and do nothing.
if !known_items.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 {
let was = if ctx.db.record_enclosure(id, &entry.guid, enc).await? { // A URL not among this feed's files may still be another feed's: the insert says.
let was = if !known_files.contains(&enc.url) && ctx.db.record_enclosure(id, &entry.guid, enc).await? {
None None
} else if let Some(reason) = skipped.get(&enc.url) { } else if let Some(reason) = skipped.get(&enc.url) {
Some(reason.as_str()) Some(reason.as_str())
@@ -1253,10 +1568,14 @@ async fn scan_one(
} }
} }
} }
anyhow::Ok(())
}, store).await?;
if scan.new_entries > 0 { if scan.new_entries > 0 {
ctx.db.rehide(id).await?; // what is new may hold someone's blocked words ctx.db.rehide(id).await?; // what is new may hold someone's blocked words
} }
// A new item takes a place among the newest and the oldest of them leaves the queue; a file a
// filter lets through again may be outside them.
ctx.db.hold_back(id, if policy.auto_download { policy.budget } else { 0 }).await?;
let budget = policy.budget; let budget = policy.budget;
if policy.auto_download && budget > 0 { if policy.auto_download && budget > 0 {
@@ -1511,7 +1830,10 @@ fn merge_policy(subs: &[db::Sub], feed_cfg: &config::Feed, global: usize) -> Pol
if subs.is_empty() { if subs.is_empty() {
return Policy { return Policy {
auto_download: feed_cfg.auto_download, // A feed listed in the Directory with nobody subscribed is there to be found, not
// downloaded: its files would be for no one. Once someone subscribes, the files
// skipped for it are judged again on the next scan, by their settings (#107).
auto_download: feed_cfg.auto_download && !feed_cfg.listed,
allow_explicit: feed_cfg.allow_explicit, allow_explicit: feed_cfg.allow_explicit,
wants: vec![Want { keywords: feed_cfg.keywords.clone(), blocked: vec![] }], wants: vec![Want { keywords: feed_cfg.keywords.clone(), blocked: vec![] }],
budget: cap(feed_cfg.max_new_per_check), budget: cap(feed_cfg.max_new_per_check),
@@ -1541,6 +1863,7 @@ fn merge_policy(subs: &[db::Sub], feed_cfg: &config::Feed, global: usize) -> Pol
policy policy
} }
#[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>,
feed_id: &str, feed_id: &str,
@@ -1602,7 +1925,7 @@ fn spawn_torrent(ctx: &Arc<Ctx>, feed_id: String, enclosure: i64, url: String, d
match outcome { match outcome {
Ok((path, bytes)) => { Ok((path, bytes)) => {
if let Err(e) = db.mark_downloaded(&url, &path, bytes).await { if let Err(e) = db.mark_downloaded(&url, &path, bytes).await {
tracing::warn!(error = ?e, "could not record the finished torrent"); tracing::warn!(error = %format!("{e:#}"), "could not record the finished torrent");
} }
ctx.out.emit(Event::DownloadDone { ctx.out.emit(Event::DownloadDone {
feed: feed_id, feed: feed_id,
@@ -1623,6 +1946,7 @@ fn spawn_torrent(ctx: &Arc<Ctx>, feed_id: String, enclosure: i64, url: String, d
/// Downloads one specific enclosure immediately, whatever the per-scan cap says and /// Downloads one specific enclosure immediately, whatever the per-scan cap says and
/// wherever it sits in the queue. /// wherever it sits in the queue.
#[tracing::instrument(skip(ctx))]
async fn download_one(ctx: &Arc<Ctx>, id: i64) -> Result<()> { async fn download_one(ctx: &Arc<Ctx>, id: i64) -> Result<()> {
let cfg = ctx.cfg(); let cfg = ctx.cfg();
let enc = ctx let enc = ctx
@@ -1692,6 +2016,7 @@ async fn download_one(ctx: &Arc<Ctx>, id: i64) -> Result<()> {
/// Torrent progress is reported the same way an HTTP download's is, throttled to whole /// Torrent progress is reported the same way an HTTP download's is, throttled to whole
/// percents so a UI is not flooded. /// percents so a UI is not flooded.
#[tracing::instrument(name = "torrent", skip_all, fields(feed = feed_id, enclosure = enclosure))]
async fn torrent_one( async fn torrent_one(
ctx: &Arc<Ctx>, ctx: &Arc<Ctx>,
feed_id: &str, feed_id: &str,
@@ -1741,6 +2066,42 @@ fn duration(secs: u64) -> String {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn a_feed_is_due_after_its_wait_and_daily_with_nobody_subscribed() {
let cfg = config::Config::default();
let f = feed();
let at = |last: Option<i64>, subscribed| {
due_at(&cfg, &f, &db::HttpState { last_checked: last, ..Default::default() }, subscribed)
};
assert_eq!(at(None, true), None); // never checked: due now
assert_eq!(at(Some(1000), true), Some(1000 + 3600)); // the hourly default
assert_eq!(at(Some(1000), false), Some(1000 + 86_400)); // listed, nobody subscribed
}
#[test]
fn a_listed_feed_nobody_subscribes_to_downloads_nothing() {
let mut f = feed();
assert!(merge_policy(&[], &f, 3).auto_download);
f.listed = true;
assert!(!merge_policy(&[], &f, 3).auto_download);
}
#[test]
fn a_failing_feed_backs_off_doubling_up_to_a_day() {
let hour = 3600;
assert_eq!(backoff(hour, None), hour);
// Failing since 0: each check waits as long as the failure has lasted so far.
let (mut at, mut waits) = (0, vec![]);
for _ in 0..8 {
let w = backoff(hour, Some(at));
waits.push(w / hour);
at += w;
}
assert_eq!(waits, [1, 1, 2, 4, 8, 16, 24, 24]);
// A weekly schedule is longer than the ceiling and stays as it is.
assert_eq!(backoff(7 * 86_400, Some(30 * 86_400)), 7 * 86_400);
}
#[tokio::test] #[tokio::test]
async fn the_first_start_moves_the_configuration_in_and_trims_the_file() { async fn the_first_start_moves_the_configuration_in_and_trims_the_file() {
let dir = std::env::temp_dir().join(format!("ipx-assemble-{}", std::process::id())); let dir = std::env::temp_dir().join(format!("ipx-assemble-{}", std::process::id()));
@@ -1795,6 +2156,7 @@ mod tests {
password: None, password: None,
password_env: None, password_env: None,
category: None, category: None,
listed: false,
} }
} }
@@ -1854,6 +2216,7 @@ mod tests {
cfg: std::sync::RwLock::new(Arc::new(cfg)), cfg: std::sync::RwLock::new(Arc::new(cfg)),
db: db::Db::memory().await.unwrap(), db: db::Db::memory().await.unwrap(),
client: reqwest::Client::new(), client: reqwest::Client::new(),
feed_client: reqwest::Client::builder().redirect(reqwest::redirect::Policy::none()).build().unwrap(),
out: Emitter::terminal(), out: Emitter::terminal(),
torrents: tokio::sync::OnceCell::new(), torrents: tokio::sync::OnceCell::new(),
torrent_slots: Arc::new(tokio::sync::Semaphore::new(2)), torrent_slots: Arc::new(tokio::sync::Semaphore::new(2)),

View File

@@ -57,6 +57,10 @@ pub async fn run(cfg: &Config, db: &Db, dry_run: bool) -> Result<Report> {
report.reconciled += 1; report.reconciled += 1;
} }
for id in db.prune_abandoned_failures().await? {
tracing::info!(feed = id, "forgot a failing feed nobody subscribes to");
}
let candidates = db.reap_candidates().await?; let candidates = db.reap_candidates().await?;
if cfg.general.max_age_days > 0 { if cfg.general.max_age_days > 0 {

View File

@@ -1,9 +1,10 @@
//! Web front end. Runs inside the daemon so it reads SQLite and the event bus directly. //! Web front end. Runs inside the daemon so it reads SQLite and the event bus directly.
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use axum::http::HeaderMap;
use axum::{ use axum::{
Json, Router, Json, Router,
extract::{Path, Query, Request, State}, extract::{MatchedPath, Path, Query, Request, State},
http::{StatusCode, header}, http::{StatusCode, header},
middleware::{self, Next}, middleware::{self, Next},
response::{ response::{
@@ -52,6 +53,7 @@ pub fn router(state: WebState) -> Router {
.route("/api/fetch", post(fetch_now)) .route("/api/fetch", post(fetch_now))
.route("/api/opml", get(export_opml).post(import_opml)) .route("/api/opml", get(export_opml).post(import_opml))
.route("/api/settings", get(get_settings).patch(patch_settings)) .route("/api/settings", get(get_settings).patch(patch_settings))
.route("/api/status", get(status))
.route("/api/popular", get(get_popular)) .route("/api/popular", get(get_popular))
.route("/api/directory", get(get_directory)) .route("/api/directory", get(get_directory))
.route("/api/popular/{id}", post(subscribe_popular)) .route("/api/popular/{id}", post(subscribe_popular))
@@ -62,19 +64,25 @@ 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("/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.
.route("/login", get(login_page)) .route("/login", get(login_page))
.route("/api/login", post(login)) .route("/api/login", post(login))
.route("/icon.png", get(icon)) .route("/icon.png", get(icon))
.route("/logo.svg", get(logo)) .route("/logo.svg", get(logo))
.route("/logo-dark.svg", get(logo_dark))
.route("/favicon.ico", get(favicon)) .route("/favicon.ico", get(favicon))
.route("/favicon.png", get(favicon)) .route("/favicon.png", get(favicon))
.route("/favicon-dark.png", get(favicon_dark))
.route("/apple-touch-icon.png", get(touch_icon)) .route("/apple-touch-icon.png", get(touch_icon))
// iOS asks for this one first when the site is added to a home screen (#109).
.route("/apple-touch-icon-precomposed.png", get(touch_icon))
.route("/app.js", get(app_js)) .route("/app.js", get(app_js))
.route("/app.css", get(app_css)) .route("/app.css", get(app_css))
.route("/login.js", get(login_js)) .route("/login.js", get(login_js))
.route("/inter.woff2", get(inter)) .route("/inter.woff2", get(inter))
.route_layer(middleware::from_fn(name_span))
.layer(middleware::from_fn(access_log)) .layer(middleware::from_fn(access_log))
.with_state(state) .with_state(state)
} }
@@ -249,7 +257,11 @@ impl<S: Send + Sync> axum::extract::FromRequestParts<S> for crate::db::User {
} }
fn cookie(req: &Request, name: &str) -> Option<String> { fn cookie(req: &Request, name: &str) -> Option<String> {
req.headers() headers_cookie(req.headers(), name)
}
fn headers_cookie(headers: &HeaderMap, name: &str) -> Option<String> {
headers
.get(header::COOKIE) .get(header::COOKIE)
.and_then(|v| v.to_str().ok()) .and_then(|v| v.to_str().ok())
.and_then(|c| { .and_then(|c| {
@@ -332,38 +344,25 @@ async fn me(
) -> Json<serde_json::Value> { ) -> Json<serde_json::Value> {
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 (theme, mode) = state.ctx.db.theme(user.id).await.unwrap_or_default();
let blocked = state.ctx.db.blocklist(user.id, "").await.unwrap_or_default(); let blocked = state.ctx.db.blocklist(user.id, "").await.unwrap_or_default();
Json(serde_json::json!({ Json(serde_json::json!({
"name": user.name, "admin": user.is_admin, "sign_out": sign_out, "theme": theme, "mode": mode, "name": user.name, "admin": user.is_admin, "sign_out": sign_out, "blocked": blocked,
"blocked": blocked,
})) }))
} }
#[derive(Deserialize)] #[derive(Deserialize)]
struct MePatch { struct MePatch {
theme: Option<String>,
mode: Option<String>,
/// Words that hide an item in every feed you read. /// Words that hide an item in every feed you read.
blocked: Option<Vec<String>>, blocked: Option<Vec<String>>,
} }
/// Saves the theme to the account, so it follows the person rather than the browser, and the /// Saves the block list for every feed. The theme is not the account's: each browser keeps its
/// block list for every feed. /// own, in a cookie (issue #69).
async fn patch_me( async fn patch_me(
State(state): State<WebState>, State(state): State<WebState>,
user: crate::db::User, user: crate::db::User,
Json(body): Json<MePatch>, Json(body): Json<MePatch>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
if body.theme.is_some() || body.mode.is_some() {
let (theme, mode) = (body.theme.unwrap_or_default(), body.mode.unwrap_or_default());
// The page's script knows the themes; this only makes sure what is kept is safe to write
// into the page's <html> tag, which is where index() puts it.
if !theme_ok(&theme, &mode) {
return Err(ApiError::bad_request("not a theme"));
}
state.ctx.db.set_theme(user.id, &theme, &mode).await?;
}
if let Some(words) = body.blocked { if let Some(words) = body.blocked {
state.ctx.db.set_blocklist(user.id, "", &clean_words(words)?).await?; state.ctx.db.set_blocklist(user.id, "", &clean_words(words)?).await?;
} }
@@ -527,13 +526,26 @@ async fn app_css() -> impl IntoResponse {
) )
} }
/// The cookie the page keeps its theme in, `<theme>.<mode>`, written by theme.ts.
const THEME_COOKIE: &str = "ipx_theme";
/// The theme this browser chose, from its cookie: per device, so a phone and a desktop signed in
/// as the same person can each have their own (issue #69). A browser without one yet gets the
/// theme the account kept from before, which theme.ts then writes into the cookie.
async fn page_theme(state: &WebState, user: &crate::db::User, headers: &HeaderMap) -> (Option<String>, Option<String>) {
if let Some((t, m)) = headers_cookie(headers, THEME_COOKIE).as_deref().and_then(|v| v.split_once('.')) {
return (Some(t.to_owned()), Some(m.to_owned()));
}
state.ctx.db.theme(user.id).await.unwrap_or_default()
}
/// The admin page and its script go to admins only: not just hidden from everyone else, never /// The admin page and its script go to admins only: not just hidden from everyone else, never
/// sent. Anyone else asking for the page is sent back to the app. /// sent. Anyone else asking for the page is sent back to the app.
async fn admin_page(State(state): State<WebState>, user: crate::db::User) -> Response { async fn admin_page(State(state): State<WebState>, user: crate::db::User, headers: HeaderMap) -> Response {
if !user.is_admin { if !user.is_admin {
return Redirect::to("/").into_response(); return Redirect::to("/").into_response();
} }
let theme = state.ctx.db.theme(user.id).await.unwrap_or_default(); let theme = page_theme(&state, &user, &headers).await;
let page = with_theme(include_str!(concat!(env!("OUT_DIR"), "/admin.html")), theme); let page = with_theme(include_str!(concat!(env!("OUT_DIR"), "/admin.html")), theme);
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(page)).into_response() ([(header::CACHE_CONTROL, PAGE_CACHE)], Html(page)).into_response()
} }
@@ -562,6 +574,15 @@ async fn logo() -> impl IntoResponse {
) )
} }
/// logo.svg recoloured for a dark page. The pages pick one or the other by their light or dark
/// mode.
async fn logo_dark() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "image/svg+xml"), (header::CACHE_CONTROL, "max-age=86400")],
include_str!("../web/logo-dark.svg"),
)
}
/// The 2004 icon. Nothing here shows it any more; it stays for whatever outside ipx links to it. /// The 2004 icon. Nothing here shows it any more; it stays for whatever outside ipx links to it.
async fn icon() -> impl IntoResponse { async fn icon() -> impl IntoResponse {
( (
@@ -579,6 +600,14 @@ async fn favicon() -> impl IntoResponse {
) )
} }
/// web/logo-dark.svg at 128px, the tab's icon while the page is dark.
async fn favicon_dark() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "image/png"), (header::CACHE_CONTROL, "max-age=86400")],
include_bytes!("../web/favicon-dark.png").as_slice(),
)
}
/// web/logo.svg at 180px, for an iPhone's home screen. It is opaque edge to edge, as iOS paints /// web/logo.svg at 180px, for an iPhone's home screen. It is opaque edge to edge, as iOS paints
/// a transparent icon's background black and cuts its own corners. /// a transparent icon's background black and cuts its own corners.
async fn touch_icon() -> impl IntoResponse { async fn touch_icon() -> impl IntoResponse {
@@ -610,11 +639,14 @@ fn constant_time_eq(a: &str, b: &str) -> bool {
// quotes, so ADMIN_LINK and HTML_TAG are spelled the way the minifier leaves them. // quotes, so ADMIN_LINK and HTML_TAG are spelled the way the minifier leaves them.
const INDEX: &str = include_str!(concat!(env!("OUT_DIR"), "/index.html")); const INDEX: &str = include_str!(concat!(env!("OUT_DIR"), "/index.html"));
const ADMIN_LINK: &str = "<a id=admin "; const ADMIN_LINK: &str = "<a id=admin ";
// The logo's tooltip. Filled in here, not by build.mjs, because build.rs does not rerun when only
// Cargo.toml's version changes, and the page would go on naming the last release.
const VERSION_SLOT: &str = "iPX {version}";
/// The page, with the log button left out for anyone but an admin. Hiding it from the page's /// The page, with the log button left out for anyone but an admin. Hiding it from the page's
/// script instead showed it for a moment on every load, until /api/me answered. /// script instead showed it for a moment on every load, until /api/me answered.
async fn index(State(state): State<WebState>, user: crate::db::User) -> impl IntoResponse { async fn index(State(state): State<WebState>, user: crate::db::User, headers: HeaderMap) -> impl IntoResponse {
let theme = state.ctx.db.theme(user.id).await.unwrap_or_default(); let theme = page_theme(&state, &user, &headers).await;
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(page_for(user.is_admin, theme))) ([(header::CACHE_CONTROL, PAGE_CACHE)], Html(page_for(user.is_admin, theme)))
} }
@@ -624,7 +656,7 @@ const HTML_TAG: &str = "<html lang=en>";
/// are an admin. Not hidden for everyone else but left out: hiding it from the page's script /// are an admin. Not hidden for everyone else but left out: hiding it from the page's script
/// showed it for a moment on every load, until /api/me answered (issue #29). /// showed it for a moment on every load, until /api/me answered (issue #29).
fn page_for(admin: bool, theme: (Option<String>, Option<String>)) -> String { fn page_for(admin: bool, theme: (Option<String>, Option<String>)) -> String {
let mut page = with_theme(INDEX, theme); let mut page = with_theme(INDEX, theme).replacen(VERSION_SLOT, concat!("iPX ", env!("CARGO_PKG_VERSION")), 1);
if !admin if !admin
&& let Some(at) = page.find(ADMIN_LINK) && let Some(at) = page.find(ADMIN_LINK)
&& let Some(len) = page[at..].find("</a>") && let Some(len) = page[at..].find("</a>")
@@ -701,6 +733,12 @@ async fn feeds(
State(state): State<WebState>, State(state): State<WebState>,
user: crate::db::User, user: crate::db::User,
) -> Result<Json<Vec<FeedRow>>, ApiError> { ) -> Result<Json<Vec<FeedRow>>, ApiError> {
Ok(Json(feed_rows(&state, &user, None).await?))
}
/// 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.
async fn feed_rows(state: &WebState, user: &crate::db::User, only: Option<&str>) -> anyhow::Result<Vec<FeedRow>> {
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.
@@ -714,15 +752,16 @@ async fn feeds(
.collect(); .collect();
let counts = state.ctx.db.subscriber_counts().await?; let counts = state.ctx.db.subscriber_counts().await?;
let pinned = state.ctx.db.pinned_feeds(user.id).await?; let pinned = state.ctx.db.pinned_feeds(user.id).await?;
let mut listed = state.ctx.db.feed_list(user.id, only).await?;
let mut out = Vec::with_capacity(mine.len()); let mut out = Vec::with_capacity(mine.len());
for sub in &subs { 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);
// In a group, what you have not set on the feed comes from your settings on the group, // In a group, what you have not set on the feed comes from your settings on the group,
// the same fallback the scanner uses (`Db::subscribers`). // the same fallback the scanner uses (`Db::subscribers`).
let up = feed.group.as_deref().and_then(|g| mine.get(g)); let up = feed.group.as_deref().and_then(|g| mine.get(g));
let Some(mine) = mine.get(id) else { continue }; let Some(mine) = mine.get(id) else { continue };
let s = state.ctx.db.feed_summary(id).await?; // A feed not scanned yet has no row: blank, as feed_summary gave it.
let st = state.ctx.db.http_state(id).await?; let crate::db::FeedListing { summary: s, ttl_mins, blocked, unread } = listed.remove(id).unwrap_or_default();
out.push(FeedRow { out.push(FeedRow {
id: id.clone(), id: id.clone(),
url: feed.url.clone(), url: feed.url.clone(),
@@ -736,7 +775,7 @@ async fn feeds(
.clone() .clone()
.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: state.ctx.db.blocklist(user.id, id).await?, blocked,
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))
@@ -759,11 +798,11 @@ async fn feeds(
.schedule .schedule
.as_deref() .as_deref()
.and_then(crate::config::parse_interval), .and_then(crate::config::parse_interval),
every_mins: crate::due_after(&cfg, feed, st.ttl_mins) / 60, every_mins: crate::due_after(&cfg, feed, ttl_mins, None, 0) / 60,
last_checked: s.last_checked, last_checked: s.last_checked,
next_check: s next_check: s
.last_checked .last_checked
.map(|t| t + crate::due_after(&cfg, feed, st.ttl_mins) as i64), .map(|t| t + crate::due_after(&cfg, feed, ttl_mins, s.error_since, t) as i64),
failing: s failing: s
.error_since .error_since
.filter(|since| crate::db::now() - since >= FLAG_AFTER_SECS) .filter(|since| crate::db::now() - since >= FLAG_AFTER_SECS)
@@ -773,12 +812,12 @@ async fn feeds(
last_error: s.last_error, last_error: s.last_error,
entries: s.entries, entries: s.entries,
downloaded: s.downloaded, downloaded: s.downloaded,
unread: state.ctx.db.unread_count(user.id, id).await?, unread,
subscribers: counts.get(id).copied().unwrap_or(0), subscribers: counts.get(id).copied().unwrap_or(0),
pinned: pinned.contains(id), pinned: pinned.contains(id),
}); });
} }
Ok(Json(out)) Ok(out)
} }
// ---- popular on this server ---- // ---- popular on this server ----
@@ -833,8 +872,13 @@ struct PopularRow {
/// first. Popular is the top of it, the directory is all of it, and it is all that /// first. Popular is the top of it, the directory is all of it, and it is all that
/// `subscribe_popular` will subscribe you to. An OPML or a Patreon creator is listed as the /// `subscribe_popular` will subscribe you to. An OPML or a Patreon creator is listed as the
/// feeds inside it and never itself: both lists are for finding a show. /// feeds inside it and never itself: both lists are for finding a show.
async fn popular(state: &WebState, user_id: i64) -> Result<Vec<PopularRow>> { /// The catalogue as others may see it. `everything` is the Directory: every feed, with or
/// without subscribers, so the ones an admin listed show before anyone takes them. Without it,
/// Popular: only what people subscribe to.
async fn popular(state: &WebState, user_id: i64, everything: bool) -> Result<Vec<PopularRow>> {
let db = &state.ctx.db; let db = &state.ctx.db;
// One pass for every feed's title, artwork and category, not three queries a feed.
let mut listed = db.feed_list(user_id, None).await?;
let mine: std::collections::HashSet<String> = let mine: std::collections::HashSet<String> =
db.subscriptions_for(user_id).await?.into_iter().map(|s| s.feed_id).collect(); db.subscriptions_for(user_id).await?.into_iter().map(|s| s.feed_id).collect();
let counts = db.subscriber_counts().await?; let counts = db.subscriber_counts().await?;
@@ -849,14 +893,14 @@ async fn popular(state: &WebState, user_id: i64) -> Result<Vec<PopularRow>> {
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. // A feed inside an OPML that looks private is as private as the OPML.
let folder = s.cfg.group.as_deref().and_then(|g| by_id.get(g)); let folder = s.cfg.group.as_deref().and_then(|g| by_id.get(g));
if n == 0 if (n == 0 && !everything)
|| is_folder.contains(s.id.as_str()) || is_folder.contains(s.id.as_str())
|| looks_private(&s.cfg) || looks_private(&s.cfg)
|| folder.is_some_and(|f| looks_private(f)) || folder.is_some_and(|f| looks_private(f))
{ {
continue; continue;
} }
let sum = db.feed_summary(&s.id).await?; let sum = listed.remove(&s.id).map(|l| l.summary).unwrap_or_default();
let subscribed = mine.contains(&s.id); let subscribed = mine.contains(&s.id);
out.push(PopularRow { out.push(PopularRow {
id: s.id.clone(), id: s.id.clone(),
@@ -877,7 +921,7 @@ async fn get_popular(
State(state): State<WebState>, State(state): State<WebState>,
user: crate::db::User, user: crate::db::User,
) -> Result<Json<Vec<PopularRow>>, ApiError> { ) -> Result<Json<Vec<PopularRow>>, ApiError> {
let mut rows = popular(&state, user.id).await?; let mut rows = popular(&state, user.id, false).await?;
rows.truncate(10); rows.truncate(10);
Ok(Json(rows)) Ok(Json(rows))
} }
@@ -887,7 +931,7 @@ async fn get_directory(
State(state): State<WebState>, State(state): State<WebState>,
user: crate::db::User, user: crate::db::User,
) -> Result<Json<Vec<PopularRow>>, ApiError> { ) -> Result<Json<Vec<PopularRow>>, ApiError> {
let mut rows = popular(&state, user.id).await?; let mut rows = popular(&state, user.id, true).await?;
rows.sort_by_key(sort_name); rows.sort_by_key(sort_name);
Ok(Json(rows)) Ok(Json(rows))
} }
@@ -903,10 +947,12 @@ 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).await?.iter().any(|p| p.id == id) { if !popular(&state, user.id, true).await?.iter().any(|p| p.id == id) {
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?;
// A listed feed nobody took was checked once a day at most: read it now, not in an hour.
scan_soon(&state, Some(id.clone())).await;
Ok(Json(serde_json::json!({ "id": id }))) Ok(Json(serde_json::json!({ "id": id })))
} }
@@ -1002,6 +1048,13 @@ mod tests {
assert!(page.contains("id=prefs"), "and only the link: the settings button beside it stays"); assert!(page.contains("id=prefs"), "and only the link: the settings button beside it stays");
} }
#[test]
fn the_logo_names_the_version() {
// If the minifier drifts from VERSION_SLOT, replacen matches nothing and says nothing.
let page = page_for(false, (None, None));
assert!(!page.contains(VERSION_SLOT) && page.contains(concat!("iPX ", env!("CARGO_PKG_VERSION"))));
}
#[test] #[test]
fn the_page_arrives_in_the_theme_the_account_chose() { fn the_page_arrives_in_the_theme_the_account_chose() {
let page = |t: &str, m: &str| page_for(true, (Some(t.into()), Some(m.into()))); let page = |t: &str, m: &str| page_for(true, (Some(t.into()), Some(m.into())));
@@ -1029,6 +1082,7 @@ mod tests {
password: None, password: None,
password_env: None, password_env: None,
category: None, category: None,
listed: false,
}; };
assert!(!looks_private(&f("https://feeds.twit.tv/twit.xml"))); assert!(!looks_private(&f("https://feeds.twit.tv/twit.xml")));
assert!(!looks_private(&f("https://example.com/rss?format=mp3"))); assert!(!looks_private(&f("https://example.com/rss?format=mp3")));
@@ -1075,7 +1129,7 @@ mod tests {
crate::config::Feed { crate::config::Feed {
url: url.into(), folder: None, group: None, media_types: None, schedule: None, keywords: vec![], allow_explicit: false, url: url.into(), folder: None, group: None, media_types: None, schedule: None, keywords: vec![], allow_explicit: false,
auto_download: true, max_new_per_check: None, username: None, auto_download: true, max_new_per_check: None, username: None,
password: None, password_env: None, category: None, password: None, password_env: None, category: None, listed: false,
} }
} }
@@ -1244,7 +1298,11 @@ async fn add_feed(
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(); let mut cfg = (*state.ctx.cfg()).clone();
let url = crate::feed::expand_input(&body.url); // 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.
let url = crate::feed::find_feed(&state.ctx.client, &crate::feed::expand_input(&body.url))
.await
.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? if let Some(existing) = crate::subscriptions(&state.ctx).await?
@@ -1457,8 +1515,12 @@ async fn remove_feed(
} }
// Nobody is left: the feed stops being scanned. Its files and history stay, so if // 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. // 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(); 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() { if cfg.feeds.remove(&id).is_none() {
// A derived feed: forget it here, though the OPML will list it again on the next // A derived feed: forget it here, though the OPML will list it again on the next
// read unless you unsubscribe from the OPML itself. // read unless you unsubscribe from the OPML itself.
@@ -1481,7 +1543,7 @@ async fn set_flags(
Path((feed_id, guid)): Path<(String, String)>, Path((feed_id, guid)): Path<(String, String)>,
user: crate::db::User, user: crate::db::User,
Json(body): Json<Flags>, Json(body): Json<Flags>,
) -> Result<StatusCode, ApiError> { ) -> Result<Json<Option<FeedRow>>, ApiError> {
use crate::db::EntryFlag; use crate::db::EntryFlag;
if let Some(v) = body.read { if let Some(v) = body.read {
state.ctx.db.set_entry_flag(user.id, &feed_id, &guid, EntryFlag::Read, v).await?; state.ctx.db.set_entry_flag(user.id, &feed_id, &guid, EntryFlag::Read, v).await?;
@@ -1489,7 +1551,9 @@ async fn set_flags(
if let Some(v) = body.flagged { if let Some(v) = body.flagged {
state.ctx.db.set_entry_flag(user.id, &feed_id, &guid, EntryFlag::Flagged, v).await?; state.ctx.db.set_entry_flag(user.id, &feed_id, &guid, EntryFlag::Flagged, v).await?;
} }
Ok(StatusCode::NO_CONTENT) // The feed's row with its new unread count, for the page to put in place of the old one
// rather than reloading the whole list after every item read.
Ok(Json(feed_rows(&state, &user, Some(&feed_id)).await?.into_iter().next()))
} }
/// Downloads one enclosure now. This cannot be "requeue and scan": a scan takes the /// Downloads one enclosure now. This cannot be "requeue and scan": a scan takes the
@@ -1602,23 +1666,48 @@ async fn fetch_now(
} }
/// The same broadcast the socket clients read, as server-sent events. /// The same broadcast the socket clients read, as server-sent events.
async fn events(State(state): State<WebState>) -> Sse<impl futures_util::Stream<Item = Result<SseEvent, std::convert::Infallible>>> { async fn events(
State(state): State<WebState>,
user: crate::db::User,
) -> Sse<impl futures_util::Stream<Item = Result<SseEvent, std::convert::Infallible>>> {
let rx = state.events.subscribe();
// A client that falls behind skips what it missed rather than being cut off. // A client that falls behind skips what it missed rather than being cut off.
let stream = futures_util::stream::unfold(state.events.subscribe(), |mut rx| async move { let stream = futures_util::stream::unfold((rx, state, user), |(mut rx, state, user)| async move {
loop { loop {
match rx.recv().await { match rx.recv().await {
Ok(ev) => { Ok(ev) => {
if let Ok(data) = serde_json::to_string(&ev) { let Ok(data) = serde_json::to_string(&ev) else { continue };
let ev = Ok::<_, std::convert::Infallible>(SseEvent::default().data(data)); let mut out = vec![Ok::<_, std::convert::Infallible>(SseEvent::default().data(data))];
return Some((ev, rx)); // A feed that changed goes out as this person's row for it, which the page
// puts in place of the old one: it reloaded the whole list after each.
if let Some(feed) = changed_feed(&ev)
&& let Ok(rows) = feed_rows(&state, &user, Some(feed)).await
&& let Some(row) = rows.first()
&& let Ok(data) = serde_json::to_string(&serde_json::json!({ "ev": "feed_row", "row": row }))
{
out.push(Ok(SseEvent::default().data(data)));
} }
return Some((futures_util::stream::iter(out), (rx, state, user)));
} }
Err(broadcast::error::RecvError::Lagged(_)) => {} Err(broadcast::error::RecvError::Lagged(_)) => {}
Err(broadcast::error::RecvError::Closed) => return None, Err(broadcast::error::RecvError::Closed) => return None,
} }
} }
}); });
Sse::new(stream).keep_alive(axum::response::sse::KeepAlive::default()) Sse::new(futures_util::StreamExt::flatten(stream)).keep_alive(axum::response::sse::KeepAlive::default())
}
/// The feed an event changed what the list shows of: its counts, error or last check. Not the
/// routine skip of a feed not due, dozens a minute that change nothing.
fn changed_feed(ev: &Event) -> Option<&str> {
match ev {
Event::FeedDone { feed, .. }
| Event::FeedError { feed, .. }
| Event::FeedMoved { feed, .. }
| Event::DownloadDone { feed, .. } => Some(feed),
Event::FeedSkip { feed, reason } if !reason.starts_with("not due") => Some(feed),
_ => None,
}
} }
/// Audio, served by ServeFile so Range requests work and the player can seek. /// Audio, served by ServeFile so Range requests work and the player can seek.
@@ -1639,6 +1728,65 @@ async fn media(
} }
} }
#[derive(Deserialize)]
struct ArtQuery {
u: String,
}
/// Artwork, from iPX: the page asks here for every image a feed or item names, and the first
/// 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
/// 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
/// artwork on the https page (#90).
async fn art(State(state): State<WebState>, Query(q): Query<ArtQuery>) -> Response {
const MAX: usize = 5 << 20;
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) {
return (StatusCode::NOT_FOUND, "no feed names that artwork").into_response();
}
let keep = state.ctx.cfg().general.art_cache_mb > 0;
let file = crate::art::path(&q.u);
if keep && let Some((kind, body)) = crate::art::read(&file) {
return art_response(kind, body);
}
let got = async {
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();
anyhow::ensure!(kind.starts_with("image/"), "not an image: {kind}");
let mut body = Vec::new();
while let Some(c) = r.chunk().await? {
body.extend_from_slice(&c);
anyhow::ensure!(body.len() <= MAX, "larger than {MAX} bytes");
}
anyhow::Ok((kind, body))
};
match got.await {
Ok((kind, body)) => {
if keep && let Err(e) = crate::art::write(&file, &kind, &body) {
tracing::debug!(error = %e, "could not keep the artwork");
}
art_response(kind, body)
}
Err(e) => (StatusCode::BAD_GATEWAY, format!("{e:#}")).into_response(),
}
}
/// An image from someone else's server, served from iPX's own address: it must stay an image.
/// An SVG opened on its own would otherwise run its script as iPX's page, with its cookies.
fn art_response(kind: String, body: Vec<u8>) -> Response {
(
[
(header::CONTENT_TYPE, kind),
(header::CACHE_CONTROL, "private, max-age=2592000".into()),
(header::X_CONTENT_TYPE_OPTIONS, "nosniff".into()),
(header::CONTENT_SECURITY_POLICY, "default-src 'none'; style-src 'unsafe-inline'; sandbox".into()),
],
body,
)
.into_response()
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct Position { struct Position {
secs: i64, secs: i64,
@@ -1779,6 +1927,22 @@ async fn import_opml(
Ok(Json(serde_json::json!({ "added": added, "already": already }))) Ok(Json(serde_json::json!({ "added": added, "already": already })))
} }
/// The numbers `ipx status` prints, and the version, for a dashboard such as Homepage's
/// customapi widget. Behind sign-in like the rest of /api; Homepage sends the shared token.
async fn status(State(state): State<WebState>) -> Response {
match crate::status(&state.ctx).await {
Event::Status { feeds, pending, downloaded } => Json(serde_json::json!({
"feeds": feeds,
"pending": pending,
"downloaded": downloaded,
"version": env!("CARGO_PKG_VERSION"),
}))
.into_response(),
Event::Error { msg } => (StatusCode::INTERNAL_SERVER_ERROR, msg).into_response(),
_ => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
#[derive(Serialize)] #[derive(Serialize)]
struct Settings { struct Settings {
schedule: String, schedule: String,
@@ -1788,6 +1952,7 @@ struct Settings {
download_dir: String, download_dir: String,
max_total_gb: f64, max_total_gb: f64,
max_age_days: u64, max_age_days: u64,
art_cache_mb: u64,
} }
async fn get_settings(State(state): State<WebState>) -> Json<Settings> { async fn get_settings(State(state): State<WebState>) -> Json<Settings> {
@@ -1800,6 +1965,7 @@ async fn get_settings(State(state): State<WebState>) -> Json<Settings> {
download_dir: cfg.general.download_dir.display().to_string(), download_dir: cfg.general.download_dir.display().to_string(),
max_total_gb: cfg.general.max_total_gb, max_total_gb: cfg.general.max_total_gb,
max_age_days: cfg.general.max_age_days, max_age_days: cfg.general.max_age_days,
art_cache_mb: cfg.general.art_cache_mb,
}) })
} }
@@ -1810,6 +1976,7 @@ struct SettingsPatch {
media_types: Option<Vec<String>>, media_types: Option<Vec<String>>,
max_total_gb: Option<f64>, max_total_gb: Option<f64>,
max_age_days: Option<u64>, max_age_days: Option<u64>,
art_cache_mb: Option<u64>,
} }
async fn patch_settings( async fn patch_settings(
@@ -1846,6 +2013,9 @@ async fn patch_settings(
if let Some(v) = body.max_age_days { if let Some(v) = body.max_age_days {
cfg.general.max_age_days = v; cfg.general.max_age_days = v;
} }
if let Some(v) = body.art_cache_mb {
cfg.general.art_cache_mb = v;
}
state.ctx.store_cfg(cfg).await?; state.ctx.store_cfg(cfg).await?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
@@ -1878,6 +2048,22 @@ async fn logs(user: crate::db::User, Query(q): Query<LogQuery>) -> Result<Json<L
Ok(Json(LogPage { lines, latest })) Ok(Json(LogPage { lines, latest }))
} }
/// Names a request's trace by its route, `POST /api/entries/{feed_id}/{guid}/flags`, once routing
/// has found it. Named by the path `access_log` sees, every item's GUID was a trace name of its
/// own, and nothing grouped. A route layer, because only one runs after routing. Renamed on the
/// OpenTelemetry span itself: recording `otel.name` is ignored once the span has started.
async fn name_span(route: MatchedPath, req: Request, next: Next) -> Response {
use opentelemetry::trace::TraceContextExt;
use tracing_opentelemetry::OpenTelemetrySpanExt;
let span = tracing::Span::current();
span.context().span().update_name(format!("{} {}", req.method(), route.as_str()));
span.record("http.route", route.as_str());
let mut resp = next.run(req).await;
// For access_log, which runs outside routing and cannot see it otherwise.
resp.extensions_mut().insert(route);
resp
}
/// One line per HTTP request, so the web side shows up in the same log as the daemon. /// One line per HTTP request, so the web side shows up in the same log as the daemon.
/// ///
/// The log view polls `/api/logs`, so logging that path would generate a line per poll /// The log view polls `/api/logs`, so logging that path would generate a line per poll
@@ -1886,15 +2072,36 @@ async fn access_log(req: Request, next: Next) -> Response {
let path = req.uri().path().to_owned(); let path = req.uri().path().to_owned();
let method = req.method().clone(); let method = req.method().clone();
let quiet = path.starts_with("/api/logs"); let quiet = path.starts_with("/api/logs");
let started = std::time::Instant::now(); // No trace for the event stream either: an open page asks for it again and again, and Tempo
let resp = next.run(req).await; // would fill with nothing else.
if !quiet { let span = if quiet || path == "/api/events" {
let ms = started.elapsed().as_millis(); tracing::Span::none()
let status = resp.status().as_u16();
if resp.status().is_success() || resp.status().is_redirection() {
tracing::info!(target: "ipx::http", "{method} {path} -> {status} in {ms}ms");
} else { } else {
tracing::warn!(target: "ipx::http", "{method} {path} -> {status} in {ms}ms"); tracing::info_span!(
target: "ipx::http",
"http",
otel.name = %format!("{method} {path}"),
http.request.method = %method,
url.path = %path,
http.route = tracing::field::Empty,
http.response.status_code = tracing::field::Empty,
)
};
let started = std::time::Instant::now();
let resp = tracing::Instrument::instrument(next.run(req), span.clone()).await;
span.record("http.response.status_code", resp.status().as_u16());
if !quiet {
let duration_ms = started.elapsed().as_millis() as u64;
let status = resp.status().as_u16();
// The same as fields, for the JSON log (#91). Unrouted, a request has no route.
let route = resp.extensions().get::<MatchedPath>().map(|r| r.as_str().to_owned());
let method = method.as_str();
// Inside the request's span, so the line carries its trace id and leads to its trace.
let _in = span.enter();
if resp.status().is_success() || resp.status().is_redirection() {
tracing::info!(target: "ipx::http", method, path, route, status, duration_ms, "{method} {path} -> {status} in {duration_ms}ms");
} else {
tracing::warn!(target: "ipx::http", method, path, route, status, duration_ms, "{method} {path} -> {status} in {duration_ms}ms");
} }
} }
resp resp

View File

@@ -47,10 +47,10 @@ test('Settings picks a theme and, where it has both, light, dark or Auto', async
// Auto follows the system, live, with no reload. // Auto follows the system, live, with no reload.
await page.emulateMedia({ colorScheme: 'light' }); await page.emulateMedia({ colorScheme: 'light' });
await expect.poll(root).toEqual(['modern', 'light']); await expect.poll(root).toEqual(['modern', 'light']);
await expect.poll(bg).toBe('rgb(242, 244, 247)'); // Modern's light --bg await expect.poll(bg).toBe('rgb(238, 245, 251)'); // Modern's light --bg
await page.emulateMedia({ colorScheme: 'dark' }); await page.emulateMedia({ colorScheme: 'dark' });
await expect.poll(root).toEqual(['modern', 'dark']); await expect.poll(root).toEqual(['modern', 'dark']);
await expect.poll(bg).toBe('rgb(14, 19, 27)'); // Modern's dark --bg await expect.poll(bg).toBe('rgb(10, 23, 38)'); // Modern's dark --bg
// Dracula, then its light half, Alucard. // Dracula, then its light half, Alucard.
await page.locator('#stheme').selectOption('dracula'); await page.locator('#stheme').selectOption('dracula');
@@ -63,13 +63,9 @@ test('Settings picks a theme and, where it has both, light, dark or Auto', async
await page.locator('#stheme').selectOption('paper'); await page.locator('#stheme').selectOption('paper');
await expect(page.locator('#smode')).toBeHidden(); await expect(page.locator('#smode')).toBeHidden();
await expect.poll(bg).toBe('rgb(242, 238, 222)'); // #F2EEDE await expect.poll(bg).toBe('rgb(242, 238, 222)'); // #F2EEDE
// The save that says Classic: the ones before it may still be answering.
const saved = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH'
&& r.request().postDataJSON().theme === 'classic');
await page.locator('#stheme').selectOption('classic'); await page.locator('#stheme').selectOption('classic');
await expect(page.locator('#smode')).toBeHidden(); await expect(page.locator('#smode')).toBeHidden();
await saved; // kept on the account, not the browser await page.reload(); // kept in this browser's cookie
await page.reload();
await expect.poll(root).toEqual(['classic', 'light']); await expect.poll(root).toEqual(['classic', 'light']);
// The 2004 Mac app set its type in Lucida Grande. // The 2004 Mac app set its type in Lucida Grande.
expect(await page.evaluate(() => getComputedStyle(document.body).fontFamily)).toContain('Lucida Grande'); expect(await page.evaluate(() => getComputedStyle(document.body).fontFamily)).toContain('Lucida Grande');
@@ -83,46 +79,40 @@ test('Settings picks a theme and, where it has both, light, dark or Auto', async
await expect.poll(bg).toBe('rgb(46, 52, 64)'); // nord0 await expect.poll(bg).toBe('rgb(46, 52, 64)'); // nord0
}); });
test('the theme is kept on the account, and follows it to another browser', async ({ page, browser }) => { test('each browser keeps its own theme, in a cookie, not on the account', async ({ page, browser }) => {
// Glass on a phone, Dracula on a desktop, signed in as the same person (issue #69).
let patched = false;
page.on('request', r => { if (r.url().endsWith('/api/me') && r.method() === 'PATCH') patched = true; });
await page.locator('#prefs').click(); await page.locator('#prefs').click();
const saved = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH');
await page.locator('#stheme').selectOption('flatremix'); await page.locator('#stheme').selectOption('flatremix');
expect((await saved).status()).toBe(204);
const saved2 = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH');
await page.locator('#smode').selectOption('light'); await page.locator('#smode').selectOption('light');
await saved2; const cookie = (await page.context().cookies()).find(c => c.name === 'ipx_theme');
expect(cookie?.value).toBe('flatremix.light');
expect(patched, 'nothing sent to the account').toBe(false);
// Another browser: nothing in its localStorage, and the page still arrives in the theme, // This browser: the server reads the cookie and draws the page in it from the first frame.
// written onto <html> by the server rather than set once the script has run. const res = await page.reload();
expect(await res.text()).toContain('data-theme=flatremix data-choice=light data-mode=light');
// Another browser, the same account: not this one's theme.
const other = await browser.newContext(); const other = await browser.newContext();
const p2 = await other.newPage(); const p2 = await other.newPage();
const res = await p2.goto(`/?token=${TOKEN}`); const res2 = await p2.goto(`/?token=${TOKEN}`);
expect(await res.text()).toContain('data-theme=flatremix data-choice=light data-mode=light'); expect(await res2.text()).not.toContain('data-theme=flatremix');
expect(await p2.evaluate(() => [document.documentElement.dataset.theme, document.documentElement.dataset.mode]))
.toEqual(['flatremix', 'light']);
await other.close(); await other.close();
}); });
test('a theme this browser kept before themes were on the account goes up to it once', async ({ browser }) => { test('a theme this browser kept in localStorage becomes its cookie once', async ({ browser }) => {
// A new account, made by the proxy header on first sight, so it has no theme of its own yet.
const who = `theme-${Date.now()}@example.com`; const who = `theme-${Date.now()}@example.com`;
const ctx = await browser.newContext({ extraHTTPHeaders: { 'X-Test-User': who } }); const ctx = await browser.newContext({ extraHTTPHeaders: { 'X-Test-User': who } });
// From before light and dark: ipx.theme alone, 'light' meaning Modern, light. // From before light and dark: ipx.theme alone, 'light' meaning Modern, light.
await ctx.addInitScript(() => { localStorage.setItem('ipx.theme', 'light'); localStorage.removeItem('ipx.mode'); }); await ctx.addInitScript(() => { localStorage.setItem('ipx.theme', 'light'); localStorage.removeItem('ipx.mode'); });
const page = await ctx.newPage(); const page = await ctx.newPage();
const saved = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH');
await page.goto('/'); await page.goto('/');
expect(await page.evaluate(() => [document.documentElement.dataset.theme, document.documentElement.dataset.mode])) expect(await page.evaluate(() => [document.documentElement.dataset.theme, document.documentElement.dataset.mode]))
.toEqual(['modern', 'light']); .toEqual(['modern', 'light']);
expect((await saved).request().postDataJSON()).toEqual({ theme: 'modern', mode: 'light' }); expect((await ctx.cookies()).find(c => c.name === 'ipx_theme')?.value).toBe('modern.light');
await ctx.close(); await ctx.close();
// Anywhere else now, it comes from the account.
const fresh = await browser.newContext({ extraHTTPHeaders: { 'X-Test-User': who } });
const p2 = await fresh.newPage();
const res = await p2.goto('/');
expect(await res.text()).toContain('data-theme=modern data-choice=light');
await fresh.close();
}); });
test('the admin page saves the global schedule', async ({ page }) => { test('the admin page saves the global schedule', async ({ page }) => {
@@ -470,6 +460,11 @@ test('marking an OPML subscription read covers the feeds inside it', async ({ pa
test.describe('on a phone', () => { test.describe('on a phone', () => {
test.use({ viewport: { width: 390, height: 844 } }); test.use({ viewport: { width: 390, height: 844 } });
test('the top bar leaves the logo out, for the search box', async ({ page }) => {
await expect(page.locator('#burger')).toBeVisible();
await expect(page.locator('#applogo')).toBeHidden();
});
test('the feed list is reachable and an item reads full screen', async ({ page }) => { test('the feed list is reachable and an item reads full screen', async ({ page }) => {
// The burger used to live in the player bar, which is hidden until something plays -- // The burger used to live in the player bar, which is hidden until something plays --
// leaving no way to reach the feeds at all. // leaving no way to reach the feeds at all.
@@ -559,9 +554,11 @@ test('a second person has their own feeds and their own read state', async ({ br
const page = await ctx.newPage(); const page = await ctx.newPage();
await page.goto('/login'); await page.goto('/login');
// The sign-in page shows the logo, so it has to load before anyone has signed in. // The sign-in page shows the logo, so it has to load before anyone has signed in.
const logo = await page.request.get('/logo.svg'); for (const path of ['/logo.svg', '/logo-dark.svg']) {
expect(logo.status()).toBe(200); const logo = await page.request.get(path);
expect(logo.headers()['content-type']).toBe('image/svg+xml'); expect(logo.status(), path).toBe(200);
expect(logo.headers()['content-type'], path).toBe('image/svg+xml');
}
await page.locator('#name').fill('sam'); await page.locator('#name').fill('sam');
await page.locator('#pw').fill('sampassword'); await page.locator('#pw').fill('sampassword');
await page.locator('button[type=submit]').click(); await page.locator('button[type=submit]').click();
@@ -930,6 +927,66 @@ test('adding a feed scans it straight away', async ({ page }) => {
await expect(page.locator('.ep', { hasText: 'Fresh Ep' })).toBeVisible({ timeout: 10_000 }); await expect(page.locator('.ep', { hasText: 'Fresh Ep' })).toBeVisible({ timeout: 10_000 });
}); });
test('adding a page adds the feed it links, and a page with no feed is refused', async ({ page }) => {
const feeds = await page.locator('.feed').count();
await page.locator('#addFeed').click();
await page.locator('#nurl').fill('http://127.0.0.1:8792/nofeed.html');
await page.locator('#nsave').click();
await expect(page.locator('.toast')).toContainText('links no feed');
await expect(page.locator('#modal.on')).toBeVisible(); // left open to correct it
await expect(page.locator('.feed')).toHaveCount(feeds);
await page.locator('#nurl').fill('http://127.0.0.1:8792/site.html');
await page.locator('#nsave').click();
await expect(page.locator('.feed', { hasText: 'Linked Site' })).toBeVisible({ timeout: 20_000 });
});
test('reading an item updates its feed\'s count without reloading the list', async ({ page }) => {
const unread = page.locator('.feed:not(.group)', { has: page.locator('.badge:not(.zero)') }).first();
await expect(unread).toBeVisible({ timeout: 20_000 });
const id = await unread.getAttribute('data-id');
const badge = page.locator(`.feed[data-id="${id}"] .badge`);
const before = Number(await badge.textContent());
await unread.click();
await page.locator('.tabs button', { hasText: 'Unread' }).click();
await expect(page.locator('.ep').first()).toBeVisible();
const lists = [];
page.on('request', r => { if (new URL(r.url()).pathname === '/api/feeds') lists.push(r.url()); });
await page.locator('.ep').first().click(); // opening an item reads it
await expect(badge).toHaveText(String(before - 1));
expect(lists, 'the row came back with the read, not by reloading the list').toEqual([]);
});
test('artwork comes from iPX, kept, and only as an image', async ({ page }) => {
// Every image a feed names is drawn from iPX's address.
expect(await page.evaluate(() => artHTML('https://example.com/a.jpg', 'A'))).toContain('src="/api/art?u=https%3A%2F%2Fexample.com%2Fa.jpg"');
// Multi Show's items name art.jpg.
await expect(page.locator('.feed', { hasText: 'Multi Show' })).toBeVisible({ timeout: 20_000 });
const src = '/api/art?u=' + encodeURIComponent('http://127.0.0.1:8792/art.jpg');
for (const _ of [1, 2]) { // fetched, then kept
const r = await page.request.get(src);
expect(r.status()).toBe(200);
expect(r.headers()['content-type']).toMatch(/^image\//);
expect(r.headers()['content-security-policy']).toContain('sandbox');
}
// An address no feed names is not fetched.
expect((await page.request.get('/api/art?u=' + encodeURIComponent('http://127.0.0.1:8792/show.xml'))).status()).toBe(404);
});
test('an item without a title is named from its text, then its file, then its show and date', async ({ page }) => {
const names = await page.evaluate(() => [
entryName({ title: 'A Title' }),
entryName({ title: ' ', description: '<p>I like the way <b>AI</b> is evolving.</p><img src="x.png">' }),
entryName({ description: '<p>' + 'word '.repeat(40) + '</p>' }).text.length,
entryName({ enclosures: [{ url: 'https://k.example/media/KARTAS691.mp3?x=1' }] }),
entryName({ feed_id: 'nobody', published: 0 }),
]);
expect(names[0]).toEqual({ text: 'A Title', derived: false });
expect(names[1]).toEqual({ text: 'I like the way AI is evolving.', derived: true });
expect(names[2]).toBeLessThanOrEqual(121); // cut at a word, with …
expect(names[3]).toEqual({ text: 'KARTAS691', derived: true });
expect(names[4]).toEqual({ text: 'nobody', derived: true });
});
test('a deleted file looks as if it was never downloaded', async ({ page }) => { test('a deleted file looks as if it was never downloaded', async ({ page }) => {
// Other people subscribe to Picture Blog by now, so both prompts come; take them. // Other people subscribe to Picture Blog by now, so both prompts come; take them.
page.on('dialog', d => d.accept()); page.on('dialog', d => d.accept());
@@ -1178,32 +1235,62 @@ test('a file not yet downloaded has its icon in line with the rest of its row',
for (const o of offsets) expect(Math.abs(o)).toBeLessThanOrEqual(1); for (const o of offsets) expect(Math.abs(o)).toBeLessThanOrEqual(1);
}); });
test('the logo is the dark one in dark mode and the light one in light mode', async ({ page }) => {
const shown = () => page.locator('#applogo img:visible').getAttribute('src');
await page.evaluate(() => setTheme('modern', 'dark'));
expect(await shown()).toMatch(/^\/logo-dark\.svg\?v=/);
await page.evaluate(() => setTheme('modern', 'light'));
expect(await shown()).toMatch(/^\/logo\.svg\?v=/);
// Paper has only a light palette, so the light logo whatever the mode asked.
await page.evaluate(() => setTheme('paper', 'dark'));
expect(await shown()).toMatch(/^\/logo\.svg\?v=/);
await page.evaluate(() => setTheme('modern', 'dark'));
});
test('/api/status gives a dashboard the counts, with the shared token as a cookie', async ({ page }) => {
// As Homepage's customapi widget asks: no session, only the token in a Cookie header.
const r = await page.request.get('/api/status', { headers: { cookie: `ipx_token=${TOKEN}` } });
expect(r.status()).toBe(200);
const s = await r.json();
for (const k of ['feeds', 'pending', 'downloaded']) expect(typeof s[k], k).toBe('number');
expect(s.version).toMatch(/^\d+\.\d+\.\d+/);
const none = await page.request.get('/api/status', { headers: { cookie: '' } });
expect(none.status()).toBe(401);
});
test('the favicon is the logo, square, from both pages', async ({ page }) => { test('the favicon is the logo, square, from both pages', async ({ page }) => {
await expect(page.locator('link[rel="icon"]')).toHaveAttribute('href', /^\/favicon\.png\?v=[0-9a-f]{12}$/); await page.evaluate(() => setTheme('modern', 'light'));
await expect(page.locator('#favicon')).toHaveAttribute('href', /^\/favicon\.png\?v=[0-9a-f]{12}$/);
await page.evaluate(() => setTheme('modern', 'dark'));
await expect(page.locator('#favicon')).toHaveAttribute('href', /^\/favicon-dark\.png\?v=[0-9a-f]{12}$/);
// A browser asks for /favicon.ico on its own, signed in or not. // A browser asks for /favicon.ico on its own, signed in or not.
for (const path of ['/favicon.ico', '/favicon.png', '/apple-touch-icon.png']) { for (const path of ['/favicon.ico', '/favicon.png', '/favicon-dark.png', '/apple-touch-icon.png', '/apple-touch-icon-precomposed.png']) {
const r = await page.request.get(path, { headers: { cookie: '' } }); const r = await page.request.get(path, { headers: { cookie: '' } });
expect(r.status(), path).toBe(200); expect(r.status(), path).toBe(200);
expect(r.headers()['content-type'], path).toBe('image/png'); expect(r.headers()['content-type'], path).toBe('image/png');
} }
}); });
test('a feed error is marked in the same column as the folder triangles', async ({ page }) => { test('a failing feed is marked on its artwork and says why', async ({ page }) => {
await expect(page.locator('.feed.group .chev').first()).toBeVisible(); await expect(page.locator('.feed.group .chev').first()).toBeVisible();
if ((await page.locator('.feed.group .chev').first().getAttribute('aria-expanded')) !== 'true') if ((await page.locator('.feed.group .chev').first().getAttribute('aria-expanded')) !== 'true')
await page.locator('.feed.group .chev').first().click(); await page.locator('.feed.group .chev').first().click();
// Faked in the page: no fixture feed fails. A feed on its own, and one inside a folder. // Faked in the page: no fixture feed fails. A feed on its own failing for a day, and one
await page.evaluate(() => { // inside a folder that failed its last check.
const solo = await page.evaluate(() => {
S.feeds.find(f => f.group).last_error = 'HTTP 404'; S.feeds.find(f => f.group).last_error = 'HTTP 404';
S.feeds.find(f => !f.group && !S.feeds.some(c => c.group === f.id)).last_error = 'timed out'; const f = S.feeds.find(f => !f.group && !S.feeds.some(c => c.group === f.id));
f.last_error = 'HTTP 404 Not Found';
f.failing = { reason: 'The publisher took this feed down, or moved it.' };
renderFeeds(); renderFeeds();
return f.id;
}); });
await expect(page.locator('.ferr')).toHaveCount(2); await expect(page.locator('.fart .ferr svg')).toHaveCount(3); // both feeds and the folder
await expect(page.locator('.ferr svg')).toHaveCount(2); // the icon, not a "!"
await expect(page.locator('.chev.bad')).toHaveCount(1); // the folder holding one await expect(page.locator('.chev.bad')).toHaveCount(1); // the folder holding one
const xs = await page.$$eval('.chev, .ferr', els => const row = page.locator(`.feed[data-id="${solo}"]`);
els.map(e => { const r = e.getBoundingClientRect(); return Math.round(r.left + r.width / 2); })); await expect(row).toHaveClass(/failing/);
expect(new Set(xs).size, JSON.stringify(xs)).toBe(1); await expect(row.locator('small')).toHaveText('The publisher took this feed down, or moved it.');
await expect(page.locator('.feed.group.err small').first()).toHaveText('1 feed not updating');
await page.reload(); // put the real list back await page.reload(); // put the real list back
}); });
@@ -1255,6 +1342,34 @@ test.describe('touch gestures on a phone', () => {
await expect(page.locator('body')).not.toHaveClass(/reading/); await expect(page.locator('body')).not.toHaveClass(/reading/);
}); });
test('on the Unread tab, a swipe back goes to the item just read', async ({ page }) => {
await page.locator('#burger').click();
await page.locator('.feed', { hasText: 'Test Show' }).click();
await page.locator('.tabs button', { hasText: 'All' }).first().click();
await expect(page.locator('.ep').nth(1)).toBeVisible({ timeout: 20_000 });
// Earlier tests read these; both have to be unread to be on the Unread tab.
await page.evaluate(() => Promise.all(S.entries.map(e => setRead(e, false))));
await page.locator('.tabs button', { hasText: 'Unread' }).first().click();
await expect(page.locator('.ep').nth(1)).toBeVisible({ timeout: 20_000 });
const titles = await page.locator('.ep .t').allTextContents();
await page.locator('.ep').first().click();
const shown = page.locator('#detail .dt');
await expect(shown).toHaveText(titles[0]);
await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left: the next item
await expect(shown).toHaveText(titles[1]);
// The first is read now, and used to be gone from the list already, so this went back to
// the list instead.
await drag(page, { x: 80, y: 400 }, { x: 300, y: 410 });
await expect(shown).toHaveText(titles[0]);
await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 });
await expect(shown).toHaveText(titles[1]);
// Out of the reader, the ones read on the way leave the Unread tab as before.
await page.locator('#dback').click();
await expect(page.locator('body')).not.toHaveClass(/reading/);
await expect(page.locator('.ep .t', { hasText: titles[0] })).toHaveCount(0);
});
test('pulling the list down from its top checks the feed for new items', async ({ page }) => { test('pulling the list down from its top checks the feed for new items', async ({ page }) => {
await page.locator('#burger').click(); await page.locator('#burger').click();
await page.locator('.feed', { hasText: 'Test Show' }).click(); await page.locator('.feed', { hasText: 'Test Show' }).click();
@@ -1264,6 +1379,15 @@ test.describe('touch gestures on a phone', () => {
await drag(page, { x: 200, y: box.y + 20 }, { x: 200, y: box.y + 220 }); await drag(page, { x: 200, y: box.y + 20 }, { x: 200, y: box.y + 220 });
expect((await fetch).postDataJSON()).toEqual({ feed: 'test-show', force: true }); expect((await fetch).postDataJSON()).toEqual({ feed: 'test-show', force: true });
await expect(page.locator('#pulltip')).toHaveCount(0); // the note goes on letting go await expect(page.locator('#pulltip')).toHaveCount(0); // the note goes on letting go
// and a spinner says the check started, for a couple of seconds.
await expect(page.locator('#pullspin')).toBeVisible();
// A second pull while it is up checks nothing more.
let again = 0;
page.on('request', r => { if (r.url().endsWith('/api/fetch')) again++; });
await drag(page, { x: 200, y: box.y + 20 }, { x: 200, y: box.y + 220 });
await expect(page.locator('#pullspin')).toHaveCount(1);
await expect(page.locator('#pullspin')).toHaveCount(0, { timeout: 5_000 });
expect(again).toBe(0);
}); });
}); });
@@ -1383,3 +1507,20 @@ test('Share hands the feed, the item and the file to the share sheet', async ({
{ title: 'Second Episode', url: expect.stringContaining('/ep1.mp3?2') }, { title: 'Second Episode', url: expect.stringContaining('/ep1.mp3?2') },
]); ]);
}); });
test('a refresh button turns while what it checks is being checked', async ({ page }) => {
await page.locator('.feed', { hasText: 'Test Show' }).click();
const spin = sel => page.locator(sel).evaluate(el => getComputedStyle(el).animationName);
const btn = '.fhead [data-a=scan] .i';
await expect(page.locator(btn)).toBeVisible();
expect(await spin(btn)).toBe('none');
await page.evaluate(() => setScanning('test-show', true));
expect(await spin(btn)).toBe('ipxspin');
expect(await spin('#scanAll .i')).toBe('ipxspin');
// Another feed being checked turns the toolbar's, not this page's.
await page.evaluate(() => { setScanning('test-show', false); setScanning('multi-show', true); });
expect(await spin(btn)).toBe('none');
expect(await spin('#scanAll .i')).toBe('ipxspin');
await page.evaluate(() => setScanning('multi-show', false));
expect(await spin('#scanAll .i')).toBe('none');
});

View File

@@ -0,0 +1,4 @@
<?xml version="1.0"?>
<rss version="2.0"><channel><title>Linked Site</title><link>http://127.0.0.1:8792/site.html</link>
<item><title>Linked Post</title><guid>linked-1</guid></item>
</channel></rss>

View File

@@ -0,0 +1,2 @@
<!doctype html>
<html><head><title>No Feed Here</title></head><body>A site that links no feed.</body></html>

View File

@@ -12,7 +12,10 @@ http.createServer((req, res) => {
fs.readFile(file, (err, body) => { fs.readFile(file, (err, body) => {
if (err) { res.writeHead(404).end('no'); return; } if (err) { res.writeHead(404).end('no'); return; }
const type = file.endsWith('.mp3') ? 'audio/mpeg' const type = file.endsWith('.mp3') ? 'audio/mpeg'
: file.endsWith('.opml') ? 'text/x-opml' : 'application/xml'; : file.endsWith('.opml') ? 'text/x-opml'
// Artwork as an image, or /api/art refuses it as not one.
: file.endsWith('.jpg') ? 'image/jpeg'
: file.endsWith('.html') ? 'text/html' : 'application/xml';
res.writeHead(200, { 'content-type': type, 'content-length': body.length }); res.writeHead(200, { 'content-type': type, 'content-length': body.length });
res.end(body); res.end(body);
}); });

View File

@@ -0,0 +1,4 @@
<!doctype html>
<html><head><title>A Site</title>
<link rel="alternate" type="application/rss+xml" href="/linked.xml">
</head><body>A site with a feed.</body></html>

View File

@@ -5,7 +5,7 @@
<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">
<title>iPX admin</title> <title>iPX admin</title>
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.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">
<link rel="stylesheet" data-src="app.css"> <link rel="stylesheet" data-src="app.css">
</head> </head>
@@ -13,7 +13,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" src="/logo.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">
<h1>Admin</h1> <h1>Admin</h1>
<span class="grow"></span> <span class="grow"></span>
<nav class="tabs" id="atabs"> <nav class="tabs" id="atabs">

View File

@@ -2,44 +2,44 @@
anyone else. One variable file covers every weight used here; italics are synthesized. Classic anyone else. One variable file covers every weight used here; italics are synthesized. Classic
keeps Lucida Grande, the 2004 app's face. */ keeps Lucida Grande, the 2004 app's face. */
@font-face{font-family:Inter;src:url(/inter.woff2) format("woff2");font-weight:100 900;font-display:swap} @font-face{font-family:Inter;src:url(/inter.woff2) format("woff2");font-weight:100 900;font-display:swap}
/* Palette taken from the 2004 iPodderX icon: the silver device body, the blue /* Palette taken from the logo (web/logo.svg, logo-dark.svg): its navy ground, the tuning scale's
screen, and the amber EQ bars. Hex values in comments are sampled straight from it. */ blue and the orange needle. The dark half follows logo-dark.svg. */
:root { :root {
--bg:#0e131b; /* the screen's navy (#314B74), taken right down */ --bg:#0a1726; /* the dark logo's navy (#0c2238), taken down */
--panel:#151c27; --panel:#0f1f31;
--panel2:#1c2431; --panel2:#15283d;
--raise:#25303f; --raise:#1d3450;
--line:#2c3849; --line:#264060;
--fg:#f5f5f5; /* #F5F5F5 device highlight */ --fg:#f3f7fb;
--dim:#95a0b1; /* #95A0B1 straight from the icon's blue-grey */ --dim:#a3b6ca;
--faint:#7e8b9c; /* lifted from the icon ramp until it clears AA at small sizes */ --faint:#8a9fb5;
--accent:#92b2e6; /* #92B2E6 the screen blue */ --accent:#8fc2ea; /* #8FC2EA the dark logo's scale */
--accent2:#f49e2c; /* #F49E2C the EQ bars */ --accent2:#ff6a1a; /* #FF6A1A the dark logo's needle */
--ink:#0e131b; /* text on an accent fill */ --ink:#0a1726; /* text on an accent fill */
--good:#6fbf8b; --good:#6fbf8b;
--warn:#f49e2c; /* the amber doubles as the pending colour */ --warn:#f5a524; /* amber, apart from the needle's orange, for pending */
--bad:#e2705f; --bad:#ff6b7a; /* pushed towards pink, apart from the needle */
--shadow:0 8px 28px rgba(6,10,16,.55); --shadow:0 8px 28px rgba(4,10,20,.55);
} }
/* Every other theme overrides the same variables, under data-theme and data-mode. The page's /* Every other theme overrides the same variables, under data-theme and data-mode. The page's
script sets data-mode to light or dark, working Auto out from the system, so each theme has script sets data-mode to light or dark, working Auto out from the system, so each theme has
one block per variant here and none needs repeating under a media query. */ one block per variant here and none needs repeating under a media query. */
:root[data-theme="modern"][data-mode="light"] { :root[data-theme="modern"][data-mode="light"] {
--bg:#f2f4f7; --bg:#eef5fb; /* the light logo's sky (#DFF0FB), paler */
--panel:#ffffff; /* #FFFFFF device body */ --panel:#ffffff;
--panel2:#e9edf3; --panel2:#e4eff9;
--raise:#dde3ec; --raise:#d5e6f5;
--line:#d6d6d6; /* #D6D6D6 device edge */ --line:#c6d9ea;
--fg:#1a1a1a; /* #1A1A1A icon outline */ --fg:#10233a;
--dim:#606060; /* #606060 */ --dim:#46596e;
--faint:#6b6b6b; /* between the icon's #929292 and #606060, to clear AA on the page ground */ --faint:#56697e;
--accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */ --accent:#2f6aa0; /* #2F6AA0 the logo's scale */
--accent2:#985e0a; /* the EQ amber, taken down until white on it clears AA */ --accent2:#c43e00; /* the needle (#FF5500), taken down until white on it clears AA */
--ink:#ffffff; --ink:#ffffff;
--good:#2f7d4f; --good:#2f7d4f;
--warn:#985e0a; /* the same amber; the lighter one failed AA as text */ --warn:#985e0a; /* amber, apart from the needle's orange; lighter failed AA as text */
--bad:#b3402f; --bad:#b3263a; /* pushed towards crimson, apart from the needle */
--shadow:0 8px 28px rgba(45,83,145,.14); --shadow:0 8px 28px rgba(47,106,160,.14);
} }
/* Dracula, and Alucard, its light half, from draculatheme.com/spec. The spec's comment colour /* Dracula, and Alucard, its light half, from draculatheme.com/spec. The spec's comment colour
(#6272A4, #6C664B) is too faint for small text on its own background, so --faint is lifted. */ (#6272A4, #6C664B) is too faint for small text on its own background, so --faint is lifted. */
@@ -493,11 +493,11 @@ a{color:var(--accent)}
background:var(--panel);border-right:1px solid var(--line); background:var(--panel);border-right:1px solid var(--line);
display:flex;flex-direction:column;min-height:0; display:flex;flex-direction:column;min-height:0;
} }
.brand{display:flex;align-items:center;gap:9px;padding:14px 14px 10px} #applogo{display:flex;flex:none}
.brand .logo{ #applogo img{width:28px;height:28px;border-radius:7px}
width:30px;height:30px;flex:none;border-radius:7px; /* 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. */
.brand h1{font-size:16px;margin:0;font-weight:650;letter-spacing:-.01em;color:var(--fg);flex:1} :root[data-mode="light"] .logo.ondark,:root:not([data-mode="light"]) .logo.onlight{display:none}
.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;
@@ -548,7 +548,7 @@ a{color:var(--accent)}
.who button:hover{color:var(--fg)} .who button:hover{color:var(--fg)}
.sidefoot button{display:inline-flex;align-items:center;justify-content:center;gap:6px} .sidefoot button{display:inline-flex;align-items:center;justify-content:center;gap:6px}
.sidefoot i{font-style:normal;opacity:.75} .sidefoot i{font-style:normal;opacity:.75}
.searchwrap{padding:0 12px 8px} .searchwrap{padding:12px 12px 8px}
input[type=search],input[type=text],input[type=password],input[type=number],select{ input[type=search],input[type=text],input[type=password],input[type=number],select{
width:100%;background:var(--bg);border:1px solid var(--line);color:var(--fg); width:100%;background:var(--bg);border:1px solid var(--line);color:var(--fg);
border-radius:8px;padding:7px 10px;font:inherit;font-size:13.5px; border-radius:8px;padding:7px 10px;font:inherit;font-size:13.5px;
@@ -566,7 +566,14 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
/* A show sits under its folder's title, a size down, so an open folder reads as one. */ /* A show sits under its folder's title, a size down, so an open folder reads as one. */
.feed.child{margin-left:11px} .feed.child{margin-left:11px}
/* Pinned feeds, at the top of the list: a small pin before the name, and a rule under the last. */ /* Pinned feeds, at the top of the list: a small pin before the name, and a rule under the last. */
.feed .fpin .i{width:10px;height:10px;margin-right:5px;vertical-align:-1px;color:var(--accent)} /* Pinned: a disc on the artwork's corner, as a failing feed's mark is, in the accent and the ink
the theme pairs with it. A feed both pinned and failing keeps the error below, the pin above. */
.fpin{
position:absolute;right:-5px;bottom:-5px;width:17px;height:17px;border-radius:50%;
display:grid;place-items:center;background:var(--accent);color:var(--ink);border:2px solid var(--bg);
}
.fpin .i{width:9px;height:9px}
.feed.err .fpin{bottom:auto;top:-5px}
.feed.lastpin{margin-bottom:9px} .feed.lastpin{margin-bottom:9px}
.feed.lastpin::after{content:"";position:absolute;left:8px;right:8px;bottom:-5px;border-bottom:1px solid var(--line)} .feed.lastpin::after{content:"";position:absolute;left:8px;right:8px;bottom:-5px;border-bottom:1px solid var(--line)}
.feed.child .art{width:28px;height:28px;font-size:11px} .feed.child .art{width:28px;height:28px;font-size:11px}
@@ -586,11 +593,20 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
border:2px solid var(--line);border-top-color:var(--accent);animation:ipxspin .8s linear infinite; border:2px solid var(--line);border-top-color:var(--accent);animation:ipxspin .8s linear infinite;
} }
@keyframes ipxspin{to{transform:rotate(360deg)}} @keyframes ipxspin{to{transform:rotate(360deg)}}
/* A feed's error mark, in the triangle's place: the same column as every folder's triangle, body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,body.scan-any #scanAll .i{
a child's included, which is why it moves left by the child's indent. */ animation:ipxspin 1s linear infinite}
.ferr{position:absolute;left:-16px;top:0;bottom:0;width:24px;display:grid;place-items:center;color:var(--bad)} /* A feed's error mark: a solid disc on the artwork's corner, ringed in the page ground so it
.ferr .i{width:12px;height:12px} stands off any cover. The glyph is cut out of it in --bg, which is the dark against the light
.feed.child .ferr{left:-27px} --bad of a dark theme and the light against the deep one of a light theme. */
.fart{position:relative;flex:none;display:grid}
.ferr{
position:absolute;right:-5px;bottom:-5px;width:17px;height:17px;border-radius:50%;
display:grid;place-items:center;background:var(--bad);color:var(--bg);border:2px solid var(--bg);
}
.ferr .i{width:9px;height:9px}
.feed.err .txt small{color:var(--bad)}
/* Failing for a day or more: the cover goes grey, a show gone off the air. */
.feed.failing .fart>.art{filter:grayscale(1);opacity:.45}
.chev .i{width:12px;height:12px;transition:transform .12s} .chev .i{width:12px;height:12px;transition:transform .12s}
.chev[aria-expanded="true"] .i{transform:rotate(90deg)} .chev[aria-expanded="true"] .i{transform:rotate(90deg)}
.childlist{display:grid;grid-template-columns:minmax(0,1fr);gap:4px;margin-top:10px} .childlist{display:grid;grid-template-columns:minmax(0,1fr);gap:4px;margin-top:10px}
@@ -689,6 +705,12 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
reloads the page, from starting on top of it. */ reloads the page, from starting on top of it. */
#pulltip{display:flex;align-items:flex-end;justify-content:center;padding-bottom:6px;overflow:hidden; #pulltip{display:flex;align-items:flex-end;justify-content:center;padding-bottom:6px;overflow:hidden;
font-size:12px;color:var(--faint)} font-size:12px;color:var(--faint)}
/* What a pull started, for a couple of seconds after letting go, under the top bar. */
#pullspin{position:fixed;left:50%;top:calc(58px + var(--safe-t));transform:translateX(-50%);z-index:55;
display:flex;align-items:center;gap:8px;padding:6px 12px;border-radius:999px;font-size:12px;
color:var(--dim);background:var(--panel);border:1px solid var(--line);box-shadow:var(--shadow)}
#pullspin::before{content:"";width:12px;height:12px;border-radius:50%;
border:2px solid var(--line);border-top-color:var(--accent);animation:ipxspin .8s linear infinite}
/* The selected item's files, beside the list, as the original's Files pane was. */ /* The selected item's files, beside the list, as the original's Files pane was. */
#files{grid-area:files;overflow-y:auto;padding:8px 12px;border-left:1px solid var(--line);background:var(--panel)} #files{grid-area:files;overflow-y:auto;padding:8px 12px;border-left:1px solid var(--line);background:var(--panel)}
/* Nothing selected, or an item with no files: the list takes the width. */ /* Nothing selected, or an item with no files: the list takes the width. */
@@ -721,9 +743,7 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
.acts{display:flex;gap:7px;flex-wrap:wrap;align-items:center;margin-top:auto} .acts{display:flex;gap:7px;flex-wrap:wrap;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.primary i{opacity:.9}
.acts .btn:hover{background:var(--raise)} .acts .btn:hover{background:var(--raise)}
.acts .btn.primary:hover{background:var(--accent)}
.btn{ .btn{
background:var(--panel2);border:1px solid var(--line);border-radius:8px; background:var(--panel2);border:1px solid var(--line);border-radius:8px;
padding:6px 12px;font-size:13px; padding:6px 12px;font-size:13px;
@@ -824,6 +844,8 @@ body.playing .eq i:nth-child(3){animation-delay:-.6s}
@keyframes eq{from{transform:scaleY(.35)}} @keyframes eq{from{transform:scaleY(.35)}}
.ep .st:hover,.ep .fl:hover{background:var(--raise)} .ep .st:hover,.ep .fl:hover{background:var(--raise)}
.ep .t{font-weight:600;font-size:13.5px;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .ep .t{font-weight:600;font-size:13.5px;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* An item with no title shows its opening words: set as text, not as a heading. */
.ep .t.notitle{font-weight:400}
.ep.read .t{color:var(--dim);font-weight:500} .ep.read .t{color:var(--dim);font-weight:500}
.ep .line{display:flex;gap:9px;align-items:center;flex-wrap:wrap;color:var(--faint);font-size:11.5px} .ep .line{display:flex;gap:9px;align-items:center;flex-wrap:wrap;color:var(--faint);font-size:11.5px}
.ep .line:empty{display:none} .ep .line:empty{display:none}
@@ -978,6 +1000,8 @@ body:has(#player.on) #status{padding-bottom:3px}
/* The feed list is reachable whether or not anything is playing. */ /* The feed list is reachable whether or not anything is playing. */
#burger{display:grid} #burger{display:grid}
/* No logo on a phone: the bar has no room to spare for it, and no hover to show its version. */
#applogo{display:none}
#topbar{ #topbar{
gap:6px;padding-top:calc(6px + var(--safe-t));padding-bottom:6px; gap:6px;padding-top:calc(6px + var(--safe-t));padding-bottom:6px;
padding-left:calc(8px + var(--safe-l));padding-right:calc(8px + var(--safe-r)); padding-left:calc(8px + var(--safe-l));padding-right:calc(8px + var(--safe-r));

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@@ -75,7 +75,7 @@ export function buildPage(name, { minify = true } = {}) {
// The icons are named by their contents too. They are kept a day under a fixed name, and a new // The icons are named by their contents too. They are kept a day under a fixed name, and a new
// logo went unseen for that day, in browsers and at Cloudflare's edge. The server ignores the // logo went unseen for that day, in browsers and at Cloudflare's edge. The server ignores the
// query; /favicon.ico, which a browser asks for on its own, cannot carry one. // query; /favicon.ico, which a browser asks for on its own, cannot carry one.
out = out.replace(/(href|src)="\/(favicon\.png|apple-touch-icon\.png|logo\.svg)"/g, out = out.replace(/(href|src|srcset|data-light|data-dark)="\/(favicon\.png|favicon-dark\.png|apple-touch-icon\.png|logo\.svg|logo-dark\.svg)"/g,
(_, attr, file) => `${attr}="/${file}?v=${hash(fs.readFileSync(path.join(here, file)))}"`); (_, attr, file) => `${attr}="/${file}?v=${hash(fs.readFileSync(path.join(here, file)))}"`);
if (!minify) return { html: out, js, script }; if (!minify) return { html: out, js, script };
const r = html.minifySync(out, { minifyJs: false, minifyCss: true, removeComments: true }); const r = html.minifySync(out, { minifyJs: false, minifyCss: true, removeComments: true });

BIN
web/favicon-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -5,7 +5,7 @@
<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">
<title>iPX</title> <title>iPX</title>
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.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">
<link rel="stylesheet" data-src="app.css"> <link rel="stylesheet" data-src="app.css">
</head> </head>
@@ -14,6 +14,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>
<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>
@@ -33,9 +34,6 @@
</header> </header>
<div id="shell"> <div id="shell">
<aside id="sidebar"> <aside id="sidebar">
<div class="brand">
<img class="logo" src="/logo.svg" alt="iPX"><h1>iPX</h1>
</div>
<div class="searchwrap"><input type="search" id="feedFilter" placeholder="Filter feeds…"></div> <div class="searchwrap"><input type="search" id="feedFilter" placeholder="Filter feeds…"></div>
<div id="feedlist"></div> <div id="feedlist"></div>
<div class="sidefoot"> <div class="sidefoot">

View File

@@ -4,46 +4,48 @@
<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">
<title>Sign in — iPX</title> <title>Sign in — iPX</title>
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png"> <link rel="icon" type="image/png" sizes="128x128" href="/favicon.png" media="(prefers-color-scheme:light)">
<link rel="icon" type="image/png" sizes="128x128" href="/favicon-dark.png" media="(prefers-color-scheme:dark)">
<link rel="apple-touch-icon" href="/apple-touch-icon.png"> <link rel="apple-touch-icon" href="/apple-touch-icon.png">
<style> <style>
/* Inter, from ipx itself; see the same rule in index.html. */ /* Inter, from ipx itself; see the same rule in index.html. */
@font-face{font-family:Inter;src:url(/inter.woff2) format("woff2");font-weight:100 900;font-display:swap} @font-face{font-family:Inter;src:url(/inter.woff2) format("woff2");font-weight:100 900;font-display:swap}
:root { :root {
--bg:#0e131b; /* the screen's navy (#314B74), taken right down */ --bg:#0a1726; /* the dark logo's navy (#0c2238), taken down */
--panel:#151c27; --panel:#0f1f31;
--panel2:#1c2431; --panel2:#15283d;
--raise:#25303f; --raise:#1d3450;
--line:#2c3849; --line:#264060;
--fg:#f5f5f5; /* #F5F5F5 device highlight */ --fg:#f3f7fb;
--dim:#95a0b1; /* #95A0B1 straight from the icon's blue-grey */ --dim:#a3b6ca;
--faint:#7e8b9c; /* lifted from the icon ramp until it clears AA at small sizes */ --faint:#8a9fb5;
--accent:#92b2e6; /* #92B2E6 the screen blue */ --accent:#8fc2ea; /* #8FC2EA the dark logo's scale */
--accent2:#f49e2c; /* #F49E2C the EQ bars */ --accent2:#ff6a1a; /* #FF6A1A the dark logo's needle */
--ink:#0e131b; /* text on an accent fill */ --ink:#0a1726; /* text on an accent fill */
--good:#6fbf8b; --good:#6fbf8b;
--warn:#f49e2c; /* the amber doubles as the pending colour */ --warn:#f5a524; /* amber, apart from the needle's orange, for pending */
--bad:#e2705f; --bad:#ff6b7a; /* pushed towards pink, apart from the needle */
--shadow:0 8px 28px rgba(6,10,16,.55); --shadow:0 8px 28px rgba(4,10,20,.55);
--r:10px; --r:10px;
} }
/* Signed out, there is no account to take a theme from, so this follows the system. */ /* Modern, as app.css has it. Signed out, there is no account to take a theme from, so this follows
the system. */
@media (prefers-color-scheme:light){:root { @media (prefers-color-scheme:light){:root {
--bg:#f2f4f7; --bg:#eef5fb; /* the light logo's sky (#DFF0FB), paler */
--panel:#ffffff; /* #FFFFFF device body */ --panel:#ffffff;
--panel2:#e9edf3; --panel2:#e4eff9;
--raise:#dde3ec; --raise:#d5e6f5;
--line:#d6d6d6; /* #D6D6D6 device edge */ --line:#c6d9ea;
--fg:#1a1a1a; /* #1A1A1A icon outline */ --fg:#10233a;
--dim:#606060; /* #606060 */ --dim:#46596e;
--faint:#6b6b6b; --faint:#56697e;
--accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */ --accent:#2f6aa0; /* #2F6AA0 the logo's scale */
--accent2:#985e0a; --accent2:#c43e00; /* the needle (#FF5500), taken down until white on it clears AA */
--ink:#ffffff; --ink:#ffffff;
--good:#2f7d4f; --good:#2f7d4f;
--warn:#985e0a; --warn:#985e0a; /* amber, apart from the needle's orange; lighter failed AA as text */
--bad:#b3402f; --bad:#b3263a; /* pushed towards crimson, apart from the needle */
--shadow:0 8px 28px rgba(45,83,145,.14); --shadow:0 8px 28px rgba(47,106,160,.14);
}} }}
*{box-sizing:border-box} *{box-sizing:border-box}
html,body{height:100%} html,body{height:100%}
@@ -74,7 +76,7 @@ button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
</style> </style>
<form id="f"> <form id="f">
<div class="brand"><img src="/logo.svg" alt=""><h1>iPX</h1></div> <div class="brand"><picture><source media="(prefers-color-scheme:light)" srcset="/logo.svg"><img src="/logo-dark.svg" alt=""></picture><h1>iPX</h1></div>
<label for="name">Name</label> <label for="name">Name</label>
<input id="name" name="name" autocomplete="username" autofocus required> <input id="name" name="name" autocomplete="username" autofocus required>
<label for="pw">Password</label> <label for="pw">Password</label>

View File

@@ -15,5 +15,5 @@
<rect x="624" y="280" width="112" height="544" rx="56" opacity=".85"/> <rect x="624" y="280" width="112" height="544" rx="56" opacity=".85"/>
<rect x="792" y="600" width="112" height="224" rx="56" opacity=".5"/> <rect x="792" y="600" width="112" height="224" rx="56" opacity=".5"/>
</g> </g>
<g id="needle"><rect x="728" y="160" width="72" height="704" rx="36" fill="#ff9f2e"/></g> <g id="needle"><rect x="728" y="160" width="72" height="704" rx="36" fill="#ff6a1a"/></g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1003 B

After

Width:  |  Height:  |  Size: 1003 B

View File

@@ -18,5 +18,5 @@
<rect x="624" y="280" width="112" height="544" rx="56" opacity=".85"/> <rect x="624" y="280" width="112" height="544" rx="56" opacity=".85"/>
<rect x="792" y="600" width="112" height="224" rx="56" opacity=".5"/> <rect x="792" y="600" width="112" height="224" rx="56" opacity=".5"/>
</g> </g>
<g id="needle"><rect x="728" y="160" width="72" height="704" rx="36" fill="#f7931e"/></g> <g id="needle"><rect x="728" y="160" width="72" height="704" rx="36" fill="#ff5500"/></g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -34,11 +34,11 @@ async function drawServer(){
</div> </div>
<span class="hint">Applies to every feed that does not set its own. A feed's suggested <span class="hint">Applies to every feed that does not set its own. A feed's suggested
interval (its <b>ttl</b>) is still honoured when it asks to be polled less often.</span></div> interval (its <b>ttl</b>) is still honoured when it asks to be polled less often.</span></div>
<div class="field"><label>Max new downloads per scan, per feed</label> <div class="field"><label>Newest episodes to download, per feed</label>
<input type="number" id="gmax" min="0" max="999" value="${g.max_new_per_check}"> <input type="number" id="gmax" min="0" max="999" value="${g.max_new_per_check}">
<span class="hint">Applies to any feed that does not set its own — including every feed <span class="hint">Applies to any feed that does not set its own — including every feed
inside an OPML subscription. <b>0 means unlimited</b>, which will pull a whole back inside an OPML subscription. Older episodes stay listed to download by hand. <b>0 means
catalogue the first time a feed is scanned.</span></div> every episode</b>, the whole back catalogue.</span></div>
<div class="field"><label>Download these media types automatically</label> <div class="field"><label>Download these media types automatically</label>
<input type="text" id="gtypes" value="${esc((g.media_types||[]).join(', '))}" placeholder="audio, video"> <input type="text" id="gtypes" value="${esc((g.media_types||[]).join(', '))}" placeholder="audio, video">
<span class="hint">Anything else is still listed and can be downloaded by hand — blog feeds <span class="hint">Anything else is still listed and can be downloaded by hand — blog feeds
@@ -49,6 +49,10 @@ async function drawServer(){
items are never touched.</span></div> items are never touched.</span></div>
<div class="field"><label>Delete items older than (days, 0 = keep)</label> <div class="field"><label>Delete items older than (days, 0 = keep)</label>
<input type="number" id="gage" min="0" value="${g.max_age_days}"></div> <input type="number" id="gage" min="0" value="${g.max_age_days}"></div>
<div class="field"><label>Artwork kept (MB, 0 = none)</label>
<input type="number" id="gart" min="0" value="${g.art_cache_mb}">
<span class="hint">Show and episode artwork is kept here once shown, so it loads from iPX
instead of each publisher. Over this, what has gone longest unshown is dropped first.</span></div>
<div class="field"><label>Download folder</label> <div class="field"><label>Download folder</label>
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div> <span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>
<div class="cardacts"><button class="btn primary" id="gsave">${ICON.check} Save</button></div>`; <div class="cardacts"><button class="btn primary" id="gsave">${ICON.check} Save</button></div>`;
@@ -59,7 +63,8 @@ async function drawServer(){
max_new_per_check: Math.max(0, Number($('#gmax').value) || 0), max_new_per_check: Math.max(0, Number($('#gmax').value) || 0),
media_types: $('#gtypes').value.split(',').map(t => t.trim()).filter(Boolean), media_types: $('#gtypes').value.split(',').map(t => t.trim()).filter(Boolean),
max_total_gb: Number($('#gquota').value) || 0, max_total_gb: Number($('#gquota').value) || 0,
max_age_days: Number($('#gage').value) || 0})}); max_age_days: Number($('#gage').value) || 0,
art_cache_mb: Math.max(0, Number($('#gart').value) || 0)})});
toast('Settings saved'); toast('Settings saved');
}catch(e){ toast(e.message, true); } }catch(e){ toast(e.message, true); }
}; };

View File

@@ -55,7 +55,7 @@ function listedFeed(p,cls){
`<small class="meta">${p.subscribers} subscriber${p.subscribers===1?'':'s'}</small></div>`+ `<small class="meta">${p.subscribers} subscriber${p.subscribers===1?'':'s'}</small></div>`+
// Green, as a downloaded file is: it is already yours. Plus, beside it, is the way to get one. // Green, as a downloaded file is: it is already yours. Plus, beside it, is the way to get one.
(p.subscribed?`<span class="subbed" title="Subscribed: click to open it" aria-label="Subscribed">${ICON.subbed}</span>` (p.subscribed?`<span class="subbed" title="Subscribed: click to open it" aria-label="Subscribed">${ICON.subbed}</span>`
:`<button class="btn ico" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.subbed}</button>`); :`<button class="btn ico" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.plus}</button>`);
// Yours already: the row opens it instead. // Yours already: the row opens it instead.
if(p.subscribed){ el.onclick=()=>{ closeModal(); selectFeed(p.id); }; return el; } if(p.subscribed){ el.onclick=()=>{ closeModal(); selectFeed(p.id); }; return el; }
$('[data-a="sub"]',el).onclick=async()=>{ $('[data-a="sub"]',el).onclick=async()=>{
@@ -140,7 +140,7 @@ async function renderListening(url,box){
el.className='childrow'; el.className='childrow';
el.entry=e; el.entry=e;
el.innerHTML=artHTML(e.image||feedArt(e.feed_id),e.title||'')+ el.innerHTML=artHTML(e.image||feedArt(e.feed_id),e.title||'')+
`<div class="txt"><b>${EQ}<span>${esc(e.title||'(untitled)')}</span></b>`+ `<div class="txt"><b>${EQ}<span>${esc(entryName(e).text)}</span></b>`+
`<small><span class="fd">${esc(feedName(e.feed_id))}</span><span class="left"></span></small></div>`+ `<small><span class="fd">${esc(feedName(e.feed_id))}</span><span class="left"></span></small></div>`+
`<button class="iconbtn" data-a="play"></button>`+ `<button class="iconbtn" data-a="play"></button>`+
`<button class="iconbtn" data-a="remove" title="Remove from Currently Listening" aria-label="Remove from Currently Listening">${ICON.close}</button>`+ `<button class="iconbtn" data-a="remove" title="Remove from Currently Listening" aria-label="Remove from Currently Listening">${ICON.close}</button>`+
@@ -241,9 +241,7 @@ async function prefsModal(){
settings can add more.</span></div> settings can add more.</span></div>
<div class="field"><label>Feeds are checked every</label> <div class="field"><label>Feeds are checked every</label>
<span class="hint">${everyText(g.every_mins)}, for every feed that does not set its own. <span class="hint">${everyText(g.every_mins)}, for every feed that does not set its own.
${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></div> ${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></div>`);
<div class="field"><label>Download folder</label>
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>`);
$('#stheme').onchange=e=>setTheme(e.target.value,undefined,true); $('#stheme').onchange=e=>setTheme(e.target.value,undefined,true);
$('#smode').onchange=e=>setTheme(undefined,e.target.value,true); $('#smode').onchange=e=>setTheme(undefined,e.target.value,true);
$('#gopml').onclick=opmlModal; $('#gopml').onclick=opmlModal;
@@ -277,10 +275,10 @@ function settingsModal(f, newUrl?: string){
<span class="hint">Comma separated words or phrases. An item with one in its title or text <span class="hint">Comma separated words or phrases. An item with one in its title or text
is hidden from you and not downloaded for you, as well as those your Settings hide is hidden from you and not downloaded for you, as well as those your Settings hide
everywhere.</span></div>`} everywhere.</span></div>`}
<div class="field"><label>Max new downloads per scan</label> <div class="field"><label>Newest episodes to download</label>
<input type="number" id="smax" min="0" value="${f.max_new_per_check??''}"> <input type="number" id="smax" min="0" value="${f.max_new_per_check??''}">
<span class="hint">Blank follows the global default (${globalMax}). The rest wait for <span class="hint">Blank follows the global default (${globalMax}). Older episodes stay
the next scan.</span></div> listed to download by hand. <b>0 means every episode</b>, the whole back catalogue.</span></div>
<label class="check"><input type="checkbox" id="sauto" ${f.auto_download?'checked':''}> Download new items automatically</label> <label class="check"><input type="checkbox" id="sauto" ${f.auto_download?'checked':''}> Download new items automatically</label>
<label class="check"><input type="checkbox" id="sexp" ${f.allow_explicit?'checked':''}> Allow items marked explicit</label> <label class="check"><input type="checkbox" id="sexp" ${f.allow_explicit?'checked':''}> Allow items marked explicit</label>
<div class="field"><label>Feed URL</label> <div class="field"><label>Feed URL</label>

View File

@@ -3,7 +3,6 @@ let sse;
function connect(){ function connect(){
sse=new EventSource('/api/events'); sse=new EventSource('/api/events');
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 refreshFeeds=soon(()=>loadFeeds(true));
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.
const mine=id=>S.feeds.some(f=>f.id===id); const mine=id=>S.feeds.some(f=>f.id===id);
@@ -22,7 +21,7 @@ function connect(){
// Said only for a file on screen, as one downloaded by hand is: the scheduled downloads of // Said only for a file on screen, as one downloaded by hand is: the scheduled downloads of
// everyone's feeds used to announce themselves to everyone. // everyone's feeds used to announce themselves to everyone.
if(bar){ bar.classList.remove('live'); toast('Downloaded '+ev.path.split('/').pop()); } if(bar){ bar.classList.remove('live'); toast('Downloaded '+ev.path.split('/').pop()); }
refreshEntries(); refreshFeeds(); refreshEntries();
} }
else if(ev.ev==='download_error'){ else if(ev.ev==='download_error'){
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`); const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
@@ -35,12 +34,19 @@ function connect(){
else if(ev.ev==='feed_done'){ else if(ev.ev==='feed_done'){
setScanning(ev.feed,false); setScanning(ev.feed,false);
if(!mine(ev.feed)) return; if(!mine(ev.feed)) return;
refreshFeeds(); if(ev.feed===S.feed||S.feed===':all') refreshEntries(); if(ev.feed===S.feed||S.feed===':all') refreshEntries();
} }
// No toast: a scan of every feed raised one per failure, to everyone. The feed list's // No toast: a scan of every feed raised one per failure, to everyone. The feed list's
// red ! marks the feed instead, and its page says why. // red ! marks the feed instead, and its page says why.
else if(ev.ev==='feed_error'){ setScanning(ev.feed,false); if(mine(ev.feed)) refreshFeeds(); } else if(ev.ev==='feed_error') setScanning(ev.feed,false);
else if(ev.ev==='scan_done'){ scanning.clear(); paintScanning(); refreshFeeds(); refreshEntries(); } // The server sends a feed's new row after anything changes it (counts, error, last check),
// only to those who subscribe; the page used to reload the whole list after each event.
else if(ev.ev==='feed_row') patchFeed(ev.row);
// Only a scan that checked something: the scheduler scans every minute, due or not, and
// every open page reloaded the whole list each time (#103).
// Rows came as feed_row events. ponytail: a feed an OPML drops stays in the list until the
// 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(); }
}; };
sse.onerror=()=>{ sse.close(); setTimeout(connect,4000); }; sse.onerror=()=>{ sse.close(); setTimeout(connect,4000); };
} }

View File

@@ -1,5 +1,6 @@
/* ---------------- feed page ---------------- */ /* ---------------- feed page ---------------- */
function renderFeed(){ function renderFeed(){
paintScanning(); // another feed's page may be the one open now
const box=$('#content'); const box=$('#content');
box.classList.remove('plain'); box.classList.remove('plain');
const v=VIEWS[S.feed]; const v=VIEWS[S.feed];
@@ -27,7 +28,7 @@ function renderFeed(){
${f.group?`<div class="sub">From the OPML subscription <b>${esc(f.group)}</b></div>`:''} ${f.group?`<div class="sub">From the OPML subscription <b>${esc(f.group)}</b></div>`:''}
</div> </div>
<div class="acts"> <div class="acts">
<button class="btn ico primary" data-a="scan" title="Check this feed now" aria-label="Check this feed now">${ICON.scan}</button> <button class="btn ico" data-a="scan" title="Check this feed now" aria-label="Check this feed now">${ICON.scan}</button>
<button class="btn ico" data-a="dl" title="Download latest…" aria-label="Download latest">${ICON.download}</button> <button class="btn ico" data-a="dl" title="Download latest…" aria-label="Download latest">${ICON.download}</button>
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button> <button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
<button class="btn ico" data-a="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button> <button class="btn ico" data-a="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button>
@@ -44,7 +45,7 @@ function renderFeed(){
subscribe to, newest first · ${unreadAll} unread</div> subscribe to, newest first · ${unreadAll} unread</div>
</div> </div>
<div class="acts"> <div class="acts">
<button class="btn ico primary" data-a="scanall" title="Check every feed now" aria-label="Check every feed now">${ICON.scan}</button> <button class="btn ico" data-a="scanall" title="Check every feed now" aria-label="Check every feed now">${ICON.scan}</button>
<button class="btn ico" data-a="readall" title="Mark everything read" aria-label="Mark everything read">${ICON.checks}</button> <button class="btn ico" data-a="readall" title="Mark everything read" aria-label="Mark everything read">${ICON.checks}</button>
</div> </div>
</div>`) + ` </div>`) + `
@@ -118,7 +119,7 @@ function renderGroup(f,kids){
listed but kept because ${gone===1?'it has':'they have'} downloads.</div>`:''} listed but kept because ${gone===1?'it has':'they have'} downloads.</div>`:''}
</div> </div>
<div class="acts"> <div class="acts">
<button class="btn ico primary" data-a="scan" title="Re-read the OPML now" aria-label="Re-read the OPML now">${ICON.scan}</button> <button class="btn ico" data-a="scan" title="Re-read the OPML now" aria-label="Re-read the OPML now">${ICON.scan}</button>
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button> <button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
<button class="btn ico" data-a="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button> <button class="btn ico" data-a="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button>
<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>

View File

@@ -1,7 +1,18 @@
/* ---------------- feeds ---------------- */ /* ---------------- feeds ---------------- */
/// One feed's row from the server, in place of the old one (or added, for a feed an OPML just
/// listed). A scan sends dozens; the list is redrawn once a frame, not once each.
let rowsQueued=0;
function patchFeed(row){
const i=S.feeds.findIndex(f=>f.id===row.id);
if(i<0) S.feeds.push(row); else S.feeds[i]=row;
if(!rowsQueued) rowsQueued=requestAnimationFrame(()=>{ rowsQueued=0; renderFeeds(); });
}
async function loadFeeds(keepSel?: boolean){ async function loadFeeds(keepSel?: boolean){
S.feeds = await api('/api/feeds'); S.feeds = await api('/api/feeds');
api('/api/settings').then(g=>{globalMax=g.max_new_per_check}).catch(()=>{}); // Only for a hint in a feed's settings, so once, on the page's first load, not on every reload
// of the list (#103).
if(!keepSel) api('/api/settings').then(g=>{globalMax=g.max_new_per_check}).catch(()=>{});
renderFeeds(); renderFeeds();
// Land back where you were; a feed you no longer subscribe to, or a first visit, goes to // Land back where you were; a feed you no longer subscribe to, or a first visit, goes to
// All Subscriptions rather than picking one alphabetically. Nothing to land on at all (a // All Subscriptions rather than picking one alphabetically. Nothing to land on at all (a
@@ -39,6 +50,11 @@ function paintScanning(){
row.classList.toggle('scanning',on); row.classList.toggle('scanning',on);
if(on) row.title='Checking for new items…'; else row.removeAttribute('title'); if(on) row.title='Checking for new items…'; else row.removeAttribute('title');
} }
// The refresh buttons turn while what they check is being checked (#78). On <body>, because
// a feed's page is redrawn as its items come in and would take a class on the button with it.
const busy=id=>scanning.has(id)||S.feeds.some(c=>c.group===id&&scanning.has(c.id));
document.body.classList.toggle('scan-this',busy(S.feed));
document.body.classList.toggle('scan-any',S.feeds.some(f=>scanning.has(f.id)));
} }
function renderFeeds(){ function renderFeeds(){
@@ -100,19 +116,26 @@ function renderFeeds(){
// went wrong (failBannerHTML); the list only has to make it findable. // went wrong (failBannerHTML); the list only has to make it findable.
const bad=c=>c.failing?.reason||c.last_error; const bad=c=>c.failing?.reason||c.last_error;
const err=mine.length ? mine.map(bad).find(Boolean) : bad(f); const err=mine.length ? mine.map(bad).find(Boolean) : bad(f);
const nbad=mine.filter(bad).length;
const el=document.createElement('div'); const el=document.createElement('div');
// A feed that has failed for a day goes grey, as if switched off: a change in lightness
// shows in every theme, where the red mark alone was easy to miss on a dark one.
el.className='feed'+(S.feed===f.id?' sel':'')+(depth?' child':'')+(kids?' group':'')+ el.className='feed'+(S.feed===f.id?' sel':'')+(depth?' child':'')+(kids?' group':'')+
(err?' err':'')+(f.failing?' failing':'')+
(f.pinned&&!depth?' pinned':'')+(f===lastPin&&lastTop>=0?' lastpin':''); (f.pinned&&!depth?' pinned':'')+(f===lastPin&&lastTop>=0?' lastpin':'');
el.tabIndex=0; el.dataset.id=f.id; el.tabIndex=0; el.dataset.id=f.id;
const open = !!(kids && (expanded.has(f.id) || q)); const open = !!(kids && (expanded.has(f.id) || q));
el.innerHTML = el.innerHTML =
// The error mark hangs in the margin where a folder's triangle does. A folder already has (kids?`<button class="chev${err?' bad':''}" aria-expanded="${open}" title="${err?`A feed inside has a problem: ${esc(err)}`:'Show or hide the feeds inside'}" aria-label="Show or hide the feeds inside">${ICON.caret}</button>`:'')+
// its triangle there, so that turns red instead, and the feed inside shows the mark. // The mark sits on the artwork, the thing the eye scans the list by, and the line under
(kids?`<button class="chev${err?' bad':''}" aria-expanded="${open}" title="${err?`A feed inside has a problem: ${esc(err)}`:'Show or hide the feeds inside'}" aria-label="Show or hide the feeds inside">${ICON.caret}</button>` // the name says what is wrong in place of the counts.
:err?`<span class="ferr" role="img" title="${esc(err)}" aria-label="Error: ${esc(err)}">${ICON.alert}</span>`:'')+ `<span class="fart">${mine.length?folderArt(f,mine):artHTML(f.image,f.title||f.id)}`+
(mine.length?folderArt(f,mine):artHTML(f.image,f.title||f.id))+ (err?`<span class="ferr" role="img" title="${esc(err)}" aria-label="Error: ${esc(err)}">${ICON.alert}</span>`:'')+
`<div class="txt"><b>${f.pinned?`<span class="fpin" title="Pinned">${ICON.pinOn}</span>`:''}${esc(f.title||f.id)}</b><small>`+ (f.pinned?`<span class="fpin" role="img" title="Pinned" aria-label="Pinned">${ICON.pinOn}</span>`:'')+`</span>`+
`${mine.length?plural(mine.length,'feed'):plural(eps,'item')} · ${saved} downloaded`+ `<div class="txt"><b>${esc(f.title||f.id)}</b><small>`+
(nbad?`${plural(nbad,'feed')} not updating`
:err?esc(f.failing?.reason||'The last check failed')
:`${mine.length?plural(mine.length,'feed'):plural(eps,'item')} · ${saved} downloaded`)+
`</small></div>`+ `</small></div>`+
(f.orphaned?'<span class="tag" title="No longer listed, kept because it has downloads">Gone</span>':'')+ (f.orphaned?'<span class="tag" title="No longer listed, kept because it has downloads">Gone</span>':'')+
`<span class="badge${unread?'':' zero'}" title="${unread} unread">${unread>999?'999+':unread}</span>`; `<span class="badge${unread?'':' zero'}" title="${unread} unread">${unread>999?'999+':unread}</span>`;

View File

@@ -36,13 +36,25 @@ function pullShow(dy: number){
tip.textContent = dy >= PULL ? 'Release to check for new items' : 'Pull to check for new items'; tip.textContent = dy >= PULL ? 'Release to check for new items' : 'Pull to check for new items';
} }
/// A check started by a pull, while its spinner is up. Letting go showed nothing before (issue
/// #68), the sidebar's spinner is hidden on a phone, and people pulled again and again.
let refreshing = false;
function refreshFeed(){ function refreshFeed(){
const f = S.feeds.find(x => x.id === S.feed); const f = S.feeds.find(x => x.id === S.feed);
if(!f && S.feed !== ':all') return; if(!f && S.feed !== ':all') return;
if(refreshing) return;
refreshing = true;
// Outside #list, which a feed's render rebuilds and would take the spinner with it.
const spin = document.createElement('div');
spin.id = 'pullspin'; spin.setAttribute('role', 'status'); spin.textContent = 'Checking for new items';
document.body.append(spin);
// New items arrive by the event stream when the scan finishes, as they do for a button press. // New items arrive by the event stream when the scan finishes, as they do for a button press.
api('/api/fetch', {method: 'POST', body: JSON.stringify(f ? {feed: f.id, force: true} : {force: true})}) const asked = api('/api/fetch', {method: 'POST', body: JSON.stringify(f ? {feed: f.id, force: true} : {force: true})})
.catch(e => toast(e.message, true)); .catch(e => toast(e.message, true));
loadEntries(); loadEntries();
// Up for two seconds at least, so it is seen even when the server answers at once.
Promise.all([asked, new Promise(r => setTimeout(r, 2000))]).then(() => { spin.remove(); refreshing = false; });
} }
document.addEventListener('touchstart', ev => { document.addEventListener('touchstart', ev => {

View File

@@ -20,10 +20,12 @@ async function loadEntries(append?: boolean){
// A background scan finishing refreshes the list from the server, which -- on the Unread // A background scan finishing refreshes the list from the server, which -- on the Unread
// tab -- would drop the item you have open the moment reading it took it off the filter. // tab -- would drop the item you have open the moment reading it took it off the filter.
// Keep it until you pick a different one; the next refresh after that no longer protects it. // Keep it until you pick a different one; the next refresh after that no longer protects it.
if(S.filter==='unread') entries=entries.filter(e=>!e.read||e.guid===S.sel); // The items turned past on the way to it are kept too, so a swipe back still finds them.
if(!append && S.sel && !entries.some(e=>e.guid===S.sel)){ const kept=e=>e.guid===S.sel||turned.has(e.guid);
const open=S.entries.find(e=>e.guid===S.sel); if(S.filter==='unread') entries=entries.filter(e=>!e.read||kept(e));
if(open) entries=[open,...entries]; if(!append && S.sel){
const gone=S.entries.filter(e=>kept(e)&&!entries.some(n=>n.guid===e.guid));
entries=[...gone,...entries];
} }
S.entries = entries; S.entries = entries;
renderEntries(); renderEntries();
@@ -63,13 +65,14 @@ function epEl(e){
el.dataset.guid=e.guid; el.dataset.guid=e.guid;
const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join(''); const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join('');
const left = e.position>10 && e.duration ? `${clock(e.duration-e.position)} left` : (e.duration?clock(e.duration):''); const left = e.position>10 && e.duration ? `${clock(e.duration-e.position)} left` : (e.duration?clock(e.duration):'');
const name=entryName(e);
el.innerHTML=` el.innerHTML=`
<button class="st" data-a="read" title="Mark ${e.read?'unread':'read'}">${ <button class="st" data-a="read" title="Mark ${e.read?'unread':'read'}">${
player.guid===e.guid?EQ:(e.read?'':'●')}</button> player.guid===e.guid?EQ:(e.read?'':'●')}</button>
<button class="fl${e.flagged?' on':''}" data-a="flag" title="${ <button class="fl${e.flagged?' on':''}" data-a="flag" aria-pressed="${!!e.flagged}" title="${
e.flagged?'Unpin':'Pin, so it is never deleted'}">${e.flagged?ICON.pinOn:ICON.pin}</button> e.flagged?'Unpin':'Pin, so it is never deleted'}">${e.flagged?ICON.pinOn:ICON.pin}</button>
<div class="body"> <div class="body">
<span class="t">${esc(e.title||'(untitled)')}</span> <span class="t${name.derived?' notitle':''}">${esc(name.text)}</span>
<div class="line">${[ <div class="line">${[
num&&`<span>${num}</span>`, num&&`<span>${num}</span>`,
left&&`<span>${left}</span>`, left&&`<span>${left}</span>`,
@@ -134,6 +137,25 @@ function kindIcon(enc){
function feedArt(id=S.feed){ const f=S.feeds.find(x=>x.id===id); return f&&f.image; } function feedArt(id=S.feed){ const f=S.feeds.find(x=>x.id===id); return f&&f.image; }
const feedName=id=>{ const f=S.feeds.find(x=>x.id===id); return f?(f.title||f.id):id; }; const feedName=id=>{ const f=S.feeds.find(x=>x.id===id); return f?(f.title||f.id):id; };
/// What an item is called. Its title, or for one published without (RSS 2.0 makes it optional,
/// and Scripting News titles almost none of its posts) the opening of its text, then its file's
/// name, then its feed and date: "(untitled)" fifty times down a list said nothing about any of
/// them. `derived` marks a name that is not a title, which the list sets as text, not heading.
function entryName(e): {text: string, derived: boolean}{
if(e.title&&e.title.trim()) return {text:e.title, derived:false};
// An inert document: nothing in it loads or runs, where a detached element fetches its images.
const words=e.description
? (new DOMParser().parseFromString(e.description,'text/html').body.textContent||'').replace(/\s+/g,' ').trim()
: '';
if(words){
const cut=words.length>120 ? words.slice(0,120).replace(/\s+\S*$/,'')+'…' : words;
return {text:cut, derived:true};
}
const file=((e.enclosures||[])[0]?.url||'').split(/[?#]/)[0].split('/').pop().replace(/\.[a-z0-9]{1,5}$/i,'');
if(file){ try{ return {text:decodeURIComponent(file), derived:true}; }catch{ return {text:file, derived:true}; } }
return {text:[feedName(e.feed_id), dateOf(e.published)].filter(Boolean).join(', '), derived:true};
}
/// Selecting an item shows it in the pane below, rather than expanding the row. /// Selecting an item shows it in the pane below, rather than expanding the row.
/// Replaces one row with a fresh one, leaving the rest of the list and its scroll alone. /// Replaces one row with a fresh one, leaving the rest of the list and its scroll alone.
function swapRow(e){ function swapRow(e){
@@ -151,7 +173,7 @@ function setRead(e,read){
const w: {read: boolean, done?: number}={read}; readWrites.set(readKey(e),w); const w: {read: boolean, done?: number}={read}; readWrites.set(readKey(e),w);
return api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/flags`, return api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/flags`,
{method:'POST',body:JSON.stringify({read})}) {method:'POST',body:JSON.stringify({read})})
.then(()=>{ w.done=performance.now(); loadFeeds(true); }); .then(row=>{ w.done=performance.now(); if(row) patchFeed(row); });
} }
/// Opening an item is reading it. The row is redrawn where it stands rather than the list /// Opening an item is reading it. The row is redrawn where it stands rather than the list
@@ -161,13 +183,24 @@ function markRead(e){
setRead(e,true).catch(err=>{ e.read=false; readWrites.delete(readKey(e)); toast(err.message,true); }); setRead(e,true).catch(err=>{ e.read=false; readWrites.delete(readKey(e)); toast(err.message,true); });
} }
function selectEntry(e){ /// On the Unread tab, the items read while turning from one to the next (a swipe, j and k), kept
/// in the list until the reader closes or another item is picked from the list. Dropped as each
/// was left, a swipe back had nothing to go back to: the item just read was already gone.
const turned=new Set<string>();
function dropTurned(keep){
const gone=S.entries.filter(x=>turned.has(x.guid)&&x.read&&x.guid!==keep);
turned.clear();
if(!gone.length) return;
S.entries=S.entries.filter(x=>!gone.includes(x)); S.total-=gone.length;
for(const x of gone) $(`#eps .ep[data-guid="${CSS.escape(x.guid)}"]`)?.remove();
}
function selectEntry(e,turning=false){
// On the Unread tab the item you were reading goes as you move on, not whenever a refresh // On the Unread tab the item you were reading goes as you move on, not whenever a refresh
// next happens to come along, which left a few read ones in the list for a while. // next happens to come along, which left a few read ones in the list for a while.
const prev=S.filter==='unread' && S.sel!==e.guid && S.entries.find(x=>x.guid===S.sel); if(S.filter==='unread' && S.sel && S.sel!==e.guid){
if(prev&&prev.read){ turned.add(S.sel);
S.entries=S.entries.filter(x=>x!==prev); S.total--; if(!turning) dropTurned(e.guid);
$(`#eps .ep[data-guid="${CSS.escape(prev.guid)}"]`)?.remove();
} }
S.sel=e.guid; S.sel=e.guid;
markRead(e); markRead(e);
@@ -207,8 +240,12 @@ const cur=()=>S.entries.find(x=>x.guid===S.sel);
function syncTools(e){ function syncTools(e){
$('#tbPlay').disabled=!(e&&e.enclosures.some(isPlayable)); $('#tbPlay').disabled=!(e&&e.enclosures.some(isPlayable));
$('#tbRead').disabled=$('#tbFlag').disabled=!e; $('#tbRead').disabled=$('#tbFlag').disabled=!e;
// The same icons as the item's own buttons beside its title, so the two never disagree. // The same icons as the item's own buttons beside its title, so the two never disagree. Each
$('#tbRead').innerHTML=e&&e.read?ICON.unread:ICON.check; // shows what is, as the pin always did: read showed what a click would do, so the two side by
// side said opposite things (#74). The shape carries it, not the colour.
$('#tbRead').innerHTML=e&&!e.read?ICON.unread:ICON.check;
$('#tbRead').setAttribute('aria-pressed',String(!!(e&&e.read)));
$('#tbFlag').setAttribute('aria-pressed',String(!!(e&&e.flagged)));
$('#tbFlag').innerHTML=e&&e.flagged?ICON.pinOn:ICON.pin; $('#tbFlag').innerHTML=e&&e.flagged?ICON.pinOn:ICON.pin;
if(e){ if(e){
$('#tbRead').title=`Mark ${e.read?'unread':'read'}`; $('#tbRead').title=`Mark ${e.read?'unread':'read'}`;
@@ -224,6 +261,7 @@ function showDetail(e){
document.body.classList.toggle('reading',!!e); document.body.classList.toggle('reading',!!e);
syncTools(e); syncTools(e);
if(!e){ if(!e){
dropTurned(S.sel);
box.innerHTML='<p class="empty">Pick an item to read it.</p>'; box.innerHTML='<p class="empty">Pick an item to read it.</p>';
if(files) files.innerHTML='<p class="empty">No files</p>'; if(files) files.innerHTML='<p class="empty">No files</p>';
return; return;
@@ -253,15 +291,16 @@ function detailHtml(e){
// description was sanitized server-side with ammonia before it ever reached here // description was sanitized server-side with ammonia before it ever reached here
return ` return `
<button class="btn ico" id="dback" title="Back to the items" aria-label="Back to the items">${ICON.left}</button> <button class="btn ico" id="dback" title="Back to the items" aria-label="Back to the items">${ICON.left}</button>
<h3 class="dt">${esc(e.title||'(untitled)')}</h3> ${/* A post without a title starts with its text: its own first words as a heading above
themselves would read as a mistake. */ e.title&&e.title.trim() ? `<h3 class="dt">${esc(e.title)}</h3>` : ''}
<div class="dmeta"> <div class="dmeta">
${/* 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'}" <button class="btn ico" data-a="read" title="Mark ${e.read?'unread':'read'}"
aria-label="Mark ${e.read?'unread':'read'}">${e.read?ICON.unread:ICON.check}</button> aria-label="Mark ${e.read?'unread':'read'}" 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'}">${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>`:''}
@@ -310,7 +349,7 @@ function encBox(x){
// Nothing on disk. For an image or a PDF you usually just want to look at it, so link // Nothing on disk. For an image or a PDF you usually just want to look at it, so link
// straight to the publisher's copy in a new tab -- no download, and nothing proxied // straight to the publisher's copy in a new tab -- no download, and nothing proxied
// through here, which would make ipx a fetch-anything relay. // through here, which would make ipx a fetch-anything relay.
const viewable = !isPlayable(x) && x.state !== 'pending'; const viewable = !isPlayable(x) && x.state !== 'pending' && x.state !== 'held';
return `<div class="encbox"> return `<div class="encbox">
${kindIcon(x)} ${kindIcon(x)}
<span class="meta" style="flex:1">${size}</span> <span class="meta" style="flex:1">${size}</span>
@@ -328,13 +367,13 @@ async function epAction(a: string, e, el, encId?: number){
const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`; const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`;
try{ try{
if(a==='play') play(e, encId!=null ? enc : undefined); if(a==='play') play(e, encId!=null ? enc : undefined);
if(a==='share') await share(e.title||'', encId!=null ? enc.url : e.link, el); if(a==='share') await share(entryName(e).text, encId!=null ? enc.url : e.link, el);
if(a==='flag'){ e.flagged=!e.flagged; await api(path+'/flags',{method:'POST',body:JSON.stringify({flagged:e.flagged})}); redraw(); } if(a==='flag'){ e.flagged=!e.flagged; await api(path+'/flags',{method:'POST',body:JSON.stringify({flagged:e.flagged})}); redraw(); }
if(a==='read'){ await setRead(e,!e.read); redraw(); } if(a==='read'){ await setRead(e,!e.read); redraw(); }
if(a==='get'){ if(a==='get'){
if(!enc) return; if(!enc) return;
await api(`/api/enclosures/${enc.id}/download`,{method:'POST'}); await api(`/api/enclosures/${enc.id}/download`,{method:'POST'});
toast('Queued: '+(e.title||'item')); toast('Queued: '+entryName(e).text);
} }
if(a==='del'){ if(a==='del'){
const f=S.feeds.find(x=>x.id===e.feed_id); const f=S.feeds.find(x=>x.id===e.feed_id);

View File

@@ -77,7 +77,7 @@ function installNativePlayback(){
realLoad.call(audio); realLoad.call(audio);
const e = player.entry, f = player.feed; const e = player.entry, f = player.feed;
post({t:'load', url:v, enc:player.enc, feedId:f, guid:player.guid, post({t:'load', url:v, enc:player.enc, feedId:f, guid:player.guid,
title:(e && e.title) || '', feedTitle:feedName(f), title:e ? entryName(e).text : '', feedTitle:feedName(f),
artwork:(e && e.image) || feedArt(f) || null, artwork:(e && e.image) || feedArt(f) || null,
// Where the host starts is not this: the seek to where you left off is player.ts's, on // Where the host starts is not this: the seek to where you left off is player.ts's, on
// loadedmetadata, so one piece of code decides it. This is for the host's now-playing // loadedmetadata, so one piece of code decides it. This is for the host's now-playing

View File

@@ -32,7 +32,7 @@ function play(e,enc=e.enclosures.find(isPlayable)){
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.
$('#partwrap').innerHTML=artHTML(e.image||feedArt(e.feed_id),feedName(e.feed_id)); $('#partwrap').innerHTML=artHTML(e.image||feedArt(e.feed_id),feedName(e.feed_id));
$('#ptitle').textContent=e.title||'(untitled)'; $('#ptitle').textContent=entryName(e).text;
const f=S.feeds.find(x=>x.id===e.feed_id); const f=S.feeds.find(x=>x.id===e.feed_id);
$('#pfeed').textContent=f?(f.title||f.id):''; $('#pfeed').textContent=f?(f.title||f.id):'';
$('#player').classList.add('on'); $('#player').classList.add('on');
@@ -45,8 +45,8 @@ function play(e,enc=e.enclosures.find(isPlayable)){
function mediaSession(e,f){ function mediaSession(e,f){
if(!('mediaSession' in navigator)) return; if(!('mediaSession' in navigator)) return;
navigator.mediaSession.metadata=new MediaMetadata({ navigator.mediaSession.metadata=new MediaMetadata({
title:e.title||'', artist:f?(f.title||f.id):'', album:f?(f.title||''):'', title:entryName(e).text, artist:f?(f.title||f.id):'', album:f?(f.title||''):'',
artwork:(e.image||(f&&f.image))?[{src:e.image||f.image,sizes:'512x512'}]:[], artwork:(e.image||(f&&f.image))?[{src:artSrc(e.image||f.image),sizes:'512x512'}]:[],
}); });
const h={play:()=>audio.play(),pause:()=>audio.pause(), const h={play:()=>audio.play(),pause:()=>audio.pause(),
seekbackward:()=>audio.currentTime-=15,seekforward:()=>audio.currentTime+=30}; seekbackward:()=>audio.currentTime-=15,seekforward:()=>audio.currentTime+=30};
@@ -141,7 +141,7 @@ function stepEntry(by){
if(VIEWS[S.feed]?.url||!S.entries.length) return; if(VIEWS[S.feed]?.url||!S.entries.length) return;
const i=S.entries.findIndex(x=>x.guid===S.sel); const i=S.entries.findIndex(x=>x.guid===S.sel);
const e=S.entries[i<0?0:Math.min(S.entries.length-1,Math.max(0,i+by))]; const e=S.entries[i<0?0:Math.min(S.entries.length-1,Math.max(0,i+by))];
selectEntry(e); selectEntry(e,true);
$(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`)?.scrollIntoView({block:'nearest'}); $(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`)?.scrollIntoView({block:'nearest'});
} }
function stepFeed(by){ function stepFeed(by){

View File

@@ -1,7 +1,8 @@
/* ---------------- theme ---------------- */ /* ---------------- theme ---------------- */
// A theme, and for those that come in both, light, dark or Auto, chosen in Settings and kept on // A theme, and for those that come in both, light, dark or Auto, chosen in Settings and kept in a
// the account, so it follows you to another browser or computer. The server writes it onto the // cookie, so each device has its own: Glass on a phone, Dracula on a desktop (issue #69). The
// page's <html> tag (data-theme, data-choice) so the page is drawn in it from the start. The // server reads it and writes it onto the page's <html> tag (data-theme, data-choice) so the page
// is drawn in it from the start. The
// page gets data-mode, light or dark, which is all the CSS reads: Auto is worked out here, from // page gets data-mode, light or dark, which is all the CSS reads: Auto is worked out here, from
// the system, so no palette is written twice. // the system, so no palette is written twice.
const THEMES: Record<string, {name: string, modes: boolean}> = { const THEMES: Record<string, {name: string, modes: boolean}> = {
@@ -25,7 +26,7 @@ const OLD_THEMES: Record<string, [string, string]> = {dark: ['modern', 'dark'],
const systemDark = window.matchMedia?.('(prefers-color-scheme: dark)'); const systemDark = window.matchMedia?.('(prefers-color-scheme: dark)');
const theme = {name: 'modern', mode: 'dark'}; const theme = {name: 'modern', mode: 'dark'};
/// `save` for a choice made in Settings, which goes to the account; not for applying one. /// `save` for a choice made in Settings, which goes to this browser's cookie; not for applying one.
function setTheme(name = theme.name, mode = theme.mode, save = false){ function setTheme(name = theme.name, mode = theme.mode, save = false){
theme.name = THEMES[name] ? name : 'modern'; theme.name = THEMES[name] ? name : 'modern';
theme.mode = MODES[mode] ? mode : 'dark'; theme.mode = MODES[mode] ? mode : 'dark';
@@ -35,27 +36,26 @@ function setTheme(name = theme.name, mode = theme.mode, save = false){
// A theme with one palette has it whatever the mode; both of those are light. // A theme with one palette has it whatever the mode; both of those are light.
root.dataset.mode = !both ? 'light' root.dataset.mode = !both ? 'light'
: 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.
const fav = $('#favicon'); if(fav) fav.href = fav.dataset[root.dataset.mode];
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;
if(save) saveTheme(); if(save) saveTheme();
} }
/// One save at a time, each sending the choice as it stands when it goes. Sent as they came, /// Kept a year, for the whole site: the admin page is drawn in it too.
/// several at once, a quick run through the list could reach the server out of order and
/// leave the account on a theme passed on the way.
let themeSaving = Promise.resolve();
function saveTheme(){ function saveTheme(){
themeSaving = themeSaving document.cookie = `ipx_theme=${theme.name}.${theme.mode}; Path=/; Max-Age=31536000; SameSite=Lax`;
.then(() => api('/api/me', {method: 'PATCH', body: JSON.stringify({theme: theme.name, mode: theme.mode})}))
.catch(e => toast(`Your theme was not saved: ${e.message}`, true));
} }
systemDark?.addEventListener?.('change', () => { if(theme.mode === 'auto') setTheme(); }); systemDark?.addEventListener?.('change', () => { if(theme.mode === 'auto') setTheme(); });
(() => { (() => {
const root = document.documentElement; const root = document.documentElement;
if(root.dataset.choice) return setTheme(root.dataset.theme, root.dataset.choice); // Without a cookie, the server sent the theme the account kept from before; this browser takes
// Nothing on the account yet. A theme this browser kept, from before themes were kept on the // it as its own, once, so nobody has to choose again.
// account, goes up to it once, so nobody has to choose again. if(root.dataset.choice) return setTheme(root.dataset.theme, root.dataset.choice, !/(^|; )ipx_theme=/.test(document.cookie));
// Nothing on the account either. A theme this browser kept in localStorage, from before that,
// becomes its cookie, once.
let name: string | null = null, mode: string | null = null; let name: string | null = null, mode: string | null = null;
try{ name = localStorage.getItem('ipx.theme'); mode = localStorage.getItem('ipx.mode'); }catch{} try{ name = localStorage.getItem('ipx.theme'); mode = localStorage.getItem('ipx.mode'); }catch{}
if(OLD_THEMES[name]) [name, mode] = OLD_THEMES[name]; if(OLD_THEMES[name]) [name, mode] = OLD_THEMES[name];

View File

@@ -145,9 +145,15 @@ const tint=name=>{ let h=0; for(const c of name||'?') h=(h*31+c.charCodeAt(0))>>
function tileHTML(name,cls){ function tileHTML(name,cls){
return `<div class="art ini ${cls||''}" style="--tint:${tint(name)}">${esc(initials(name))}</div>`; return `<div class="art ini ${cls||''}" style="--tint:${tint(name)}">${esc(initials(name))}</div>`;
} }
/// Every image a feed names comes from iPX, which keeps a copy: fast after the first time, and
/// no publisher's server asked on every visit. It began with http-only artwork, which the https
/// page could not load (#90).
function artSrc(url: string){
return /^https?:\/\//i.test(url) ? '/api/art?u='+encodeURIComponent(url) : url;
}
function artHTML(url: string | null, name: string, cls?: string){ function artHTML(url: string | null, name: string, cls?: string){
return url return url
? `<img class="art ${cls||''}" src="${esc(url)}" alt="" loading="lazy" onerror="this.outerHTML=${esc(JSON.stringify(tileHTML(name,cls)))}">` ? `<img class="art ${cls||''}" src="${esc(artSrc(url))}" alt="" loading="lazy" onerror="this.outerHTML=${esc(JSON.stringify(tileHTML(name,cls)))}">`
: tileHTML(name,cls); : tileHTML(name,cls);
} }
/// A folder's tile is its first four shows' art. With fewer than four to show, the folder's own. /// A folder's tile is its first four shows' art. With fewer than four to show, the folder's own.
@@ -156,7 +162,7 @@ function folderArt(f,kids){
if(art.length<4) return artHTML(f.image,f.title||f.id); if(art.length<4) return artHTML(f.image,f.title||f.id);
// Tinted underneath, so art that fails to load leaves colour behind rather than a hole. // Tinted underneath, so art that fails to load leaves colour behind rather than a hole.
return `<div class="art ini mosaic" style="--tint:${tint(f.title||f.id)}">${art.map(c=> return `<div class="art ini mosaic" style="--tint:${tint(f.title||f.id)}">${art.map(c=>
`<img src="${esc(c.image)}" alt="" loading="lazy" onerror="this.style.visibility='hidden'">`).join('')}</div>`; `<img src="${esc(artSrc(c.image))}" alt="" loading="lazy" onerror="this.style.visibility='hidden'">`).join('')}</div>`;
} }
/// The sidebar slides over the page on a phone, so it needs a scrim to tap away. /// The sidebar slides over the page on a phone, so it needs a scrim to tap away.