For anyone who turns DeadAir on, a background task makes each episode a
copy with every silence quieter than -38 dB for over 0.35 s shortened to
0.2 s. Nothing is re-encoded: ffmpeg copies the original's frames and
leaves the silent ones out, about 11 s for an hour of MP3 in a release
build. On Brad & Will's episode 359 that is 520 cuts and 3.3 minutes
saved, the copy decodes clean, and it has no isolated spikes in its
silences that the original lacks.
Cuts are made from the middle of each silence, keeping 0.1 s at each
end, so the frames either side of a cut are silent: decoding blends a
frame with the one before it, and an MP3 frame can begin in the bytes of
the frame before, so the first frame after a cut decodes against the
wrong neighbour. Inside a silence nobody hears that; at a word they would.
Positions stay in the original's time and the server converts them both
ways. The iOS and Android apps save positions themselves from their own
player, even while the page is asleep, so converting in the page would
have stored the copy's times as the original's. /media/{id}/deadair
serves the copy at its own address and records that this person is
playing it; a position saved meanwhile is converted from the copy's
clock, as is the length a player measures.
The copy is made by a task of its own, not the worker: a first pass over
a library is hours of ffmpeg, and scans must not wait behind it. It
checks every two minutes, which covers new downloads, files already
downloaded, a switch turned on and a file downloaded again with one rule.
Episodes everyone who would hear the copy has finished get none.
download::remove deletes a copy with its original, and the quota counts
copies.
Settings: the switch is PATCH /api/me {deadair}, a feed kept out is
PATCH /api/feeds/{id} {deadair: false}. GET /api/me gives the switch and
deadair_saved, the seconds saved on episodes heard 75% or more on a copy,
each counted once. Three new tables, so no ALTER.
The image gains Debian's ffmpeg, about 480 MB, in a layer that stays
cached until the base image changes. The player's switch, the feed
setting and the stat come next, after mockups.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A torrent of several files is recorded as its folder. /media could not serve a folder, and the
reaper's remove_file failed on it, warned "could not delete" on every pass and freed nothing, so it
counted against the quota for good; Delete in the page failed the same way.
download::remove deletes a file or a whole folder, for the reaper and Delete alike, and the reaper
counts a folder by its recorded size, as a directory's own size is not its contents'.
download::playable answers the path itself, or for a folder the largest file in it, which is the
episode, and /media serves that.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The reaper counted read marks and pins in entry_state from anyone and compared them with the feed's
subscribers. Unsubscribing leaves your rows behind, and the flags endpoint writes them for feeds you
do not subscribe to as well. So an episode read by someone who had left and by one subscriber of
two counted as heard by everyone, and went first under the quota though the other had not played
it; and a pin of a person who had left kept a file, and its item, for good, which can leave the
quota impossible to meet.
Both queries, the reaper's candidates and prune_entries, now count only the marks of people who
subscribe to the item's feed. The old prune test's pin had no subscriber behind it; it has one now.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
- IPX_DATABASE_URL (postgres://...) picks the database; unset, it is the SQLite
file as before. Passwords are taken out of anything logged.
- `ipx copy-db <state.db>` copies every table into the empty database the URL
names, in one transaction, and moves the id counters past the copied ids. A
copy of production went across in 14s with every count and column
fingerprint identical.
- With IPX_TEST_DATABASE_URL set, each test gets a Postgres schema of its own;
all 79 pass on both databases. Fixtures write booleans as true/false.
- Sorts say where an item with no value goes (NULLS FIRST going up, LAST going
down): SQLite counts NULL as smallest, Postgres as largest, so "largest first"
on Postgres led with every item that has no file. Tested on both.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The last nineteen functions move to SeaORM: recording feeds, items and
enclosures, managed OPML feeds, folding WordPress's repeated files, and handing a
Patreon creator's files to its shows. Two SQLite-only forms go: GLOB becomes a
LIKE with the underscore escaped (broader, harmlessly: the fold still keys on
`_=` and digits), and UPDATE OR IGNORE becomes an UPDATE ... WHERE NOT EXISTS.
The two transactions are SeaORM transactions.
With nothing left on it, rusqlite goes, with the SQL schema and migrate(). The
entities are the schema: create_missing makes whatever tables and indexes a
database lacks, from them, with CREATE ... IF NOT EXISTS. Production's schema
already has every column migrate() added and none it dropped.
Not SeaORM's schema sync, used until now: despite its docs it drops a unique
index the entities do not describe, so it dropped users_name_lower on every open.
Every `ipx` command then took a write lock, and against a daemon busy writing,
`ipx status` -- the healthcheck -- failed 7 times in 15 where the old code
failed none. Now 15 in 15, as before. On Postgres it would not have started.
WAL is set only when a file is not already in it: setting it takes a lock that
cannot wait out a busy daemon.
Checked on copies of production: a forced scan of all 162 feeds against the real
feeds with no database errors; the feed list, filters, sorts, search and the
reaper's candidates against the old code on the same data, earlier in the
branch. The column comments from the SQL schema move to the entities.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Twelve enclosure functions move to SeaORM: recording, the download queue,
marking done or failed, requeueing, and what the reaper may delete. INSERT OR
IGNORE becomes ON CONFLICT DO NOTHING; the reaper's read verdict is true or
false rather than 1 or 0, which Postgres would type as a 32-bit integer and
refuse to read as an i64; `read = 1` and `flagged = 1` test the booleans
themselves. retention::run and its callers (reap, rm, retire_group,
retire_stranded) become async.
The reaper deletes files, so it was checked on a copy of production against the
old SQL on the same file: all 2,195 candidates, identical and in the same order.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first step of moving to SeaORM (#18, phase 1). Nothing a user sees changes.
- src/entity.rs: the seven tables as SeaORM entities, matching the SQLite schema.
Strings are Text, as the columns are; yes/no columns are bool, which is BOOLEAN
on Postgres and stays INTEGER in the existing SQLite file (sync notes the
difference and leaves it alone).
- Db holds a SeaORM connection to the same SQLite file beside the rusqlite one;
functions move to it one at a time, and rusqlite goes with the last of them.
- db::sync creates what a database is missing from the entities (SeaORM's
schema-sync, experimental, so sea-orm is pinned to ~2.0), plus the two indexes
an entity cannot express. Checked against a copy of production: it added the
lower(name) index and changed nothing else.
- Test databases are now built from the entities alone, in a temporary file
(two connections to one ":memory:" are two databases), so every test also
checks that the entities describe what the queries need. That caught the one
difference: finding a user by name relied on COLLATE NOCASE, which Postgres
lacks; it now compares lower() on both sides.
- rusqlite steps back to 0.39: 0.40's libsqlite3-sys is newer than sqlx accepts,
and only one may link SQLite. It goes away at the end of this phase.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drops the created columns on users, subscriptions and sessions, which were
written by every insert and read by nothing, and migrate()'s add list, whose
columns all predate 0.3.0. Removes Db::subscribed_feed_ids (no callers),
Db::subscriber_count (one caller wanting > 0) and Managed.orphaned (never
read). The old-database test now builds the tables with foreign keys on.
Popular now lists the feeds inside an OPML or a Patreon creator, never the
collection, as Directory does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
prune_entries still guarded on entries.flagged, which nothing writes
since read state moved to entry_state -- so starring a text item with no
file would not have saved it from the age sweep. It follows the reaper's
rule now, and takes orphaned read state with whatever it deletes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
reap_candidates still read entries.read/flagged, which nothing writes
since read state moved to entry_state -- so starring no longer protected
a file and the read-first ordering was dead. One file serves every
subscriber, so anyone starring it keeps it, and it counts as read only
once everyone subscribed has read it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
Oldest-first reaper with the original's 50 MB headroom pad, plus a
reconcile pass for files deleted by hand and pruning of stale entries.
The Python meant to reap only read, unflagged episodes but a missing
import and a typo made that filter throw on every candidate. Requiring
read=1 would be equally dead headless, so flagged is the keep-forever
marker and read only decides ordering.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe