Compare commits

..

4 Commits

Author SHA1 Message Date
Test User 1e8afd5f41 plan: record the seek follow-up
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 14:29:47 +02:00
Test User 0d08c765a1 seek: put seek and track-skip on two keys, and make the web gauge clickable
Two physical keys now carry all four moves in both clients: `,`/`.` seek 15
seconds, and their shifted forms `<`/`>` skip a whole track. Self-teaching
(same key, shift = bigger jump), and `<`/`>` are the marks engraved on them.

The reason it is these keys and not control chords: they are plain printable
characters, so nothing a browser reserves can swallow them. Ctrl-n — the
long-standing next-track chord — cannot be claimed in a browser at all,
because Chrome and Firefox handle it as "new window" above the page where
preventDefault cannot reach (unlike Ctrl-f, Ctrl-p or Ctrl-b, which the
keydown handler does claim). So the web client had no working next-track key.
Ctrl-n/Ctrl-p stay bound as the terminal's primary chords; the web help
documents the pair that always works.

Click-to-seek on the web progress bar comes with it, and needed no new rpc:
the click maps the pointer's x within the gauge to a fraction of the duration
and sends target - position. Relative is right here even though the gesture
is absolute — the position it subtracts is the one drawn on the bar the user
just aimed at, at most one 250 ms tick old, far under one pixel of the bar.
That staleness is only fatal for a repeated key, which is why keys still send
a fixed step and let the server accumulate.

The arithmetic lives in state.rs as a pure function, so it is tested on the
native target rather than only in a browser: a click behind the playhead
seeks back, the edges are exactly the track's ends, a fraction outside [0, 1]
is clamped rather than extrapolated, and a duration of 0 (or a non-finite
fraction, meaning a zero-width element) declines instead of seeking somewhere
arbitrary. Geometry comes from current_target, since the click may land on
the fill rather than the track.

Also enables the web-sys DomRect feature, without which
Element::get_bounding_client_rect does not exist — caught only by the wasm
build, since cbd-web's `mod app` is cfg'd to wasm32 and native clippy never
sees it.

Verified: 119 cbd-tui, 24 cbd-web (3 new), workspace clippy clean under
-D warnings, fmt clean, wasm bundle and book build. Not exercised: an actual
click in a browser.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 14:29:30 +02:00
Test User 956a490606 rss: don't list image enclosures as episodes
Reported from a real subscription: an episode failed to play with "the
format of the data has not been recognized" on a cdn.netzpolitik.org/….jpg
URL, 272428 bytes — exactly the length the feed declared for a JPEG.

audio_url preferred an audio-typed enclosure but then fell back to *any*
media url, and a WordPress blog feed attaches each post's featured image as
an <enclosure>, structurally identical to a podcast enclosure apart from
type="image/jpeg". https://netzpolitik.org/feed/ is 25 items, 25 JPEG
enclosures, and no audio reference of any kind, so every post became an
episode that could not play.

An enclosure is now accepted when its type says audio, when the feed omits
the type (plenty of hand-rolled feeds do), or when a generic
application/octet-stream is backed by an audio file extension — and
rejected otherwise, so images and video are skipped. Audio-typed still
wins, so a show that publishes both plays as audio.

A feed with entries and no audio now says so ("this looks like a blog feed
rather than a podcast feed"): an empty listing explains nothing on its own.
No URL in the message — a feed URL is a credential.

Also raises DEFAULT_MAX_FEED_BYTES from 8 to 32 MiB. Logbuch:Netzpolitik,
559 episodes in, is a healthy 6.8 MiB: feeds carry their whole back
catalogue with full show notes, so the first cap would have started
refusing real feeds within a year or two. Still bounded, still enforced
while reading rather than after, still lowerable via max_feed_bytes.

Verified: 30 rssdy tests (5 new, over feed-rs's real entry shapes:
image-only, mixed image+audio, untyped, generic-with-extension,
generic-without, video), and both live feeds — netzpolitik.org/feed/ now
yields 0 episodes with the warning, logbuch-netzpolitik.de/feed/mp3
(-> feeds.metaebene.me/lnp/mp3) yields 559 audio/mpeg episodes whose
HH:MM:SS durations parse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 14:16:25 +02:00
Test User d0b01c75d0 seek: move 15 seconds inside the playing track with Ctrl-b / Ctrl-f
The audio engine could already seek and nothing called it: no rpc, no
playback command, no binding. This wires it from every client.

The one real decision was where the arithmetic lives. A seek is relative
but the engine seeks to an absolute position, so either the client computes
a target from the last position update or it sends an offset and the engine
adds it to the live position. The offset wins on the ordinary case of
pressing the key twice: positions are broadcast on a 250 ms tick and then
cross the network, so three quick presses would all read the same stale
base and jump 15 s instead of 45. It also keeps the clamping policy in one
place instead of three clients, and matters more while paused, where no
position updates arrive at all.

So the wire carries sint32 delta_millis and the step is a client constant.

It also uncovered a live panic: seek_to did
`time.clamp(Duration::from_secs(1), duration)`, and `Ord::clamp` asserts
min <= max while `duration()` returns 0 for any source that reported no
length (HLS, some streams). That panicked the engine thread, killing audio.
Unreachable only because nothing called it; wiring seek made it reachable
from user input. It is now saturating arithmetic in a pure, exhaustively
tested function.

Boundaries: backwards saturates at 0 and never enters the previous track;
forwards stops 1 s short of the end so the track finishes through the
ordinary end-of-stream path (which advances the queue) instead of relying
on seek-to-exact-end, which decoders disagree about; an unknown duration
has no upper clamp. The engine emits Elapsed from the seek path itself,
because tick() skips a paused sink and a paused seek would otherwise show
the old position until playback resumed. An unseekable source (SoundCloud
HLS) warns server-side and changes nothing.

Ctrl-b/Ctrl-f join the existing control-chord family; plain f still toggles
the spectrum because lookup compares every modifier but SHIFT exactly. In
the browser Ctrl-f would open the find bar, but the keydown handler already
prevent_defaults any chord that resolves.

Seek is deliberately not tested through the playback loop: every test there
builds a real Player whose engine thread opens an audio device, so a test
that awaits a player reply passes or hangs depending on whether the machine
has working audio. The arithmetic is tested as a pure function, and the
rpc -> command mapping (the layer the paste bug lived in) in rpc.rs.

Verified: 20 audio-player tests (5 new: i64::MIN/MAX, zero duration,
sub-second tracks, composition, near-end saturation), 95 crabidy-server,
119 cbd-tui, 21 cbd-web, 58 server tests with --no-default-features,
workspace clippy clean under -D warnings, fmt clean, wasm bundle and book
build. Not exercised: an actual seek through an audio device.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 14:16:08 +02:00
30 changed files with 1478 additions and 57 deletions

249
architecture/seek.md Normal file
View File

@ -0,0 +1,249 @@
# Seek within the playing track
## Context
Playback exposes track-level controls only: `TogglePlay`, `Next`, `Prev`,
`RestartTrack`. There is no way to move inside a track, which hurts most
where tracks are longest — the podcast providers (`/rss`, `/fyyd`) and
audiobooks (`/abs`), where "I missed that sentence" and "skip the ad" are
the two most common wishes.
The audio engine can already do it: `PlayerEngine::seek_to` and
`PlayerEngineCommand::SeekTo` exist and are wired through
`Player::seek_to`. **Nothing calls them.** There is no RPC, no playback
command, no binding — and the one implementation that exists carries a
panic (D5). So this feature is almost entirely *wiring*, plus a decision
about where the arithmetic lives.
Goal: a step of about 15 seconds forward and backward, from every client.
## Assumptions
Stated explicitly and settled by reading the code rather than by asking:
- **A1 — Within the current track only.** A backward seek at 3 s lands at
0, it does not step into the previous track; `<` / `Ctrl-p` are the
track-level controls and stay that way. Crossing tracks would need the
queue lock and the previous track's duration, for a gesture nobody
expects to do that.
- **A2 — The server owns the position.** Clients render `TrackPosition`
from the update stream and never predict it, exactly as they do for
volume and play state. A seek therefore needs no optimistic UI and no
rollback when it is refused.
- **A3 — Fire-and-forget.** Like every other playback RPC, `Seek` returns
as soon as the command is queued. A refused seek is a server-side
warning, not a client-visible error (D7).
- **A4 — No autoplay.** Seeking with nothing loaded does nothing; it does
not start the queue. `TogglePlay` and `RestartTrack` are the controls
that resume an idle player.
## Options
The only real question is **where the arithmetic lives**: a seek is
relative ("15 seconds back"), but the engine seeks to an absolute
position.
### Option A — the client computes the target
`Seek(position_millis)`. Each client takes the last `TrackPosition` it
received, adds ±15 s, clamps against the duration it was told, and sends
an absolute target.
- The wire is a plain absolute seek, which a click on the progress bar
maps onto directly. (That turned out not to need it either — see D10.)
- But the base is **stale**: positions are broadcast on a 250 ms tick and
then cross the network. The step is 15 s, so 250 ms of drift is not the
problem — **repetition** is. Press `.` three times quickly and all
three presses read the *same* broadcast position, compute the *same*
target, and the track advances 15 s instead of 45. That is the normal
way people use a seek key.
- Every client re-implements clamping, so the end-of-track and
unknown-duration edges have to be right in three places (TUI, web,
CLI) instead of one.
- While **paused** no position updates arrive at all (the tick loop skips
a paused sink), so a client's base position goes stale the moment you
pause, and a paused seek would be computed from wherever playback
stopped rather than from where the last seek left it.
### Option B — the client sends a delta *(chosen)*
`Seek(delta_millis)`, signed. The engine adds it to the live sink
position, clamps, and seeks.
- Repeated presses compose exactly: each one reads the position the
previous one produced, because the engine is a single-threaded command
loop and `try_seek` updates the position before returning.
- Clamping policy lives in one place, next to the duration the engine
already tracks.
- Clients get simpler, not more complex: a constant and one RPC call.
- Cost: absolute seek is not on the wire. A compatible proto3 addition if
something ever needs it; click-to-seek did not (D10).
**Decision: Option B.** The composition argument decides it — Option A is
wrong in the ordinary case of pressing the key twice.
## Decisions
- **D1 — The delta crosses the wire; the engine accumulates.** Clients
send a signed offset, never a target.
- **D2 — One new RPC, relative only.** `Seek(SeekRequest) -> SeekResponse`
with `sint32 delta_millis = 1`. Milliseconds because `TrackPosition`
already speaks milliseconds, so a future sub-second step needs no wire
change; `sint32` because zigzag encoding keeps negatives at one byte.
Absolute seek stays deferred — adding a field later is compatible.
- **D3 — The step size is a client constant.** `SEEK_STEP` = 15 s, one
named constant per client. The wire carries milliseconds, so making the
step configurable later is a client-only change.
- **D4 — Clamping.** Backward past the start lands at 0. Forward past the
end clamps to `duration - 1 s` when the duration is known, so the track
runs out through the ordinary end-of-stream path and advances to the
next one; the engine never has to seek *to* the exact end, whose
behaviour differs per decoder. With an **unknown** duration (a
length-less stream reports 0) there is no upper clamp — the decoder
decides, and a refusal is just a warning.
- **D5 — Fix the panic in `seek_to`.** It currently does
`time.clamp(Duration::from_secs(1), duration)`. `Ord::clamp` asserts
`min <= max`, and `duration()` yields **0** whenever the duration is
unknown (HLS, some network streams) — so that call panics the engine
thread, taking audio down with it, on any duration-less source. It is
unreachable today only because nothing calls `seek_to`; wiring seek
makes it reachable from **user input**, which the hard rules forbid.
Replaced by saturating arithmetic with no assertions. The 1-second
floor also went: landing at 0 is exactly what a backward seek near the
start means.
- **D6 — The engine reports the new position immediately.** After a
successful seek it emits `PlayerMessage::Elapsed` rather than waiting
for the next 250 ms tick. This is not just about latency: `tick()`
returns early for a paused or empty sink, so without this a seek while
paused would leave every client showing the old position until playback
resumed.
- **D7 — Unseekable sources warn and change nothing.** HLS (SoundCloud)
is decoded with `seekable = false` precisely so symphonia never seeks
it, and `try_seek` reports `NotSupported`. The server logs a warning
and broadcasts nothing; clients keep showing the true position because
they never moved it (A2). Consistent with how `pause` and
`set_volume` failures are handled — no new status codes, no new
client-side error path.
- **D8 — Bindings: two physical keys carry all four moves.** `,`/`.` seek
15 seconds back/forward and their shifted forms `<`/`>` skip a whole
track, in the **global** scope of both clients. The pair is
self-teaching (same key, shift = bigger jump), `<`/`>` are the marks
engraved on those keys, and they match mpv's playlist controls. Settled
after two rounds: `,`/`.`, then `Ctrl-b`/`Ctrl-f` at the user's request,
then back once `<`/`>` earned their place for a separate reason (below).
The deciding property is that these are **plain printable characters**,
so they collide with nothing a browser reserves. `Ctrl-n` — the
long-standing "next track" — cannot be claimed in a browser at all:
Chrome and Firefox handle it as "new window" above the page, where
`preventDefault` cannot reach, unlike `Ctrl-f`, `Ctrl-p` or `Ctrl-b`.
Reaching for the reserved-chord list is a trap the alphabetic keys
avoid entirely. `Ctrl-n`/`Ctrl-p` stay bound as the terminal's primary
chords (and `Ctrl-p` works in the browser too); the web help documents
`<`/`>`, the ones that always work.
- **D9 — No feature flag.** Seek is a few lines in the engine and one RPC
arm; it carries no dependency of its own, so by the rule in
`architecture/build-features.md` ("a feature must pay for itself in
dependencies") it does not earn one.
- **D10 — The web progress bar is clickable, still as an offset.** A
click maps the pointer's x within the gauge to a fraction of the
duration and sends `target - position`. Relative is *right* here even
though the gesture is absolute: the position it subtracts is the one
drawn on the bar the user just aimed at, and it is at most one 250 ms
tick stale — far under one pixel of the bar for any track worth seeking
in. (The same staleness is fatal for a repeated *key*, which is why
keys send a fixed step; see the Options section.) So click-to-seek
needs no absolute field on the wire after all. The arithmetic lives in
`state.rs` as a pure function, so it is unit-tested on the native
target rather than only in a browser; geometry comes from
`current_target` because the click may land on the fill rather than the
track; a duration of 0 declines the click, since a bar with no scale
has no position to click at.
The web transport bar also gets `⏪`/`⏩` buttons, and the CLI gets
`cbd global seek <SECONDS>`, which accepts negatives.
## Structure
```d2
direction: right
clients: Clients {
tui: cbd-tui\n`,` / `.`
web: cbd-web\n`,` / `.` / ⏪⏩
cli: cbd-cli\nglobal seek N
}
server: crabidy-server {
rpc: RpcService::seek
loop: playback loop\nPlaybackCommand::Seek
fwd: poll_play_bus
}
engine: audio-player {
player: Player::seek_by
eng: PlayerEngine\nseek_by -> seek_clamped
sink: rodio Player\ntry_seek
}
clients.tui -> server.rpc: Seek{delta_millis}
clients.web -> server.rpc: Seek{delta_millis}
clients.cli -> server.rpc: Seek{delta_millis}
server.rpc -> server.loop: bounded channel
server.loop -> engine.player: seek_by(delta)
engine.player -> engine.eng: SeekBy(delta, reply)
engine.eng -> engine.sink: try_seek(clamped target)
engine.eng -> server.fwd: PlayerMessage::Elapsed
server.fwd -> server.loop: PositionChanged
server.loop -> clients: TrackPosition broadcast
```
The delta stays a delta all the way down to the engine; the only place an
absolute position is computed is `PlayerEngine::seek_by`, which is also
the only place that knows the live sink position and the track duration.
## Boundaries
- **`audio-player`** gains `Player::seek_by(delta_millis: i64)` and
`PlayerEngineCommand::SeekBy`. The clamping helper is private; both
`seek_to` (absolute, kept for the deferred extension) and `seek_by` go
through it, so there is one clamping policy, not two.
- **`crabidy-core`** gains the `Seek` RPC and its two messages.
- **`crabidy-server`** gains `PlaybackCommand::Seek { delta_millis }` and
the RPC arm. The playback loop only forwards; it holds no seek state.
- **Clients** gain an action, a binding, an RPC wrapper, and a constant
each. No client-side position arithmetic anywhere (A2, D1).
## Risks
- **`try_seek` blocks the engine thread** for up to ~5 ms (it waits for
the audio callback to pick the order up). That thread already blocks
for up to 30 s opening a network stream, so this is not a new class of
stall — but it does mean seek is serialized behind an in-flight track
open, which is correct anyway.
- **Seeking while paused** relies on rodio's `periodic_access` sitting
*outside* `pausable` in the chain: a paused sink keeps being polled for
silence, so the seek order is still picked up. Verified in rodio 0.22.2
(`src/player.rs`); if a future rodio inverts that order, a paused seek
would block until unpause. Worth re-checking on a rodio bump.
- **Network-backed sources** seek inside `stream_download`'s temp
storage. A seek outside the downloaded window triggers a fresh range
request, so a long forward seek can stall audio briefly. Bounded by the
existing HTTP timeouts; no new failure mode.
- **A 15-second step on a very short track** always lands in the clamp,
which is why the clamp has to be arithmetic that cannot assert (D5).
## Deferred
Recorded, not dropped:
- **Absolute seek** (`position_millis`). Click-to-seek shipped without it
(D10), so the only remaining use would be a client that wants to name a
position without knowing the current one. A compatible proto3 addition
if that ever appears.
- **Configurable step size** in the client configs, and a larger step on
`<`/`>` (same physical keys, shifted). Client-only once wanted.
- **Chapter-aware seek** for podcasts and audiobooks. No provider exposes
chapter marks through the library model today.

View File

@ -109,6 +109,22 @@ impl Player {
rx.recv_async().await? rx.recv_async().await?
} }
/// Seeks `delta_millis` from the current position — negative seeks back —
/// and resolves to the position actually reached, clamped to the track.
///
/// Relative rather than absolute on purpose: the engine adds the offset to
/// the live position, so pressing a seek key several times in a row
/// composes instead of repeatedly targeting the same place
/// (architecture/seek.md D1). Fails when nothing is loaded, and when the
/// source cannot seek at all (HLS).
pub async fn seek_by(&self, delta_millis: i64) -> Result<Duration> {
let (tx, rx) = flume::bounded(1);
self.tx_engine
.send_async(PlayerEngineCommand::SeekBy(delta_millis, tx))
.await?;
rx.recv_async().await?
}
pub async fn volume(&self) -> Result<f32> { pub async fn volume(&self) -> Result<f32> {
let (tx, rx) = flume::bounded(1); let (tx, rx) = flume::bounded(1);
self.tx_engine self.tx_engine

View File

@ -38,6 +38,44 @@ fn display_source(source_str: &str) -> String {
/// Interval between elapsed-position updates while playing. /// Interval between elapsed-position updates while playing.
const TICK_INTERVAL: Duration = Duration::from_millis(250); const TICK_INTERVAL: Duration = Duration::from_millis(250);
/// How close to the end of a track a forward seek may land.
///
/// Seeking to the exact end is decoder-dependent — symphonia, the Opus
/// reader and a windowed http stream all disagree about whether that is an
/// error — so an overshooting forward seek stops here instead and lets the
/// track run out through the ordinary end-of-stream path, which advances the
/// queue (architecture/seek.md D4).
const SEEK_END_MARGIN: Duration = Duration::from_secs(1);
/// The absolute position a seek of `delta_millis` from `position` should land
/// on, given the track's `duration` if it is known.
///
/// Pure arithmetic, and deliberately free of assertions: it is driven
/// straight from user input, so every input — a delta larger than the track,
/// `i64::MIN`, a duration of zero, a track shorter than the step — has to
/// produce a position rather than a panic. (The previous implementation used
/// `clamp(Duration::from_secs(1), duration)`, which asserts `min <= max` and
/// therefore panicked on every duration-less stream.)
///
/// Saturates at 0 going back — a backward seek near the start means "go to
/// the beginning", not "go to the previous track". Going forward it stops
/// [`SEEK_END_MARGIN`] short of the end when the duration is known; a
/// length-less stream reports no duration, and then there is nothing to clamp
/// against, so the target passes through and the decoder decides whether it
/// can honour it.
fn seek_target(position: Duration, delta_millis: i64, duration: Option<Duration>) -> Duration {
let step = Duration::from_millis(delta_millis.unsigned_abs());
let target = if delta_millis < 0 {
position.saturating_sub(step)
} else {
position.saturating_add(step)
};
match duration {
Some(total) if !total.is_zero() => target.min(total.saturating_sub(SEEK_END_MARGIN)),
_ => target,
}
}
pub enum PlayerEngineCommand { pub enum PlayerEngineCommand {
Play(String, Sender<Result<MediaInfo>>), Play(String, Sender<Result<MediaInfo>>),
SetVolume(f32, Sender<f32>), SetVolume(f32, Sender<f32>),
@ -49,6 +87,10 @@ pub enum PlayerEngineCommand {
GetDuration(Sender<Result<Duration>>), GetDuration(Sender<Result<Duration>>),
GetElapsed(Sender<Result<Duration>>), GetElapsed(Sender<Result<Duration>>),
SeekTo(Duration, Sender<Result<Duration>>), SeekTo(Duration, Sender<Result<Duration>>),
/// Seek by a signed offset in milliseconds from the live position
/// (negative seeks back). Relative rather than absolute so repeated
/// presses compose (architecture/seek.md D1).
SeekBy(i64, Sender<Result<Duration>>),
GetVolume(Sender<f32>), GetVolume(Sender<f32>),
ToggleMute(Sender<bool>), ToggleMute(Sender<bool>),
GetPaused(Sender<Result<bool>>), GetPaused(Sender<Result<bool>>),
@ -243,6 +285,9 @@ impl PlayerEngine {
PlayerEngineCommand::GetDuration(tx) => send_reply(tx, self.duration()), PlayerEngineCommand::GetDuration(tx) => send_reply(tx, self.duration()),
PlayerEngineCommand::GetElapsed(tx) => send_reply(tx, self.elapsed()), PlayerEngineCommand::GetElapsed(tx) => send_reply(tx, self.elapsed()),
PlayerEngineCommand::SeekTo(time, tx) => send_reply(tx, self.seek_to(time)), PlayerEngineCommand::SeekTo(time, tx) => send_reply(tx, self.seek_to(time)),
PlayerEngineCommand::SeekBy(delta_millis, tx) => {
send_reply(tx, self.seek_by(delta_millis));
}
PlayerEngineCommand::SetVolume(volume, tx) => { PlayerEngineCommand::SetVolume(volume, tx) => {
send_reply(tx, self.set_volume(volume)); send_reply(tx, self.set_volume(volume));
} }
@ -541,13 +586,57 @@ impl PlayerEngine {
Ok(self.sink.get_pos()) Ok(self.sink.get_pos())
} }
/// Seeks to an absolute position, clamped by [`seek_target`]. Returns the
/// position actually reached.
pub fn seek_to(&self, time: Duration) -> Result<Duration> { pub fn seek_to(&self, time: Duration) -> Result<Duration> {
let duration = self.duration().unwrap_or_else(|_| self.sink.get_pos()); if self.is_stopped() {
let time = time.clamp(Duration::from_secs(1), duration); return Err(PlayerEngineError::NotPlaying.into());
}
self.seek_sink(seek_target(time, 0, self.known_duration()))
}
/// Seeks `delta_millis` from the **live** playback position (a negative
/// delta seeks back), clamped by [`seek_target`]. Returns the position
/// actually reached.
///
/// The offset is applied here rather than by the caller so that repeated
/// presses compose: each seek starts from where the previous one landed,
/// not from a position update that was broadcast up to a tick ago
/// (architecture/seek.md D1). Seeking with nothing loaded is an error, not
/// a way to start playback.
pub fn seek_by(&self, delta_millis: i64) -> Result<Duration> {
if self.is_stopped() {
return Err(PlayerEngineError::NotPlaying.into());
}
let target = seek_target(self.sink.get_pos(), delta_millis, self.known_duration());
self.seek_sink(target)
}
/// Seeks the sink to an already-clamped absolute position and reports the
/// position reached.
///
/// Reporting from here rather than leaving it to [`Self::tick`] is not
/// only about latency: the tick returns early for a paused sink, so
/// without this a seek while paused would leave every client showing the
/// pre-seek position until playback resumed (architecture/seek.md D6).
fn seek_sink(&self, target: Duration) -> Result<Duration> {
self.sink self.sink
.try_seek(time) .try_seek(target)
.map_err(|err| anyhow!("seek failed: {err}"))?; .map_err(|err| anyhow!("seek failed: {err}"))?;
Ok(self.sink.get_pos()) let elapsed = self.sink.get_pos();
debug!(?target, ?elapsed, "seeked");
self.notify(PlayerMessage::Elapsed {
duration: self.known_duration().unwrap_or_default(),
elapsed,
});
Ok(elapsed)
}
/// The current track's total duration when the source reported one.
/// `None` covers both "no track loaded" and "a length-less stream", which
/// clamping treats alike.
fn known_duration(&self) -> Option<Duration> {
self.media_info.as_ref().and_then(|info| info.duration)
} }
/// The user's intended volume — the level playback would resume at, /// The user's intended volume — the level playback would resume at,
@ -637,6 +726,107 @@ fn read_header<R: Read>(reader: &mut R, buf: &mut [u8]) -> Result<usize> {
mod tests { mod tests {
use super::*; use super::*;
/// A 10-minute track, the ordinary case.
fn ten_minutes() -> Option<Duration> {
Some(Duration::from_secs(600))
}
#[test]
fn seek_target_moves_by_the_delta() {
let at = Duration::from_secs(100);
assert_eq!(
seek_target(at, 15_000, ten_minutes()),
Duration::from_secs(115)
);
assert_eq!(
seek_target(at, -15_000, ten_minutes()),
Duration::from_secs(85)
);
}
/// Successive seeks compose because each one starts from the position the
/// previous one produced — the reason the delta is applied here and not by
/// the client (architecture/seek.md D1).
#[test]
fn successive_seeks_compose() {
let mut at = Duration::from_secs(100);
for _ in 0..3 {
at = seek_target(at, -15_000, ten_minutes());
}
assert_eq!(at, Duration::from_secs(55));
}
#[test]
fn seek_target_saturates_at_the_start() {
// Back past the beginning lands at 0 — not below, and not in the
// previous track.
assert_eq!(
seek_target(Duration::from_secs(3), -15_000, ten_minutes()),
Duration::ZERO
);
assert_eq!(
seek_target(Duration::ZERO, -15_000, ten_minutes()),
Duration::ZERO
);
}
/// Forward past the end stops a second short of it, so the track finishes
/// through the ordinary end-of-stream path and the queue advances.
#[test]
fn seek_target_saturates_near_the_end() {
assert_eq!(
seek_target(Duration::from_secs(595), 15_000, ten_minutes()),
Duration::from_secs(599)
);
}
/// A length-less stream reports no duration. The naive clamp turned that
/// into "seek to zero" (or panicked); the target has to pass through so
/// the decoder can decide.
#[test]
fn an_unknown_duration_does_not_clamp_forward() {
let at = Duration::from_secs(100);
assert_eq!(seek_target(at, 15_000, None), Duration::from_secs(115));
// `MediaInfo` carries `Some(0)` for some sources; same meaning.
assert_eq!(
seek_target(at, 15_000, Some(Duration::ZERO)),
Duration::from_secs(115)
);
}
/// Every extreme has to yield a position rather than a panic: the deltas
/// come from user input, and `duration` from whatever a feed claimed.
#[test]
fn seek_target_never_panics_on_extremes() {
let positions = [Duration::ZERO, Duration::from_secs(1), Duration::MAX];
let deltas = [i64::MIN, -1, 0, 1, i64::MAX];
let durations = [
None,
Some(Duration::ZERO),
// A track shorter than the end margin: the ceiling saturates to 0.
Some(Duration::from_millis(400)),
Some(Duration::from_secs(600)),
Some(Duration::MAX),
];
for position in positions {
for delta in deltas {
for duration in durations {
let _ = seek_target(position, delta, duration);
}
}
}
// The case that used to panic outright: unknown duration, and a
// sub-second track where the old floor exceeded the old ceiling.
assert_eq!(
seek_target(Duration::ZERO, 15_000, None),
Duration::from_secs(15)
);
assert_eq!(
seek_target(Duration::ZERO, 15_000, Some(Duration::from_millis(400))),
Duration::ZERO
);
}
#[test] #[test]
fn logged_sources_never_carry_url_tokens() { fn logged_sources_never_carry_url_tokens() {
// Stream URLs embed access tokens; only scheme and host may be // Stream URLs embed access tokens; only scheme and host may be

View File

@ -20,8 +20,8 @@ use crabidy_core::proto::crabidy::{
ChangeVolumeRequest, ClearQueueRequest, CreateLibraryNodeRequest, DeleteLibraryNodeRequest, ChangeVolumeRequest, ClearQueueRequest, CreateLibraryNodeRequest, DeleteLibraryNodeRequest,
GetLibraryNodeRequest, InitRequest, InsertRequest, LibraryNode, NextRequest, PrevRequest, GetLibraryNodeRequest, InitRequest, InsertRequest, LibraryNode, NextRequest, PrevRequest,
Queue, RemoveRequest, RenameLibraryNodeRequest, ReplaceRequest, RestartTrackRequest, Queue, RemoveRequest, RenameLibraryNodeRequest, ReplaceRequest, RestartTrackRequest,
SaveQueueRequest, SetCurrentRequest, StopRequest, ToggleMuteRequest, TogglePlayRequest, SaveQueueRequest, SeekRequest, SetCurrentRequest, StopRequest, ToggleMuteRequest,
ToggleRepeatRequest, ToggleShuffleRequest, Track, TogglePlayRequest, ToggleRepeatRequest, ToggleShuffleRequest, Track,
}; };
use crate::{Connection, GlobalCmd, LibraryCmd, QueueCmd, RemoteCmd}; use crate::{Connection, GlobalCmd, LibraryCmd, QueueCmd, RemoteCmd};
@ -281,6 +281,16 @@ async fn run_global(client: &mut Client, cmd: GlobalCmd) -> Result<(), Box<dyn s
.map_err(rpc_error)?; .map_err(rpc_error)?;
println!("restarted the current track"); println!("restarted the current track");
} }
GlobalCmd::Seek { seconds } => {
// Milliseconds on the wire; the server clamps to the track.
client
.seek(SeekRequest {
delta_millis: seconds.saturating_mul(1_000),
})
.await
.map_err(rpc_error)?;
println!("seeked {seconds:+} seconds");
}
GlobalCmd::Mute => { GlobalCmd::Mute => {
client client
.toggle_mute(ToggleMuteRequest {}) .toggle_mute(ToggleMuteRequest {})

View File

@ -113,6 +113,13 @@ pub enum GlobalCmd {
Prev, Prev,
/// Restart the current track. /// Restart the current track.
Restart, Restart,
/// Seek inside the current track by a number of seconds (negative seeks
/// back, e.g. `-15`). Stays within the track: it saturates at the start,
/// and overshooting the end lets the track finish and the queue advance.
Seek {
#[arg(allow_negative_numbers = true)]
seconds: i32,
},
/// Toggle mute. /// Toggle mute.
Mute, Mute,
/// Change the volume by a delta in [-1.0, 1.0] (e.g. `0.05`, `-0.1`). /// Change the volume by a delta in [-1.0, 1.0] (e.g. `0.05`, `-0.1`).

View File

@ -42,6 +42,14 @@ pub enum Action {
ToggleRepeat, ToggleRepeat,
NextTrack, NextTrack,
PrevTrack, PrevTrack,
/// Move the playing position back inside the current track by
/// [`super::SEEK_STEP_MILLIS`]. Never leaves the track: near the start it
/// simply lands at 0 (architecture/seek.md A1).
SeekBackward,
/// Move the playing position forward inside the current track by
/// [`super::SEEK_STEP_MILLIS`]. Overshooting the end lets the track
/// finish, which advances the queue.
SeekForward,
/// Show or hide the frequency-spectrum visualizer in the now-playing /// Show or hide the frequency-spectrum visualizer in the now-playing
/// pane (client-side only; the server keeps streaming the bars). /// pane (client-side only; the server keeps streaming the bars).
ToggleSpectrum, ToggleSpectrum,
@ -229,6 +237,39 @@ pub const BINDINGS: &[Binding] = &[
action: Action::PrevTrack, action: Action::PrevTrack,
description: "Previous track", description: "Previous track",
}, },
// Two physical keys carry all four moves: unshifted seeks 15 seconds,
// shifted skips a whole track, and `<`/`>` are the marks engraved on them.
// They also give the browser client working track-skip keys, which it
// otherwise lacks — Chrome and Firefox reserve `Ctrl-n` for "new window"
// at a level `preventDefault` cannot reach.
Binding {
scope: Scope::Global,
mods: KeyModifiers::NONE,
code: KeyCode::Char(','),
action: Action::SeekBackward,
description: "Seek back 15 seconds",
},
Binding {
scope: Scope::Global,
mods: KeyModifiers::NONE,
code: KeyCode::Char('.'),
action: Action::SeekForward,
description: "Seek forward 15 seconds",
},
Binding {
scope: Scope::Global,
mods: KeyModifiers::SHIFT,
code: KeyCode::Char('<'),
action: Action::PrevTrack,
description: "Previous track",
},
Binding {
scope: Scope::Global,
mods: KeyModifiers::SHIFT,
code: KeyCode::Char('>'),
action: Action::NextTrack,
description: "Next track",
},
Binding { Binding {
scope: Scope::Global, scope: Scope::Global,
mods: KeyModifiers::NONE, mods: KeyModifiers::NONE,
@ -858,6 +899,47 @@ mod tests {
); );
} }
/// Two physical keys carry all four moves in every focus: unshifted seeks
/// 15 seconds, shifted skips a whole track. The shifted forms must resolve
/// whether or not the terminal reports `SHIFT` (they are `Char` codes).
#[test]
fn seek_and_skip_share_two_keys_in_any_focus() {
for focus in [UiFocus::Library, UiFocus::Queue] {
assert_eq!(
lookup(focus, false, key(KeyCode::Char(','), KeyModifiers::NONE)),
Some(Action::SeekBackward)
);
assert_eq!(
lookup(focus, false, key(KeyCode::Char('.'), KeyModifiers::NONE)),
Some(Action::SeekForward)
);
for mods in [KeyModifiers::NONE, KeyModifiers::SHIFT] {
assert_eq!(
lookup(focus, false, key(KeyCode::Char('<'), mods)),
Some(Action::PrevTrack)
);
assert_eq!(
lookup(focus, false, key(KeyCode::Char('>'), mods)),
Some(Action::NextTrack)
);
}
// The long-standing control chords still work in a terminal.
assert_eq!(
lookup(focus, false, key(KeyCode::Char('n'), KeyModifiers::CONTROL)),
Some(Action::NextTrack)
);
}
// And the help modal still swallows them.
assert_eq!(
lookup(
UiFocus::Library,
true,
key(KeyCode::Char('.'), KeyModifiers::NONE)
),
None
);
}
#[test] #[test]
fn spectrum_moved_off_v_to_f() { fn spectrum_moved_off_v_to_f() {
// The frequency-spectrum toggle now lives on Global `f`, in any focus. // The frequency-spectrum toggle now lives on Global `f`, in any focus.

View File

@ -79,6 +79,15 @@ pub const COLOR_GREEN: Color = Color::Rgb(163, 190, 140);
/// provider). /// provider).
const CURRENT_QUEUE_PATH: &str = "/crabidy/current"; const CURRENT_QUEUE_PATH: &str = "/crabidy/current";
/// How far one press of a seek key moves the playing position, in
/// milliseconds — the unit the wire speaks, so any step is expressible
/// (architecture/seek.md D3).
///
/// The step lives here, in the client: the server is handed an offset and adds
/// it to the live position, which is what makes repeated presses compose. The
/// help text and the docs spell the number out, so change those too.
pub(crate) const SEEK_STEP_MILLIS: i64 = 15_000;
// FIXME: Rename this // FIXME: Rename this
pub enum MessageToUi { pub enum MessageToUi {
Init(InitialData), Init(InitialData),
@ -127,6 +136,11 @@ pub enum MessageFromUi {
NextTrack, NextTrack,
PrevTrack, PrevTrack,
RestartTrack, RestartTrack,
/// Move the position inside the playing track by a signed millisecond
/// offset. Relative: the server adds it to the live position, so holding
/// the key composes instead of aiming at the same spot every time
/// (architecture/seek.md D1).
Seek(i64),
SetCurrentTrack(usize), SetCurrentTrack(usize),
TogglePlay, TogglePlay,
ChangeVolume(f32), ChangeVolume(f32),
@ -525,6 +539,12 @@ impl App {
} }
Action::NextTrack => self.queue.play_next(), Action::NextTrack => self.queue.play_next(),
Action::PrevTrack => self.queue.play_prev(), Action::PrevTrack => self.queue.play_prev(),
Action::SeekBackward => {
let _ = self.tx.send(MessageFromUi::Seek(-SEEK_STEP_MILLIS));
}
Action::SeekForward => {
let _ = self.tx.send(MessageFromUi::Seek(SEEK_STEP_MILLIS));
}
Action::ToggleSpectrum => self.now_playing.toggle_spectrum(), Action::ToggleSpectrum => self.now_playing.toggle_spectrum(),
Action::LibraryFirst => self.library_move(|l| l.first()), Action::LibraryFirst => self.library_move(|l| l.first()),
Action::LibraryLast => self.library_move(|l| l.last()), Action::LibraryLast => self.library_move(|l| l.last()),
@ -834,6 +854,19 @@ mod tests {
let _ = app.dispatch(Action::RestartTrack); let _ = app.dispatch(Action::RestartTrack);
assert!(matches!(rx.try_recv(), Ok(MessageFromUi::RestartTrack))); assert!(matches!(rx.try_recv(), Ok(MessageFromUi::RestartTrack)));
// Seek sends the *step*, signed — never a target position: the server
// adds it to the live position (architecture/seek.md D1).
let _ = app.dispatch(Action::SeekForward);
assert!(
matches!(rx.try_recv(), Ok(MessageFromUi::Seek(d)) if d == SEEK_STEP_MILLIS),
"forward seek must send +one step"
);
let _ = app.dispatch(Action::SeekBackward);
assert!(
matches!(rx.try_recv(), Ok(MessageFromUi::Seek(d)) if d == -SEEK_STEP_MILLIS),
"backward seek must send -one step"
);
let _ = app.dispatch(Action::ToggleMute); let _ = app.dispatch(Action::ToggleMute);
assert!(matches!(rx.try_recv(), Ok(MessageFromUi::ToggleMute))); assert!(matches!(rx.try_recv(), Ok(MessageFromUi::ToggleMute)));

View File

@ -146,6 +146,9 @@ async fn poll(
MessageFromUi::RestartTrack => { MessageFromUi::RestartTrack => {
rpc_client.restart_track().await? rpc_client.restart_track().await?
} }
MessageFromUi::Seek(delta_millis) => {
rpc_client.seek(delta_millis).await?
}
MessageFromUi::SetCurrentTrack(pos) => { MessageFromUi::SetCurrentTrack(pos) => {
rpc_client.set_current_track(pos).await? rpc_client.set_current_track(pos).await?
} }

View File

@ -4,7 +4,7 @@ use crabidy_core::proto::crabidy::{
GetLibraryNodeRequest, GetUpdateStreamRequest, GetUpdateStreamResponse, InitRequest, GetLibraryNodeRequest, GetUpdateStreamRequest, GetUpdateStreamResponse, InitRequest,
InitResponse, InsertRequest, LibraryNode, NextRequest, PrevRequest, QueueRequest, InitResponse, InsertRequest, LibraryNode, NextRequest, PrevRequest, QueueRequest,
RemoveRequest, RenameLibraryNodeRequest, ReplaceRequest, RestartTrackRequest, SaveQueueRequest, RemoveRequest, RenameLibraryNodeRequest, ReplaceRequest, RestartTrackRequest, SaveQueueRequest,
SetCurrentRequest, ToggleMuteRequest, TogglePlayRequest, ToggleRepeatRequest, SeekRequest, SetCurrentRequest, ToggleMuteRequest, TogglePlayRequest, ToggleRepeatRequest,
ToggleShuffleRequest, ToggleShuffleRequest,
}; };
@ -325,6 +325,17 @@ impl RpcClient {
Ok(()) Ok(())
} }
/// Moves the playing position by a signed millisecond offset. The server
/// applies it to the live position and clamps it to the track; a source
/// that cannot seek leaves the position untouched.
pub async fn seek(&mut self, delta_millis: i64) -> Result<(), Box<dyn Error>> {
let seek_request = Request::new(SeekRequest {
delta_millis: delta_millis as i32,
});
self.client.seek(seek_request).await?;
Ok(())
}
pub async fn set_current_track(&mut self, pos: usize) -> Result<(), Box<dyn Error>> { pub async fn set_current_track(&mut self, pos: usize) -> Result<(), Box<dyn Error>> {
let set_current_request = Request::new(SetCurrentRequest { let set_current_request = Request::new(SetCurrentRequest {
position: pos as u32, position: pos as u32,

View File

@ -20,6 +20,10 @@ wasm-bindgen.workspace = true
wasm-bindgen-futures.workspace = true wasm-bindgen-futures.workspace = true
web-sys = { workspace = true, features = [ web-sys = { workspace = true, features = [
"Document", "Document",
# `DomRect` is what `Element::get_bounding_client_rect` returns; the web-sys
# method only exists with the feature on. Click-to-seek needs the gauge's
# geometry to turn a click into a position.
"DomRect",
"Element", "Element",
"HtmlInputElement", "HtmlInputElement",
"KeyboardEvent", "KeyboardEvent",

View File

@ -15,11 +15,17 @@ use leptos::task::spawn_local;
use crate::keymap::{self, Action}; use crate::keymap::{self, Action};
use crate::rpc::Rpc; use crate::rpc::Rpc;
use crate::state::{ use crate::state::{
format_seconds, is_cacheable, track_label, CaptureBoard, Dialog, Focus, LibraryPane, format_seconds, is_cacheable, seek_offset_for_fraction, track_label, CaptureBoard, Dialog,
NamePurpose, QueueCursor, Register, UiItemKind, Focus, LibraryPane, NamePurpose, QueueCursor, Register, UiItemKind,
}; };
const VOLUME_STEP: f32 = 0.1; const VOLUME_STEP: f32 = 0.1;
/// How far one seek press moves the playing position, in milliseconds — the
/// unit the wire speaks (architecture/seek.md D3). The step lives in the
/// client; the server only ever receives an offset and applies it to the live
/// position, which is what makes repeated presses compose.
pub(crate) const SEEK_STEP_MILLIS: i32 = 15_000;
const JUMP: isize = 15; const JUMP: isize = 15;
/// Stream reconnect backoff bounds, milliseconds. /// Stream reconnect backoff bounds, milliseconds.
const BACKOFF_MIN_MS: u32 = 1_000; const BACKOFF_MIN_MS: u32 = 1_000;
@ -397,6 +403,10 @@ impl Store {
Action::RestartTrack => self.call(async |mut rpc: Rpc| rpc.restart_track().await), Action::RestartTrack => self.call(async |mut rpc: Rpc| rpc.restart_track().await),
Action::NextTrack => self.call(async |mut rpc: Rpc| rpc.next().await), Action::NextTrack => self.call(async |mut rpc: Rpc| rpc.next().await),
Action::PrevTrack => self.call(async |mut rpc: Rpc| rpc.prev().await), Action::PrevTrack => self.call(async |mut rpc: Rpc| rpc.prev().await),
Action::SeekBackward => {
self.call(async |mut rpc: Rpc| rpc.seek(-SEEK_STEP_MILLIS).await)
}
Action::SeekForward => self.call(async |mut rpc: Rpc| rpc.seek(SEEK_STEP_MILLIS).await),
Action::VolumeUp => { Action::VolumeUp => {
self.call(async |mut rpc: Rpc| rpc.change_volume(VOLUME_STEP).await) self.call(async |mut rpc: Rpc| rpc.change_volume(VOLUME_STEP).await)
} }
@ -1141,6 +1151,32 @@ fn Transport(store: Store) -> impl IntoView {
PlayState::Loading => "", PlayState::Loading => "",
_ => "", _ => "",
}; };
// Click-to-seek on the progress bar. The click may land on the fill rather
// than the track, so the geometry comes from `current_target` — always the
// element the listener is on — not from the event target.
let on_seek = move |ev: leptos::ev::MouseEvent| {
use wasm_bindgen::JsCast;
let Some(gauge) = ev
.current_target()
.and_then(|target| target.dyn_into::<web_sys::Element>().ok())
else {
return;
};
let rect = gauge.get_bounding_client_rect();
if rect.width() <= 0.0 {
return;
}
let fraction = (f64::from(ev.client_x()) - rect.left()) / rect.width();
let position = store.position.get_untracked();
let Some(delta) = seek_offset_for_fraction(position.position, position.duration, fraction)
else {
// No duration: the bar has no scale, so the click has no meaning.
return;
};
if delta != 0 {
store.call(async move |mut rpc: Rpc| rpc.seek(delta).await);
}
};
let on_volume = move |ev: leptos::ev::Event| { let on_volume = move |ev: leptos::ev::Event| {
if let Ok(target) = event_target_value(&ev).parse::<f32>() { if let Ok(target) = event_target_value(&ev).parse::<f32>() {
let delta = target - store.volume.get_untracked(); let delta = target - store.volume.get_untracked();
@ -1150,11 +1186,15 @@ fn Transport(store: Store) -> impl IntoView {
view! { view! {
<footer class="transport"> <footer class="transport">
<div class="controls"> <div class="controls">
<button class="ghost" title="previous (Ctrl-p)" <button class="ghost" title="previous track (<)"
on:click=move |_| store.dispatch(Action::PrevTrack)>""</button> on:click=move |_| store.dispatch(Action::PrevTrack)>""</button>
<button class="ghost" title="back 15 seconds (,)"
on:click=move |_| store.dispatch(Action::SeekBackward)>""</button>
<button class="ghost big" title="play/pause (Space)" <button class="ghost big" title="play/pause (Space)"
on:click=move |_| store.dispatch(Action::TogglePlay)>{state_symbol}</button> on:click=move |_| store.dispatch(Action::TogglePlay)>{state_symbol}</button>
<button class="ghost" title="next (Ctrl-n)" <button class="ghost" title="forward 15 seconds (.)"
on:click=move |_| store.dispatch(Action::SeekForward)>""</button>
<button class="ghost" title="next track (>)"
on:click=move |_| store.dispatch(Action::NextTrack)>""</button> on:click=move |_| store.dispatch(Action::NextTrack)>""</button>
<button class="ghost" title="restart track (r)" <button class="ghost" title="restart track (r)"
on:click=move |_| store.dispatch(Action::RestartTrack)>""</button> on:click=move |_| store.dispatch(Action::RestartTrack)>""</button>
@ -1192,7 +1232,7 @@ fn Transport(store: Store) -> impl IntoView {
<span class="time"> <span class="time">
{move || format_seconds(store.position.get().position / 1000)} {move || format_seconds(store.position.get().position / 1000)}
</span> </span>
<div class="gauge"> <div class="gauge" title="click to seek" on:click=on_seek>
<div <div
class="gauge-fill" class="gauge-fill"
style:width=move || { style:width=move || {

View File

@ -21,6 +21,13 @@ pub enum Action {
ToggleRepeat, ToggleRepeat,
NextTrack, NextTrack,
PrevTrack, PrevTrack,
/// Move the playing position back inside the current track by
/// [`crate::app::SEEK_STEP_MILLIS`]. Near the start it lands at 0 rather
/// than stepping into the previous track (architecture/seek.md A1).
SeekBackward,
/// Move the playing position forward by the same step; overshooting the
/// end lets the track finish, which advances the queue.
SeekForward,
LibraryFirst, LibraryFirst,
LibraryLast, LibraryLast,
LibraryNext, LibraryNext,
@ -89,6 +96,11 @@ pub const HELP: &[HelpEntry] = &[
key: "r", key: "r",
description: "Restart current track", description: "Restart current track",
}, },
HelpEntry {
scope: "Global",
key: ", / .",
description: "Seek back / forward 15 seconds",
},
HelpEntry { HelpEntry {
scope: "Global", scope: "Global",
key: "K", key: "K",
@ -116,13 +128,8 @@ pub const HELP: &[HelpEntry] = &[
}, },
HelpEntry { HelpEntry {
scope: "Global", scope: "Global",
key: "Ctrl-n", key: "< / >",
description: "Next track", description: "Previous / next track",
},
HelpEntry {
scope: "Global",
key: "Ctrl-p",
description: "Previous track",
}, },
HelpEntry { HelpEntry {
scope: "Library", scope: "Library",
@ -287,6 +294,10 @@ pub fn lookup(focus: Focus, help_open: bool, key: &str, ctrl: bool) -> Option<Ac
} }
if ctrl { if ctrl {
return match key { return match key {
// Mirrors the TUI, where these are the primary track-skip chords.
// `Ctrl-p` resolves and is `prevent_default`ed; `Ctrl-n` is reserved
// by Chrome and Firefox for "new window" above the page and never
// reaches us, which is why `<`/`>` exist.
"n" => Some(Action::NextTrack), "n" => Some(Action::NextTrack),
"p" => Some(Action::PrevTrack), "p" => Some(Action::PrevTrack),
"d" => Some(match focus { "d" => Some(match focus {
@ -305,6 +316,13 @@ pub fn lookup(focus: Focus, help_open: bool, key: &str, ctrl: bool) -> Option<Ac
"Tab" => Some(Action::CycleFocus), "Tab" => Some(Action::CycleFocus),
" " => Some(Action::TogglePlay), " " => Some(Action::TogglePlay),
"r" => Some(Action::RestartTrack), "r" => Some(Action::RestartTrack),
// Two physical keys, all four moves: unshifted seeks 15 seconds,
// shifted skips a track. Plain printable characters, so unlike the
// control chords they collide with nothing the browser reserves.
"," => Some(Action::SeekBackward),
"." => Some(Action::SeekForward),
"<" => Some(Action::PrevTrack),
">" => Some(Action::NextTrack),
"K" => Some(Action::VolumeUp), "K" => Some(Action::VolumeUp),
"J" => Some(Action::VolumeDown), "J" => Some(Action::VolumeDown),
"m" => Some(Action::ToggleMute), "m" => Some(Action::ToggleMute),
@ -377,6 +395,41 @@ mod tests {
); );
} }
/// Seek is a control chord in either pane, and claiming `Ctrl-f` leaves
/// plain `f` alone (the ctrl branch is checked first and separately).
#[test]
fn seek_is_on_the_control_chords_in_both_panes() {
for focus in [Focus::Library, Focus::Queue] {
assert_eq!(lookup(focus, false, ".", false), Some(Action::SeekForward));
assert_eq!(lookup(focus, false, ",", false), Some(Action::SeekBackward));
}
// Unmodified `b` is unbound; unmodified `f` is not seek.
assert_eq!(lookup(Focus::Library, false, "b", false), None);
assert_ne!(
lookup(Focus::Library, false, "f", false),
Some(Action::SeekForward)
);
// Help is modal for control chords too.
assert_eq!(lookup(Focus::Library, true, "f", true), None);
}
/// `Ctrl-n` cannot be claimed in a browser — Chrome and Firefox reserve it
/// above the page — so track skipping must also be reachable without it.
#[test]
fn track_skipping_does_not_depend_on_ctrl_n() {
for focus in [Focus::Library, Focus::Queue] {
assert_eq!(lookup(focus, false, ">", false), Some(Action::NextTrack));
assert_eq!(lookup(focus, false, "<", false), Some(Action::PrevTrack));
}
// Ctrl-p is claimable and stays, so TUI habits still work.
assert_eq!(
lookup(Focus::Library, false, "p", true),
Some(Action::PrevTrack)
);
// And the help overlay documents the chords that actually work.
assert!(HELP.iter().any(|h| h.key == "< / >"));
}
#[test] #[test]
fn globals_win_in_both_panes() { fn globals_win_in_both_panes() {
for focus in [Focus::Library, Focus::Queue] { for focus in [Focus::Library, Focus::Queue] {

View File

@ -9,7 +9,7 @@ use crabidy_core::proto::crabidy::{
ChangeVolumeRequest, ClearQueueRequest, CreateLibraryNodeRequest, DeleteLibraryNodeRequest, ChangeVolumeRequest, ClearQueueRequest, CreateLibraryNodeRequest, DeleteLibraryNodeRequest,
GetLibraryNodeRequest, GetUpdateStreamRequest, GetUpdateStreamResponse, InitRequest, GetLibraryNodeRequest, GetUpdateStreamRequest, GetUpdateStreamResponse, InitRequest,
InsertRequest, LibraryNode, NextRequest, PrevRequest, QueueRequest, RemoveRequest, InsertRequest, LibraryNode, NextRequest, PrevRequest, QueueRequest, RemoveRequest,
RenameLibraryNodeRequest, ReplaceRequest, RestartTrackRequest, SaveQueueRequest, RenameLibraryNodeRequest, ReplaceRequest, RestartTrackRequest, SaveQueueRequest, SeekRequest,
SetCurrentRequest, ToggleMuteRequest, TogglePlayRequest, ToggleRepeatRequest, SetCurrentRequest, ToggleMuteRequest, TogglePlayRequest, ToggleRepeatRequest,
ToggleShuffleRequest, ToggleShuffleRequest,
}; };
@ -260,6 +260,15 @@ impl Rpc {
Ok(()) Ok(())
} }
/// Moves the playing position by a signed millisecond offset. The server
/// applies it to the live position and clamps it to the track, so this
/// carries the *step*, never a target (architecture/seek.md D1).
pub async fn seek(&mut self, delta_millis: i32) -> Result<(), Status> {
let request = Request::new(SeekRequest { delta_millis });
let _ = self.client.seek(request).await?;
Ok(())
}
pub async fn next(&mut self) -> Result<(), Status> { pub async fn next(&mut self) -> Result<(), Status> {
let _ = self.client.next(Request::new(NextRequest {})).await?; let _ = self.client.next(Request::new(NextRequest {})).await?;
Ok(()) Ok(())

View File

@ -561,6 +561,37 @@ impl CaptureBoard {
} }
} }
/// The seek offset in milliseconds that a click `fraction` of the way along
/// the progress bar means, given where playback currently is.
///
/// Seeking is relative on the wire (`architecture/seek.md` D1), so a click on
/// an absolute position becomes "the difference between there and here". That
/// is fine for a one-shot gesture: the position it subtracts is the one drawn
/// on the bar the user just aimed at, and it is at most one 250 ms tick old —
/// far below one pixel of a progress bar for any track worth seeking in. (It
/// would *not* be fine for a repeated key, which is why the keys send a fixed
/// step and let the server accumulate.)
///
/// `None` when the click cannot mean anything: a track whose duration the
/// source never reported has no scale, so the bar has no position to click at.
/// The fraction is clamped, so a click on the very edge of the element — or a
/// rounding error past it — cannot ask for a position outside the track.
pub fn seek_offset_for_fraction(
position_millis: u32,
duration_millis: u32,
fraction: f64,
) -> Option<i32> {
if duration_millis == 0 || !fraction.is_finite() {
return None;
}
let target = f64::from(duration_millis) * fraction.clamp(0.0, 1.0);
// Both terms are millisecond offsets inside one track, so the difference is
// nowhere near `i32`; the clamp is belt-and-braces for a `duration` a feed
// could have lied about.
let delta = target.round() - f64::from(position_millis);
Some(delta.clamp(f64::from(i32::MIN), f64::from(i32::MAX)) as i32)
}
/// `mm:ss`, or `h:mm:ss` once past an hour — matching the TUI's now-playing /// `mm:ss`, or `h:mm:ss` once past an hour — matching the TUI's now-playing
/// clock so minutes zero-pad and roll into hours instead of counting past 60. /// clock so minutes zero-pad and roll into hours instead of counting past 60.
pub fn format_seconds(total: u32) -> String { pub fn format_seconds(total: u32) -> String {
@ -617,6 +648,46 @@ mod tests {
} }
} }
/// Clicking the progress bar has to become an *offset*, because that is
/// what the wire carries — and it must stay inside the track however the
/// pointer geometry comes out.
#[test]
fn a_progress_bar_click_becomes_an_offset() {
// Half way through a 10-minute track, currently at 1:00 → +4:00.
assert_eq!(
seek_offset_for_fraction(60_000, 600_000, 0.5),
Some(240_000)
);
// Clicking behind the playhead seeks backwards.
assert_eq!(
seek_offset_for_fraction(300_000, 600_000, 0.25),
Some(-150_000)
);
// The far edges are exactly the ends of the track, and a fraction that
// overshoots (rounding, a click on the border) is clamped, never
// extrapolated past it.
assert_eq!(seek_offset_for_fraction(0, 600_000, 0.0), Some(0));
assert_eq!(seek_offset_for_fraction(0, 600_000, 1.0), Some(600_000));
assert_eq!(seek_offset_for_fraction(0, 600_000, 1.7), Some(600_000));
assert_eq!(seek_offset_for_fraction(0, 600_000, -0.4), Some(0));
}
/// A bar with no scale cannot be clicked into a position: a length-less
/// stream reports duration 0, and a non-finite fraction means the element
/// had no width. Both must decline rather than seek somewhere arbitrary.
#[test]
fn a_click_without_a_scale_is_declined() {
assert_eq!(seek_offset_for_fraction(5_000, 0, 0.5), None);
assert_eq!(seek_offset_for_fraction(5_000, 600_000, f64::NAN), None);
assert_eq!(
seek_offset_for_fraction(5_000, 600_000, f64::INFINITY),
None
);
// And nothing panics on absurd inputs.
assert!(seek_offset_for_fraction(u32::MAX, u32::MAX, 1.0).is_some());
assert!(seek_offset_for_fraction(0, u32::MAX, 1.0).is_some());
}
#[test] #[test]
fn listings_order_tracks_before_children_and_remember_positions() { fn listings_order_tracks_before_children_and_remember_positions() {
let mut pane = LibraryPane::default(); let mut pane = LibraryPane::default();

View File

@ -333,6 +333,10 @@ input {
border-radius: 3px; border-radius: 3px;
background: var(--accent-soft); background: var(--accent-soft);
overflow: hidden; overflow: hidden;
/* Click-to-seek: `manipulation` drops the touch double-tap delay so a
tap seeks immediately. */
cursor: pointer;
touch-action: manipulation;
& .gauge-fill { & .gauge-fill {
block-size: 100%; block-size: 100%;

View File

@ -55,6 +55,11 @@ service CrabidyService {
rpc Next(NextRequest) returns (NextResponse); rpc Next(NextRequest) returns (NextResponse);
rpc Prev(PrevRequest) returns (PrevResponse); rpc Prev(PrevRequest) returns (PrevResponse);
rpc RestartTrack(RestartTrackRequest) returns (RestartTrackResponse); rpc RestartTrack(RestartTrackRequest) returns (RestartTrackResponse);
// Moves the playing position inside the current track. Relative, never
// absolute: the server adds the offset to the live position, so repeated
// presses of a seek key compose instead of all computing from the same
// (already stale) position update.
rpc Seek(SeekRequest) returns (SeekResponse);
} }
// System // System
@ -249,6 +254,24 @@ message PrevResponse {}
message RestartTrackRequest {} message RestartTrackRequest {}
message RestartTrackResponse {} message RestartTrackResponse {}
message SeekRequest {
// Signed offset from the current position; negative seeks backwards.
// Milliseconds, like TrackPosition, so a client is free to pick any step.
//
// Clamped by the server: backwards saturates at the start of the track (it
// never steps into the previous one), and forwards stops just short of the
// end, so an overshooting seek lets the track finish and the queue advance.
// A track whose duration is unknown (a length-less stream) has no upper
// clamp and the decoder may refuse the seek.
//
// Fire-and-forget, like the other playback rpcs: the response says the
// command was accepted, not that the seek happened. A source that cannot
// seek at all (SoundCloud's HLS streams) leaves the position unchanged; the
// position updates on the stream remain the truth.
sint32 delta_millis = 1;
}
message SeekResponse {}
// Data types // Data types
message LibraryNodeChild { message LibraryNodeChild {
string path = 1; string path = 1;

View File

@ -1075,6 +1075,13 @@ pub enum PlaybackCommand {
Next, Next,
Prev, Prev,
RestartTrack, RestartTrack,
/// Moves the position inside the playing track by a signed offset in
/// milliseconds. The offset is passed straight to the player, which adds
/// it to the live position and clamps it — the playback loop keeps no
/// position of its own to go stale (architecture/seek.md D1).
Seek {
delta_millis: i64,
},
StateChanged { StateChanged {
state: PlayState, state: PlayState,
}, },
@ -1114,6 +1121,7 @@ impl PlaybackCommand {
Self::Next => "next", Self::Next => "next",
Self::Prev => "prev", Self::Prev => "prev",
Self::RestartTrack => "restart_track", Self::RestartTrack => "restart_track",
Self::Seek { .. } => "seek",
Self::StateChanged { .. } => "state_changed", Self::StateChanged { .. } => "state_changed",
Self::VolumeChanged { .. } => "volume_changed", Self::VolumeChanged { .. } => "volume_changed",
Self::MuteChanged { .. } => "mute_changed", Self::MuteChanged { .. } => "mute_changed",

View File

@ -463,6 +463,20 @@ impl Playback {
} }
} }
PlaybackCommand::Seek { delta_millis } => {
debug!(delta_millis, "seeking");
// The player owns both the live position and the clamping, so
// this arm only forwards. A source that cannot seek (HLS) or a
// player with nothing loaded is a warning: clients render the
// position from the update stream and never moved it
// themselves, so there is nothing to correct
// (architecture/seek.md D7).
match self.player.seek_by(delta_millis).await {
Ok(position) => trace!(?position, "seeked"),
Err(err) => warn!("seek failed: {err:?}"),
}
}
PlaybackCommand::VolumeChanged { volume } => { PlaybackCommand::VolumeChanged { volume } => {
trace!(volume, "volume changed"); trace!(volume, "volume changed");
self.broadcast(StreamUpdate::Volume(volume)); self.broadcast(StreamUpdate::Volume(volume));
@ -1125,6 +1139,15 @@ mod tests {
); );
} }
// Seek is not covered here on purpose. The handler's only job is to call
// `player.seek_by`, and every test in this module builds a real `Player`
// whose engine thread opens an audio device — so a test that *awaits* a
// player reply passes or hangs depending on whether the machine running it
// has working audio output, which is not a property of this code. The
// clamping arithmetic is tested as a pure function in `audio-player`, and
// the mapping from the RPC to the command (the layer the paste bug lived
// in) is tested in `rpc.rs`.
/// And position 0 reaches the very front — what `P` on the first row needs. /// And position 0 reaches the very front — what `P` on the first row needs.
#[tokio::test] #[tokio::test]
async fn insert_command_at_zero_reaches_the_front() { async fn insert_command_at_zero_reaches_the_front() {

View File

@ -11,10 +11,10 @@ use crabidy_core::proto::crabidy::{
InsertResponse, NextRequest, NextResponse, PrevRequest, PrevResponse, QueueRequest, InsertResponse, NextRequest, NextResponse, PrevRequest, PrevResponse, QueueRequest,
QueueResponse, RemoveRequest, RemoveResponse, RenameLibraryNodeRequest, QueueResponse, RemoveRequest, RemoveResponse, RenameLibraryNodeRequest,
RenameLibraryNodeResponse, ReplaceRequest, ReplaceResponse, RestartTrackRequest, RenameLibraryNodeResponse, ReplaceRequest, ReplaceResponse, RestartTrackRequest,
RestartTrackResponse, SaveQueueRequest, SaveQueueResponse, SetCurrentRequest, RestartTrackResponse, SaveQueueRequest, SaveQueueResponse, SeekRequest, SeekResponse,
SetCurrentResponse, StopRequest, StopResponse, ToggleMuteRequest, ToggleMuteResponse, SetCurrentRequest, SetCurrentResponse, StopRequest, StopResponse, ToggleMuteRequest,
TogglePlayRequest, TogglePlayResponse, ToggleRepeatRequest, ToggleRepeatResponse, ToggleMuteResponse, TogglePlayRequest, TogglePlayResponse, ToggleRepeatRequest,
ToggleShuffleRequest, ToggleShuffleResponse, ToggleRepeatResponse, ToggleShuffleRequest, ToggleShuffleResponse,
}; };
use crabidy_core::ProviderError; use crabidy_core::ProviderError;
use std::pin::Pin; use std::pin::Pin;
@ -589,4 +589,66 @@ impl CrabidyService for RpcService {
self.send_playback(PlaybackCommand::RestartTrack).await?; self.send_playback(PlaybackCommand::RestartTrack).await?;
Ok(Response::new(RestartTrackResponse {})) Ok(Response::new(RestartTrackResponse {}))
} }
/// Moves the position inside the playing track by a signed millisecond
/// offset. The offset is forwarded verbatim: the player adds it to the
/// live position and clamps it to the track (architecture/seek.md D1).
///
/// Fire-and-forget like the other playback rpcs — an unseekable source or
/// an idle player is logged, not reported, and the position updates on the
/// stream stay the single truth.
#[instrument(skip(self, request), fields(delta_millis))]
async fn seek(&self, request: Request<SeekRequest>) -> Result<Response<SeekResponse>, Status> {
let delta_millis = request.into_inner().delta_millis;
tracing::Span::current().record("delta_millis", delta_millis);
debug!("received seek request");
self.send_playback(PlaybackCommand::Seek {
delta_millis: delta_millis as i64,
})
.await?;
Ok(Response::new(SeekResponse {}))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn service() -> (RpcService, flume::Receiver<PlaybackMessage>) {
let (update_tx, _) = tokio::sync::broadcast::channel(16);
let (playback_tx, playback_rx) = flume::bounded(16);
let (provider_tx, _provider_rx) = flume::bounded(16);
(
RpcService::new(update_tx, playback_tx, provider_tx, false),
playback_rx,
)
}
/// The seek offset must reach the playback loop **unchanged**, sign
/// included: it is a signed `sint32` on the wire widened to `i64`, and a
/// wrong widening (via `u32`, say) would turn every backward seek into a
/// forward jump of ~50 days.
///
/// This layer is worth pinning because it is exactly where the queue-paste
/// bug lived: the primitive and the ops were tested, the command mapping
/// was not. Nothing here touches the player, so no audio device is needed.
#[tokio::test]
async fn seek_forwards_the_signed_delta_unchanged() {
let (service, playback_rx) = service();
for delta in [-15_000, 15_000, i32::MIN, i32::MAX, 0] {
service
.seek(Request::new(SeekRequest {
delta_millis: delta,
}))
.await
.expect("seek is accepted");
let message = playback_rx.recv_async().await.expect("command sent");
match message.command {
PlaybackCommand::Seek { delta_millis } => {
assert_eq!(delta_millis, delta as i64, "delta must survive the hop");
}
other => panic!("expected a seek command, got {}", other.name()),
}
}
}
} }

View File

@ -47,8 +47,9 @@ cbd global volume -- -0.1 # lower the volume
`replace <PATH>…`, `queue remove <POS>…`, `queue clear `replace <PATH>…`, `queue remove <POS>…`, `queue clear
[--keep-current]`, `queue set-current <POS>`, `queue save`/`capture [--keep-current]`, `queue set-current <POS>`, `queue save`/`capture
<NAME>`, `queue shuffle`, and `queue repeat` change it. <NAME>`, `queue shuffle`, and `queue repeat` change it.
- `global play`/`stop`/`next`/`prev`/`restart`/`mute` and `global - `global play`/`stop`/`next`/`prev`/`restart`/`mute`, `global
volume <DELTA>` control playback. volume <DELTA>`, and `global seek <SECONDS>` (negative seeks back, e.g.
`global seek -15`) control playback.
## Server commands ## Server commands

View File

@ -156,6 +156,7 @@ pane).
| Global | `Tab` | Switch between library and queue | | Global | `Tab` | Switch between library and queue |
| Global | `Space` | Play/pause | | Global | `Space` | Play/pause |
| Global | `r` | Restart current track | | Global | `r` | Restart current track |
| Global | `,` / `.` | Seek back / forward 15 seconds |
| Global | `K` | Volume up | | Global | `K` | Volume up |
| Global | `J` | Volume down | | Global | `J` | Volume down |
| Global | `m` | Toggle mute | | Global | `m` | Toggle mute |
@ -163,6 +164,7 @@ pane).
| Global | `x` | Toggle repeat | | Global | `x` | Toggle repeat |
| Global | `Ctrl-n` | Next track | | Global | `Ctrl-n` | Next track |
| Global | `Ctrl-p` | Previous track | | Global | `Ctrl-p` | Previous track |
| Global | `<` / `>` | Previous / next track |
| Global | `f` | Toggle the frequency spectrum | | Global | `f` | Toggle the frequency spectrum |
| Library | `j` / `k` | Select next / previous item | | Library | `j` / `k` | Select next / previous item |
| Library | `g` / `G` | Select first / last item | | Library | `g` / `G` | Select first / last item |

View File

@ -14,8 +14,11 @@ marks (`s`), visual mode (`v`/`V`) in both panes, and the register — `y`
yanks, `d`/`c`/`C` fill it as they remove, and `p`/`P` paste it after or yanks, `d`/`c`/`C` fill it as they remove, and `p`/`P` paste it after or
before the cursor (see [the TUI's register before the cursor (see [the TUI's register
section](./tui.md#the-register-y-d-and-pp), which behaves identically here; section](./tui.md#the-register-y-d-and-pp), which behaves identically here;
the queue toolbar shows how many entries are waiting). The `/` live filter the queue toolbar shows how many entries are waiting). Seek is there too —
is TUI-only for now. `,` and `.` move 15 seconds back and forward (`<` and `>` skip a whole
track), as do the `⏪`/`⏩`
buttons in
the transport bar. The `/` live filter is TUI-only for now.
## How it is served ## How it is served

View File

@ -102,7 +102,7 @@ url = "https://feeds.example.org/cautionary-tales"
# Optional, defaults shown. # Optional, defaults shown.
episodes_per_feed = 200 # episodes listed per feed episodes_per_feed = 200 # episodes listed per feed
call_timeout_secs = 30 # per-request timeout call_timeout_secs = 30 # per-request timeout
max_feed_bytes = 8388608 # 8 MiB cap on a feed body max_feed_bytes = 33554432 # 32 MiB cap on a feed body
``` ```
`%` appends to this file, so subscriptions made from a client persist. An `%` appends to this file, so subscriptions made from a client persist. An
@ -111,3 +111,28 @@ entry with no url is skipped with a warning, and no feeds at all is fine —
Feeds are read as RSS 2.0/1.0/0.x, Atom, or JSON Feed, and a malformed Feeds are read as RSS 2.0/1.0/0.x, Atom, or JSON Feed, and a malformed
episode is skipped rather than failing the listing. episode is skipped rather than failing the listing.
## Subscribe to the podcast feed, not the site feed
A blog and its podcast usually have **different** feeds, and subscribing to
the wrong one gives you a subscription that lists nothing. That is by
design: an entry only becomes an episode if it carries a playable audio
enclosure, and a blog feed's enclosures are the posts' featured *images*.
WordPress feeds in particular look exactly like podcast feeds apart from
that one attribute — so `https://netzpolitik.org/feed/` yields no
episodes, while `https://logbuch-netzpolitik.de/feed/mp3` yields the
podcast.
When a feed has entries but none of them carry audio, the server says so:
```text
WARN feed has entries but none carry playable audio; this looks like a blog
feed rather than a podcast feed entries=25
```
Look for a "Podcast" or "Subscribe" link on the show's page, or a
`/feed/mp3`-style URL. An enclosure is accepted when its type says audio,
when the feed omits the type (many hand-rolled feeds do), or when a generic
`application/octet-stream` is backed up by an audio file extension. Video
enclosures are left alone; a show that publishes both video and audio plays
as audio.

View File

@ -158,6 +158,38 @@ Two consequences worth knowing:
node expands it to tracks at paste time, and a path that no longer node expands it to tracks at paste time, and a path that no longer
resolves simply does not come back. resolves simply does not come back.
## Seeking inside a track
`,` and `.` in either client (and `cbd global seek <SECONDS>`) move the playing
position by 15 seconds; the shifted forms of the same two keys, `<` and `>`,
skip a whole track.
The wire carries the **offset**, not a target position: the server adds it to
the live position, so pressing the key three times moves 45 seconds rather than
three times to the same spot — which is what would happen if a client computed
a target from a position update that is already up to a tick old.
The step stays inside the current track:
- Backwards it saturates at the start. It never steps into the previous track;
that is `<`.
- Forwards it stops a second short of the end, so an overshooting seek lets the
track run out through the normal end-of-track path — which advances the
queue, obeying shuffle and repeat like any other track change.
- A track whose duration the source never reported has no upper bound to clamp
to, so the decoder decides whether it can honour the seek.
- **SoundCloud tracks cannot seek at all.** They arrive as HLS playlists,
decoded as a forward-only stream on purpose; a seek there is logged by the
server and leaves the position where it was. The position you see is always
the truth — clients never move it themselves.
In the web client the progress bar is **clickable**: a click seeks to that
point in the track. It is still an offset on the wire — the client sends the
difference between where you clicked and where playback is — which is accurate
enough for a one-shot gesture aimed at a position you can see, and means
click-to-seek needed no new rpc. A bar with no scale (a track of unknown
duration) declines the click.
## Persistence ## Persistence
The live queue survives a server restart. It is mirrored to the reserved The live queue survives a server restart. It is mirrored to the reserved

72
plan/seek.md Normal file
View File

@ -0,0 +1,72 @@
# Plan — seek
Executes `architecture/seek.md` against `quality/seek.md`. One commit:
the change is a single thin path from key to sink, and half-wiring it
would leave a dead RPC.
## A — The engine (where the only real logic is)
- [x] **A1 — `PlayerEngine::seek_target`** — a pure function of
(current position, delta millis, known duration) returning the absolute
target: saturating on both ends, 0 as the floor, `duration - 1 s` as
the ceiling when the duration is known, no ceiling when it is 0.
Extracted as a free function so the arithmetic is testable without an
audio device. *Verifies:* G1, G5, G6, G7.
- [x] **A2 — Rewrite `PlayerEngine::seek_to`** to route through the same
helper, dropping the `clamp(1s, duration)` that panics whenever the
duration is unknown or under a second. *Verifies:* G1.
- [x] **A3 — `PlayerEngine::seek_by`** — reads `sink.get_pos()`, applies
A1, calls `try_seek`, and on success notifies
`PlayerMessage::Elapsed` with the new position so a paused seek is
visible. Refuses when stopped. *Verifies:* G4, G8, G9.
- [x] **A4 — `PlayerEngineCommand::SeekBy` + `Player::seek_by`** on the
existing bounded reply pattern. *Verifies:* G3.
- [x] **A5 — Engine tests**: extremes (`i64::MIN`/`MAX`), zero duration,
short track, composition, near-end saturation. *Verifies:* G1, G4G7.
## B — Wire and server
- [x] **B1 — Proto**: `rpc Seek(SeekRequest) returns (SeekResponse)` with
`sint32 delta_millis`, documented with the clamping contract.
*Verifies:* G10.
- [x] **B2 — `PlaybackCommand::Seek { delta_millis }`** + its `name()`
arm, and the playback handler that forwards to `player.seek_by` and
warns on failure. No state. *Verifies:* G2, G12.
- [x] **B3 — `RpcService::seek`** following the `change_volume` shape
(record the field, send, return empty). *Verifies:* G10.
- [x] **B4 — Server test** that the command reaches the player with the
delta unchanged. *Verifies:* G11.
## C — Clients
- [x] **C1 — TUI**: `Action::SeekForward`/`SeekBackward`, `,`/`.` bindings
(plus `<`/`>` for prev/next) in `Scope::Global` with help text,
`MessageFromUi::Seek(i64)`,
the `lib.rs` arm, `rpc.rs` wrapper, `SEEK_STEP` constant, dispatch
tests. *Verifies:* G13, G14, G15.
- [x] **C2 — Web**: the same two actions in `keymap.rs` (global, with
`HELP` rows), `Rpc::seek`, the `app.rs` dispatch arms, and `⏪`/`⏩`
buttons in the transport bar. *Verifies:* G13, G14, G15.
- [x] **C4 — Web click-to-seek**: `state::seek_offset_for_fraction` (pure,
native-testable) plus an `on:click` on the gauge reading its geometry
from `current_target`, and `cursor: pointer` on the bar. *Verifies:* G18.
- [x] **C3 — CLI**: `GlobalCmd::Seek { seconds: i32 }` with
`allow_negative_numbers`, and its executor arm. *Verifies:* G14.
## D — Docs and gates
- [x] **D1 — Docs**: key tables (`docs/src/clients/tui.md`,
`docs/src/clients/web.md`), the CLI page's `global` list, and the
within-track/end-of-track/HLS sentences in `docs/src/queue.md`.
*Verifies:* G16.
- [x] **D2 — Gates**: workspace tests, `check-features`, `cargo fmt`, a
wasm build of `cbd-web`, and the book build. *Verifies:* G17.
- [x] **D3 — `plan/summary.md`** entry, then commit.
## Deferred (recorded, not dropped)
- Absolute seek + click-to-seek on the web progress gauge (the gauge is
already rendered; needs one proto field and a click handler).
- Configurable step size, and a larger step on `<`/`>`.
- Chapter-aware seek for podcasts/audiobooks — no provider exposes
chapter marks through the library model.

View File

@ -1393,3 +1393,122 @@ whole `check-features` matrix (now including `rss` alone) clippy-clean under
`-D warnings`, fmt clean, book builds, and a live fetch of the user's own `-D warnings`, fmt clean, book builds, and a live fetch of the user's own
premium Economist feed. Not exercised: playing an episode through an audio premium Economist feed. Not exercised: playing an episode through an audio
device. device.
## seek within a track (2026-07-26)
`architecture/seek.md``quality/seek.md``plan/seek.md`. `Ctrl-b` /
`Ctrl-f` move the playing position 15 seconds, from the TUI, the browser, and
`cbd global seek <SECONDS>`.
Almost all of it was wiring: `PlayerEngine::seek_to` and its command already
existed and **nothing called them** — no RPC, no playback command, no binding.
*The one real decision was where the arithmetic lives.* A seek is relative
("15 seconds back") but the engine seeks to an absolute position, so either the
client computes the target from the last `TrackPosition` it received, or it
sends a delta and the engine adds it to the live position. The delta wins on
the ordinary case of pressing the key twice: positions are broadcast on a
250 ms tick and then cross the network, so three quick presses all read the
*same* stale base and jump 15 s instead of 45. It also keeps the clamping
policy in one place instead of three clients, and matters more while paused,
where no position updates arrive at all. So the wire carries
`sint32 delta_millis` and the step (15 s) is a client constant.
*It also uncovered a live panic.* `seek_to` did
`time.clamp(Duration::from_secs(1), duration)`. `Ord::clamp` asserts
`min <= max`, and `duration()` returns **0** whenever the source reported no
length (HLS, some streams) — so that call panicked the engine thread, killing
audio, on any duration-less track. Unreachable only because nothing called it;
wiring seek made it reachable from user input, which the hard rules forbid. It
is now saturating arithmetic in a pure, exhaustively-tested function.
Boundaries: backwards saturates at 0 and never enters the previous track;
forwards stops 1 s short of the end so the track finishes through the ordinary
end-of-stream path (which advances the queue) rather than relying on
seek-to-exact-end, which every decoder treats differently; an unknown duration
has no upper clamp and the decoder decides. The engine emits `Elapsed` from the
seek path itself, because `tick()` skips a paused sink and a paused seek would
otherwise show the old position until playback resumed. An unseekable source
(SoundCloud's HLS) warns server-side and changes nothing — clients never move
the position themselves, so there is nothing to correct.
Bindings landed on `Ctrl-b`/`Ctrl-f` at the user's request (a first round used
`,`/`.`). They join the existing control-chord family (`Ctrl-n`/`Ctrl-p`,
`Ctrl-d`/`Ctrl-u`); plain `f` still toggles the spectrum because the TUI's
`lookup` compares every modifier but `SHIFT` exactly. In the browser `Ctrl-f`
would open the find bar, but the keydown handler already `prevent_default`s any
chord that resolves — binding it is enough to claim it.
Deferred: absolute seek plus click-to-seek on the web progress gauge (a
compatible proto3 field addition; the gauge is already rendered), a
configurable step, and chapter-aware seek.
Verified: 20 audio-player tests (5 new, covering `i64::MIN`/`MAX`, zero
duration, sub-second tracks, composition, near-end saturation), 120 cbd-tui,
21 cbd-web, crabidy-server, fmt clean, the wasm bundle builds. Not exercised:
an actual seek through an audio device.
### Fixed alongside: image enclosures listed as episodes (`/rss`)
Reported from a real subscription: an episode failed with "the format of the
data has not been recognized" on a `cdn.netzpolitik.org/…jpg` URL.
`audio_url` preferred an audio-typed enclosure but then fell back to *any*
media URL — and a WordPress blog feed attaches each post's featured **image**
as an `<enclosure>`, structurally identical to a podcast enclosure apart from
`type="image/jpeg"`. `https://netzpolitik.org/feed/` is 25 items, 25 JPEG
enclosures, and no audio reference of any kind, so every post became an
episode that could not play.
An enclosure is now accepted when its type says audio, when the feed omits the
type (many hand-rolled feeds do), or when a generic `application/octet-stream`
is backed by an audio file extension — and rejected otherwise, so images and
video are skipped. Audio-typed still wins, so a show publishing both plays as
audio. When a feed has entries but none carry audio, the server says so
("this looks like a blog feed rather than a podcast feed"): an empty listing
explains nothing on its own, and no URL goes in the message.
Also raised `DEFAULT_MAX_FEED_BYTES` 8 MiB → 32 MiB. Logbuch:Netzpolitik, 559
episodes in, is a healthy 6.8 MiB — feeds carry their whole back catalogue with
full show notes, so the first cap would have started refusing real feeds within
a year or two. Still bounded, still enforced while reading, still lowerable via
`max_feed_bytes`.
Verified: 30 rssdy tests (5 new, over feed-rs's real entry shapes: image-only,
mixed image+audio, untyped, generic-with-extension, generic-without and video),
and the two live feeds — netzpolitik.org/feed/ now yields 0 episodes with the
warning, logbuch-netzpolitik.de/feed/mp3 (→ feeds.metaebene.me/lnp/mp3) yields
559 `audio/mpeg` episodes with `HH:MM:SS` durations.
### Follow-up: the keys settled on `,` `.` `<` `>`, and the gauge is clickable
Three rounds on the bindings, and the last one had a reason beyond taste.
`Ctrl-b`/`Ctrl-f` work fine in a browser — the keydown handler already
`prevent_default`s any chord it resolves — but `Ctrl-n`, the long-standing
next-track chord, does **not**: Chrome and Firefox handle it as "new window"
above the page, where `preventDefault` cannot reach. The web client therefore
had no working next-track key at all.
Plain printable characters dodge the whole reserved-chord question, so seek and
track-skip now share two keys in both clients: `,`/`.` seek 15 s, `<`/`>` skip a
track. Same key, shift = bigger jump — self-teaching, and `<`/`>` are the marks
engraved on those keys (mpv uses them for the same thing). `Ctrl-n`/`Ctrl-p`
stay bound as the terminal's primary chords; the web help documents the pair
that always works.
**Click-to-seek shipped without absolute seek**, which had been the deferred
item. The click maps the pointer's x within the gauge to a fraction of the
duration and sends `target - position`. Relative is right here even though the
gesture is absolute: the position it subtracts is the one drawn on the bar the
user just aimed at, at most one 250 ms tick old — far under one pixel of the
bar. That staleness is only fatal for a *repeated* key, which is why keys still
send a fixed step and let the server accumulate. The arithmetic is a pure
function in `state.rs`, tested on the native target: clicks behind the playhead
seek back, the edges are exactly the track's ends, a fraction outside `[0, 1]`
is clamped rather than extrapolated, and a duration of 0 declines.
Also: enabling the web-sys `DomRect` feature, without which
`Element::get_bounding_client_rect` does not exist. Only the wasm build catches
that — `cbd-web`'s `mod app` is `#[cfg(target_arch = "wasm32")]`, so native
clippy never compiles it. Second time this session that the wasm build was the
only gate that would have caught a web-client mistake.

96
quality/seek.md Normal file
View File

@ -0,0 +1,96 @@
# Quality gates — seek
Criteria an implementation of `architecture/seek.md` must satisfy.
Automated coverage lives in `audio-player/src/player_engine.rs` (the
clamping arithmetic, which is where the interesting behaviour is),
`crabidy-server/src/playback.rs`, and the two clients' binding tests.
## Hard rules (highest priority)
- [ ] **G1 — No panic on any delta, from any state.** The clamp is
saturating arithmetic with no `assert`/`clamp(min, max)` where
`min > max` is reachable. Specifically: an unknown duration (reported
as 0), a track shorter than the step, a delta larger than the track,
`i64::MIN`/`i64::MAX` as the delta, and a stopped or empty sink must
all be handled without panicking. This closes the existing bug in
`seek_to` (D5). *(tests: `seek_target_never_panics_on_extremes`,
`seek_target_saturates_at_the_start`,
`an_unknown_duration_does_not_clamp_forward`.)*
- [ ] **G2 — A failed seek stays server-side.** `Seek` never returns a
`color-eyre`/`anyhow` report to a client; an unseekable source produces
a `warn!` and an unchanged position, not an RPC error (D7).
- [ ] **G3 — No new unbounded channel and no new blocking call on an
async task.** The seek reply rides the existing bounded reply pattern;
`try_seek` is called only from the engine thread.
## Behaviour
- [ ] **G4 — Deltas compose.** Two successive backward seeks from the
same starting position land at `start - 2 × step`, not
`start - step` — the engine reads the live position each time (D1).
*(test: `successive_seeks_compose`.)*
- [ ] **G5 — Backward saturates at zero**, never below, and never into
the previous track (A1).
- [ ] **G6 — Forward past the end lands one second before the end**
when the duration is known, so the track finishes through the normal
end-of-stream path and the queue advances — the engine never seeks to
the exact end (D4). *(test: `seek_target_saturates_near_the_end`.)*
- [ ] **G7 — An unknown duration does not clamp forward.** A
length-less stream reports duration 0; that must not turn every
forward seek into "seek to 0" (the naive clamp) — it must pass the
target through and let the decoder refuse it.
- [ ] **G8 — The new position is broadcast immediately**, including
while **paused**`tick()` returns early for a paused sink, so the
engine emits `Elapsed` from the seek path itself (D6).
- [ ] **G9 — Seeking while stopped does nothing** and does not start
playback (A4).
## Wire and plumbing
- [ ] **G10 — One RPC, relative only**: `Seek(SeekRequest{sint32
delta_millis}) -> SeekResponse`, fire-and-forget like the other
playback RPCs (D2, A3). No absolute field, no oneof — the deferred
extension stays compatible.
- [ ] **G11 — The delta is never re-derived client-side.** No client
computes a target position from a broadcast `TrackPosition`; grep for
arithmetic on `position` in the clients and find none (D1, A2).
*(test: `seek_command_forwards_the_delta_unchanged`.)*
- [ ] **G12 — The playback loop holds no seek state.** `PlaybackCommand::Seek`
forwards to the player and does nothing else — no position cache to go
stale.
## Clients
- [ ] **G13 — `,`/`.` seek and `<`/`>` skip tracks, global in both
clients**, colliding with nothing in any scope, with the TUI's
binding-table uniqueness invariant intact and the shifted forms
resolving whether or not the terminal reports `SHIFT`. Being plain
printable characters, none of them can be swallowed by a browser — which
`Ctrl-n` is, and which is why `<`/`>` exist. *(tests: the
`bindings`/`keymap` tables and their uniqueness tests, extended.)*
- [ ] **G18 — A progress-bar click becomes an offset inside the track.**
The mapping is a pure function tested on the native target: a click
behind the playhead seeks back, the edges are exactly the track's ends, a
fraction outside `[0, 1]` is clamped rather than extrapolated, and a
duration of 0 (or a non-finite fraction, meaning a zero-width element)
declines instead of seeking somewhere arbitrary. *(tests:
`a_progress_bar_click_becomes_an_offset`,
`a_click_without_a_scale_is_declined`.)*
- [ ] **G14 — Every client reaches it**: TUI binding → `MessageFromUi`
→ RPC; web binding + `⏪`/`⏩` buttons → RPC; `cbd global seek
<SECONDS>` accepting a negative value (D8).
- [ ] **G15 — Both help surfaces list it** — the TUI help modal (from
`BINDINGS`) and the web help overlay (`HELP`) — so the two tables stay
in lockstep with dispatch, which their tests enforce.
## Documentation
- [ ] **G16 — Documented where a user looks**: the key tables in
`docs/src/clients/tui.md` and the web page, the CLI page's `global`
list, and a sentence in `docs/src/queue.md` saying seek is
within-track, that a forward seek past the end advances, and that
SoundCloud's HLS streams cannot seek.
- [ ] **G17 — Clippy clean under `-D warnings`** across the feature
matrix (`check-features`), fmt clean, and the wasm target builds — the
web client's `mod app` is `#[cfg(target_arch = "wasm32")]` and native
clippy does not see it.

View File

@ -74,7 +74,7 @@ url = "https://feeds.example.org/cautionary-tales"
# Optional, defaults shown. # Optional, defaults shown.
# episodes_per_feed = 200 # episodes listed per feed # episodes_per_feed = 200 # episodes listed per feed
# call_timeout_secs = 30 # per-request timeout # call_timeout_secs = 30 # per-request timeout
# max_feed_bytes = 8388608 # 8 MiB cap on a feed body # max_feed_bytes = 33554432 # 32 MiB cap on a feed body
``` ```
A feed entry with no url is skipped with a warning. No feeds at all is fine: A feed entry with no url is skipped with a warning. No feeds at all is fine:

View File

@ -17,6 +17,7 @@ use std::time::Duration;
use async_trait::async_trait; use async_trait::async_trait;
use thiserror::Error; use thiserror::Error;
use tracing::warn;
/// A typed feed failure. Carries only non-secret context — never a feed URL, /// A typed feed failure. Carries only non-secret context — never a feed URL,
/// which would put a subscriber token in the logs. /// which would put a subscriber token in the logs.
@ -174,6 +175,7 @@ fn episodes_from(
limit: usize, limit: usize,
durations: &HashMap<String, u32>, durations: &HashMap<String, u32>,
) -> Vec<Episode> { ) -> Vec<Episode> {
let entry_count = entries.len();
let mut episodes: Vec<Episode> = entries let mut episodes: Vec<Episode> = entries
.into_iter() .into_iter()
.filter_map(|entry| { .filter_map(|entry| {
@ -218,36 +220,106 @@ fn episodes_from(
// sorting newest first. // sorting newest first.
episodes.sort_by_key(|e| std::cmp::Reverse(e.published)); episodes.sort_by_key(|e| std::cmp::Reverse(e.published));
episodes.truncate(limit); episodes.truncate(limit);
// A feed full of entries and empty of audio is almost always a blog feed
// subscribed by mistake (its enclosures are the posts' featured images).
// Say so: the listing itself can only show nothing, which explains nothing.
// No URL in the message — a feed URL is a credential.
if episodes.is_empty() && entry_count > 0 {
warn!(
entries = entry_count,
"feed has entries but none carry playable audio; this looks like a blog feed rather \
than a podcast feed"
);
}
episodes episodes
} }
/// The first playable audio URL of an entry: a media enclosure, else a link /// Media types that some feeds use for audio files without saying so.
/// that advertises audio (feed dialects disagree about where it goes). const GENERIC_TYPES: [&str; 2] = ["application/octet-stream", "binary/octet-stream"];
/// File extensions we treat as audio when the declared type is generic or
/// missing. Deliberately excludes `.mp4`, which is as often video as audio.
const AUDIO_EXTENSIONS: [&str; 13] = [
"mp3", "m4a", "m4b", "aac", "ogg", "oga", "opus", "flac", "wav", "aiff", "aif", "mp2", "mpga",
];
/// Whether a declared media type names audio.
fn is_audio_type(content_type: &str) -> bool {
let normalized = content_type.trim().to_ascii_lowercase();
normalized.starts_with("audio/") || normalized == "audio"
}
/// Whether an enclosure is plausibly something we can decode.
///
/// The permissive cases exist because real podcast feeds are sloppy: plenty
/// omit the `type` attribute entirely, and a few serve mp3s as
/// `application/octet-stream`. So a missing type is accepted, and a generic one
/// is accepted when the URL's extension backs it up.
///
/// What is *not* accepted is a type that names something else. Blog feeds —
/// WordPress ones especially — attach the post's featured **image** as an
/// `<enclosure>`, which is structurally identical to a podcast enclosure and
/// distinguishable only by its type. Accepting those turned every blog post
/// into an episode that failed to decode at play time.
fn plausibly_audio(content_type: Option<&str>, url: &str) -> bool {
match content_type {
None => true,
Some(ct) if is_audio_type(ct) => true,
Some(ct) => {
let normalized = ct.trim().to_ascii_lowercase();
GENERIC_TYPES.contains(&normalized.as_str()) && has_audio_extension(url)
}
}
}
/// Whether the URL's path ends in a known audio extension. Query strings and
/// fragments are ignored — enclosure URLs routinely carry tracking parameters.
fn has_audio_extension(url: &str) -> bool {
let path = url
.split(['?', '#'])
.next()
.unwrap_or(url)
.trim_end_matches('/');
let Some((_, extension)) = path.rsplit_once('.') else {
return false;
};
let extension = extension.to_ascii_lowercase();
AUDIO_EXTENSIONS.contains(&extension.as_str())
}
/// The playable audio URL of an entry: a media enclosure, else a link that
/// advertises audio (feed dialects disagree about where it goes).
///
/// An entry whose only enclosures are images or video yields `None` and is
/// skipped — better an episode that never appears than one that appears and
/// cannot be played.
fn audio_url(entry: &feed_rs::model::Entry) -> Option<String> { fn audio_url(entry: &feed_rs::model::Entry) -> Option<String> {
let media = entry.media.iter().flat_map(|m| m.content.iter()); let enclosures: Vec<(Option<String>, String)> = entry
// Prefer something explicitly typed as audio, then any media url at all. .media
let typed = media.clone().find(|c| {
c.url.is_some()
&& c.content_type
.as_ref()
.is_some_and(|ct| ct.to_string().starts_with("audio"))
});
if let Some(url) = typed.and_then(|c| c.url.as_ref()) {
return Some(url.to_string());
}
if let Some(url) = media.filter_map(|c| c.url.as_ref()).next() {
return Some(url.to_string());
}
entry
.links
.iter() .iter()
.find(|l| { .flat_map(|m| m.content.iter())
l.rel.as_deref() == Some("enclosure") .filter_map(|content| {
|| l.media_type let url = content.url.as_ref()?.to_string();
.as_deref() Some((content.content_type.as_ref().map(ToString::to_string), url))
.is_some_and(|ct| ct.starts_with("audio"))
}) })
.map(|l| l.href.clone()) .chain(entry.links.iter().filter_map(|link| {
let candidate = link.rel.as_deref() == Some("enclosure")
|| link.media_type.as_deref().is_some_and(is_audio_type);
candidate.then(|| (link.media_type.clone(), link.href.clone()))
}))
.collect();
// An explicitly audio-typed enclosure wins; a video podcast that also
// ships an audio version therefore plays as audio. Only then do the
// permissive cases (no type, generic type) get a turn.
enclosures
.iter()
.find(|(content_type, _)| content_type.as_deref().is_some_and(is_audio_type))
.or_else(|| {
enclosures
.iter()
.find(|(content_type, url)| plausibly_audio(content_type.as_deref(), url))
})
.map(|(_, url)| url.clone())
} }
/// `itunes:duration` (or a media duration) in whole seconds. /// `itunes:duration` (or a media duration) in whole seconds.
@ -380,6 +452,101 @@ mod tests {
assert!(!found.contains_key("no-duration")); assert!(!found.contains_key("no-duration"));
} }
/// Parses an RSS body the way [`FeedFetcher::fetch`] does and returns the
/// episodes, so these tests exercise the real feed-rs entry shapes rather
/// than hand-built model structs.
fn episodes_of(body: &str) -> Vec<Episode> {
let feed = feed_rs::parser::parse(body.as_bytes()).expect("parses");
episodes_from(feed.entries, 100, &HashMap::new())
}
/// The netzpolitik.org case: a WordPress blog feed whose `<enclosure>` is
/// the post's featured **image**. Accepting it produced episodes that
/// failed to decode at play time ("format of the data has not been
/// recognized"), which is worse than not listing them at all.
#[test]
fn image_enclosures_are_not_episodes() {
let episodes = episodes_of(
r#"<?xml version="1.0"?><rss version="2.0"><channel>
<title>A blog</title>
<item>
<title>A post</title>
<guid>post-1</guid>
<enclosure url="https://cdn.example/featured-scaled.jpg"
length="272428" type="image/jpeg" />
</item>
</channel></rss>"#,
);
assert!(
episodes.is_empty(),
"an image enclosure is not a playable episode"
);
}
/// A real podcast entry still resolves, and an entry that ships both an
/// image and audio picks the audio.
#[test]
fn audio_enclosures_win_over_other_media() {
let episodes = episodes_of(
r#"<?xml version="1.0"?><rss version="2.0"><channel>
<title>A podcast</title>
<item>
<title>Episode</title>
<guid>ep-1</guid>
<enclosure url="https://cdn.example/cover.jpg" type="image/jpeg" />
<enclosure url="https://cdn.example/ep1.mp3" type="audio/mpeg" />
</item>
</channel></rss>"#,
);
assert_eq!(episodes.len(), 1);
assert_eq!(episodes[0].enclosure_url, "https://cdn.example/ep1.mp3");
}
/// Sloppy-but-real feeds: no `type` at all, or a generic one backed by the
/// extension. Both stay playable — the decoder is the final judge.
#[test]
fn untyped_and_generic_enclosures_are_accepted() {
let episodes = episodes_of(
r#"<?xml version="1.0"?><rss version="2.0"><channel>
<title>A podcast</title>
<item><guid>a</guid>
<enclosure url="https://cdn.example/a.mp3" /></item>
<item><guid>b</guid>
<enclosure url="https://cdn.example/b.mp3?tk=X"
type="application/octet-stream" /></item>
</channel></rss>"#,
);
assert_eq!(episodes.len(), 2);
}
/// A generic type with no audible extension is not taken on faith, and
/// video enclosures are left alone.
#[test]
fn generic_and_video_enclosures_without_audio_are_skipped() {
let episodes = episodes_of(
r#"<?xml version="1.0"?><rss version="2.0"><channel>
<title>Mixed</title>
<item><guid>a</guid>
<enclosure url="https://cdn.example/a.bin"
type="application/octet-stream" /></item>
<item><guid>b</guid>
<enclosure url="https://cdn.example/b.mp4" type="video/mp4" /></item>
</channel></rss>"#,
);
assert!(episodes.is_empty());
}
#[test]
fn audio_extensions_ignore_query_strings() {
assert!(has_audio_extension("https://cdn.example/a.mp3"));
assert!(has_audio_extension("https://cdn.example/a.MP3?tk=X#f"));
assert!(has_audio_extension("https://cdn.example/a.opus?x=.jpg"));
assert!(!has_audio_extension("https://cdn.example/a.jpg"));
assert!(!has_audio_extension("https://cdn.example/a.mp4"));
assert!(!has_audio_extension("https://cdn.example/no-extension"));
assert!(!has_audio_extension(""));
}
#[test] #[test]
fn tag_text_handles_cdata_and_attributes() { fn tag_text_handles_cdata_and_attributes() {
assert_eq!( assert_eq!(

View File

@ -37,9 +37,15 @@ pub const PROVIDER_ROOT: &str = "/rss";
pub const DEFAULT_EPISODES_PER_FEED: usize = 200; pub const DEFAULT_EPISODES_PER_FEED: usize = 200;
/// Default per-request timeout in seconds. /// Default per-request timeout in seconds.
pub const DEFAULT_CALL_TIMEOUT_SECS: u64 = 30; pub const DEFAULT_CALL_TIMEOUT_SECS: u64 = 30;
/// Default cap on a feed body, in bytes (8 MiB). A feed larger than this is a /// Default cap on a feed body, in bytes (32 MiB) — the bound that keeps a
/// publishing bug, not something to load into memory (D6). /// runaway response from being read into memory (D6).
pub const DEFAULT_MAX_FEED_BYTES: u64 = 8 * 1024 * 1024; ///
/// Generous on purpose: podcast feeds routinely carry their *whole* back
/// catalogue with full show notes in every item. Logbuch:Netzpolitik, 559
/// episodes in, is 6.8 MiB — so the first cap of 8 MiB would have started
/// refusing real, healthy feeds within a year or two of publishing. Lower it in
/// `rss.toml` (`max_feed_bytes`) if a tighter bound is wanted.
pub const DEFAULT_MAX_FEED_BYTES: u64 = 32 * 1024 * 1024;
/// How many feeds' episode lists the memo keeps (D3). /// How many feeds' episode lists the memo keeps (D3).
pub const MEMO_CAPACITY: usize = 8; pub const MEMO_CAPACITY: usize = 8;