feat(audio): auto-advance to next episode in source queue on track end

When a track reaches natural EOF (player alive, no stream error), play the
next episode from the source that started it — search results, show, or
Feed — and stop at the end of the list. A crashed/killed daemon or failed
stream never auto-advances.

- add audio-queue.ts: pure next/prev selection from the navigation source
- audio-player: expose getPlaybackError() to distinguish EOF from failure
- useAudio: finalizeTrackEnd(autoAdvance) wiring, re-selecting the current
  episode no longer reloads from stale saved progress
- tests: audio-queue units, auto-advance integration (real mpv + local
  WAVs over HTTP), backend re-select no-reload test
This commit is contained in:
2026-08-17 20:50:05 -04:00
parent 22059c24ca
commit 9ddfd21685
6 changed files with 656 additions and 115 deletions

View File

@@ -172,6 +172,76 @@ test.skipIf(!hasMpv)(
{ timeout: 20000 },
);
test.skipIf(!hasMpv)(
"play() of the already-playing url does NOT reload (no audible skip-back)",
async () => {
fixtureWavs();
const backend = new MpvBackend();
try {
// Start mid-episode (as a resume would) and let it advance.
await backend.play(wavA, { volume: 0, speed: 1, startPosition: 1 });
await waitFor(
"position advances past the start offset",
async () => (await backend.getPosition()) > 1.8,
);
const before = await backend.getPosition();
// Re-selecting the SAME episode (Enter in a list, key-repeat)
// calls play() with the STALE saved progress. The file is
// already loaded — this must not reload from that earlier
// position, or the listener hears already-played audio again.
await backend.play(wavA, { volume: 0, speed: 1, startPosition: 1 });
// A reload would drop the position back to ~1; a correct no-op
// keeps advancing from where it was.
await waitFor(
"playback continues past the pre-play position",
async () => (await backend.getPosition()) > before + 0.3,
);
expect(backend.isPlaying()).toBe(true);
// And the position never fell back toward the stale offset.
expect(await backend.getPosition()).toBeGreaterThan(1.8);
} finally {
await cleanup(backend);
}
},
{ timeout: 20000 },
);
test.skipIf(!hasMpv)(
"play() of the same url while user-paused resumes at the current position",
async () => {
fixtureWavs();
const backend = new MpvBackend();
try {
await backend.play(wavB, { volume: 0, speed: 1, startPosition: 1 });
await waitFor(
"position advances",
async () => (await backend.getPosition()) > 2,
);
await backend.pause();
await waitFor(
"paused observed",
async () => (await backend.getPauseState()) === true,
);
const pausedAt = await backend.getPosition();
// Re-selecting the paused episode resumes where it PAUSED — the
// stale saved progress must not become a backward seek target.
await backend.play(wavB, { volume: 0, speed: 1, startPosition: 1 });
expect(backend.isPlaying()).toBe(true);
await waitFor(
"resumed at the paused position",
async () => (await backend.getPosition()) > pausedAt + 0.3,
);
expect(await backend.getPosition()).toBeGreaterThan(1.5);
} finally {
await cleanup(backend);
}
},
{ timeout: 20000 },
);
test.skipIf(!hasMpv)(
"daemon killed mid-play: resume() rejects on the fresh idle daemon; play() recovers a new one",
async () => {

155
tests/audio-queue.test.ts Normal file
View File

@@ -0,0 +1,155 @@
/**
* audio-queue unit tests — pure selection logic for next/prev navigation
* and source-based auto-advance. Covers ordering, bounds, and the
* deduplication that prevents "next" from replaying the current episode.
*/
import { test, expect } from "bun:test";
import {
queueForSource,
queueIndex,
nextStep,
prevStep,
} from "../src/utils/audio-queue";
import { AudioSource } from "../src/stores/audio-nav";
import type { Episode } from "../src/types/episode";
import type { Feed } from "../src/types/feed";
import { FeedVisibility } from "../src/types/feed";
import type { SearchResult } from "../src/types/source";
function ep(id: string, n: number): Episode {
return {
id,
podcastId: "pod-" + id,
title: `Episode ${n}`,
description: "",
audioUrl: `https://example.com/${id}.mp3`,
duration: 600,
pubDate: new Date(2026, 0, n),
};
}
function feed(id: string, episodes: Episode[]): Feed {
return {
id,
podcast: {
id,
title: "Feed " + id,
description: "",
feedUrl: `https://example.com/${id}.xml`,
lastUpdated: new Date(),
isSubscribed: true,
},
episodes,
visibility: FeedVisibility.PUBLIC,
sourceId: "rss",
lastUpdated: new Date(),
isPinned: false,
};
}
function episodeResult(episode: Episode): SearchResult {
return {
sourceId: "itunes",
kind: "episode",
podcast: {
id: episode.podcastId,
title: "Show " + episode.podcastId,
description: "",
feedUrl: `https://example.com/${episode.podcastId}.xml`,
lastUpdated: new Date(),
isSubscribed: false,
},
episode,
};
}
const e1 = ep("e1", 1);
const e2 = ep("e2", 2);
const e3 = ep("e3", 3);
test("FEED queue is the chronological global list, newest first", () => {
const f1 = feed("f1", [e3, e2]);
const f2 = feed("f2", [e1]);
const queue = queueForSource(
AudioSource.FEED,
undefined,
[f1, f2],
[
{ episode: e3, feed: f1 },
{ episode: e2, feed: f1 },
{ episode: e1, feed: f2 },
],
[],
);
expect(queue.map((e) => e.id)).toEqual(["e3", "e2", "e1"]);
expect(queueIndex(queue, "e2")).toBe(1);
expect(nextStep(queue, "e2")?.episode.id).toBe("e1");
expect(prevStep(queue, "e2")?.episode.id).toBe("e3");
expect(nextStep(queue, "e1")).toBeNull();
expect(prevStep(queue, "e3")).toBeNull();
});
test("FEED queue dedupes repeated episode ids (same episode listed twice)", () => {
// The same episode appears twice in the global list (e.g. a refresh
// merge duplicated a feed's entries). Without dedupe, nextStep after
// e2 would step onto e2 AGAIN — replaying the current episode.
const f1 = feed("f1", [e3, e2, e2, e1]);
const queue = queueForSource(
AudioSource.FEED,
undefined,
[f1],
[
{ episode: e3, feed: f1 },
{ episode: e2, feed: f1 },
{ episode: e2, feed: f1 },
{ episode: e1, feed: f1 },
],
[],
);
expect(queue.map((e) => e.id)).toEqual(["e3", "e2", "e1"]);
// Distinct objects sharing an id dedupe too.
const e2clone = { ...e2 };
const queue2 = queueForSource(
AudioSource.FEED,
undefined,
[f1],
[
{ episode: e3, feed: f1 },
{ episode: e2, feed: f1 },
{ episode: e2clone, feed: f1 },
],
[],
);
expect(queue2.map((e) => e.id)).toEqual(["e3", "e2"]);
expect(nextStep(queue2, "e2")).toBeNull(); // no self-step
});
test("MY_SHOWS queue scopes to the podcast that started playback", () => {
const fA = feed("podA", [e3, e2]);
const fB = feed("podB", [e1]);
const queue = queueForSource(
AudioSource.MY_SHOWS,
"podA",
[fA, fB],
[],
[],
);
expect(queue.map((e) => e.id)).toEqual(["e3", "e2"]);
// Unknown podcastId → empty queue (nothing to play next).
expect(
queueForSource(AudioSource.MY_SHOWS, "podX", [fA, fB], [], []),
).toEqual([]);
});
test("SEARCH queue filters to episode-kind results in display order", () => {
const queue = queueForSource(
AudioSource.SEARCH,
undefined,
[],
[],
[episodeResult(e1), episodeResult(e2)],
);
expect(queue.map((e) => e.id)).toEqual(["e1", "e2"]);
expect(queueIndex(queue, "e1")).toBe(0);
expect(queueIndex(queue, "e3")).toBe(-1);
});

197
tests/auto-advance.test.ts Normal file
View File

@@ -0,0 +1,197 @@
/**
* auto-advance.test.ts — "at the end of episodes play the next one, from
* the source that started it" feature.
*
* When a track reaches its natural end (mpv eof-reached), useAudio must
* advance to the next episode in the source queue — the current show's
* episode list (MY_SHOWS), the Feed's chronological list, or the search
* results — and must STOP at the end of the list (no wrap-around). A
* crashed/killed daemon must NOT auto-advance (that path is pinned by
* external-pause-reconcile.test.ts).
*
* Integration style (like external-pause-reconcile.test.ts): real stores,
* real persistence sandbox, and the REAL mpv backend driven by real audio
* files — two short local WAVs served over HTTP, so EOF happens on a
* deterministic timer. The show is subscribed through the real feed store's
* addFeed() API (no config seeding — works on whatever singleton state this
* worker holds), and the audio-nav source is pinned to MY_SHOWS for that
* podcast so the queue is scoped and deterministic. Skipped when mpv isn't
* installed.
*/
import { test, expect, afterAll } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const hasMpv = !!Bun.which("mpv");
// ── Sandbox BEFORE any app module evaluates ───────────────────────────────
const CONFIG = mkdtempSync(join(tmpdir(), "podtui-autoadv-"));
const DATA = mkdtempSync(join(tmpdir(), "podtui-autoadv-data-"));
process.env.XDG_CONFIG_HOME = CONFIG;
process.env.XDG_DATA_HOME = DATA;
process.env.PODTUI_AUDIO_BACKEND = "mpv"; // real backend; EOF is the signal under test
/** 2s mono 16-bit WAV with a sine tone — short enough to EOF fast,
* distinct per episode so playback is unambiguous. */
function makeWav(freq: number): Buffer {
const SAMPLE_RATE = 44100;
const DURATION = 2;
const dataLen = SAMPLE_RATE * DURATION;
const buf = Buffer.alloc(44 + dataLen * 2);
buf.write("RIFF", 0);
buf.writeUInt32LE(36 + dataLen * 2, 4);
buf.write("WAVE", 8);
buf.write("fmt ", 12);
buf.writeUInt32LE(16, 16); // fmt chunk size
buf.writeUInt16LE(1, 20); // PCM
buf.writeUInt16LE(1, 22); // mono
buf.writeUInt32LE(SAMPLE_RATE, 24);
buf.writeUInt32LE(SAMPLE_RATE * 2, 28); // byte rate
buf.writeUInt16LE(2, 32); // block align
buf.writeUInt16LE(16, 34); // bits per sample
buf.write("data", 36);
buf.writeUInt32LE(dataLen * 2, 40);
for (let i = 0; i < dataLen; i++) {
const sample = Math.round(
Math.sin((2 * Math.PI * freq * i) / SAMPLE_RATE) * 8000,
);
buf.writeInt16LE(sample, 44 + i * 2);
}
return buf;
}
const wav1 = makeWav(440);
const wav2 = makeWav(880);
// ── Local HTTP server: the RSS feed + both audio files ────────────────────
let server: ReturnType<typeof Bun.serve> | null = null;
function feedXml(origin: string): string {
// Distinct pubDates so ep1 (newest) is episodes[0], ep2 older — "next"
// must step DOWN the list toward the older episode.
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel>
<title>Auto Advance Show</title>
<description>auto-advance test feed</description>
<item>
<title>Episode One</title>
<pubDate>2026-08-10T00:00:00Z</pubDate>
<enclosure url="${origin}/e1.wav" length="${wav1.length}" type="audio/wav"/>
</item>
<item>
<title>Episode Two</title>
<pubDate>2026-08-01T00:00:00Z</pubDate>
<enclosure url="${origin}/e2.wav" length="${wav2.length}" type="audio/wav"/>
</item>
</channel></rss>`;
}
server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url);
if (url.pathname.endsWith(".xml")) {
return new Response(feedXml(url.origin), {
headers: { "Content-Type": "application/rss+xml" },
});
}
if (url.pathname.endsWith("e1.wav")) {
return new Response(wav1.buffer as ArrayBuffer, {
headers: { "Content-Type": "audio/wav" },
});
}
if (url.pathname.endsWith("e2.wav")) {
return new Response(wav2.buffer as ArrayBuffer, {
headers: { "Content-Type": "audio/wav" },
});
}
return new Response("not found", { status: 404 });
},
});
// ── Real modules (loaded after env + server are up) ───────────────────────
// @ts-expect-error — bun-only query suffix: distinct module identity that
// loads the real file instead of a leaked mock.module from another test file.
const { useAudio } = await import("../src/hooks/useAudio?auto-advance-test");
const { useFeedStore } = await import("../src/stores/feed");
const { useAudioNavStore, AudioSource } = await import(
"../src/stores/audio-nav"
);
const feedStore = useFeedStore();
const audioNav = useAudioNavStore();
/** Poll `check` every 25ms until truthy; throw after `timeoutMs`. */
async function waitFor(
check: () => boolean,
timeoutMs = 15000,
): Promise<void> {
const start = Date.now();
while (!check()) {
if (Date.now() - start > timeoutMs) {
throw new Error("condition not met in time");
}
await Bun.sleep(25);
}
}
// Subscribe to the local feed through the real store API; unique podcast id
// so the MY_SHOWS queue lookup is deterministic whatever else this worker's
// shared feed store holds.
const feedUrl = `http://127.0.0.1:${server!.port}/show.xml`;
const PODCAST_ID = `auto-advance-pod-${process.pid}`;
const feed = await feedStore.addFeed(
{
id: PODCAST_ID,
title: "Auto Advance Show",
description: "auto-advance test feed",
feedUrl,
lastUpdated: new Date(),
isSubscribed: true,
},
"test-source",
);
if (!feed || feed.episodes.length < 2) {
throw new Error("test feed did not load two episodes");
}
const ep1 = feed.episodes[0]; // newest — plays first
const ep2 = feed.episodes[1]; // older — must follow automatically
if (ep1.title !== "Episode One") {
throw new Error("episode order unexpected — ep1 is not the newest");
}
afterAll(() => {
audioNav.reset(); // don't leak nav state into shared-worker tests
server?.stop(true);
rmSync(CONFIG, { recursive: true, force: true });
rmSync(DATA, { recursive: true, force: true });
});
test.skipIf(!hasMpv)(
"episode ending auto-plays the next in the show; the last episode stops",
async () => {
const audio = useAudio();
audioNav.setSource(AudioSource.MY_SHOWS, PODCAST_ID);
// Start the newest episode.
await audio.play(ep1);
expect(audio.isPlaying()).toBe(true);
expect(audio.currentEpisode()?.id).toBe(ep1.id);
// EOF → the next (older) episode starts automatically, and the nav
// index moves with it.
await waitFor(
() =>
audio.currentEpisode()?.id === ep2.id && audio.isPlaying(),
);
expect(audioNav.getCurrentIndex()).toBe(1);
// The last episode ends → playback stops; no wrap-around to ep1.
await waitFor(() => !audio.isPlaying());
expect(audio.currentEpisode()?.id).toBe(ep2.id);
await Bun.sleep(600); // give any (wrong) auto-advance time to fire
expect(audio.currentEpisode()?.id).toBe(ep2.id);
expect(audio.isPlaying()).toBe(false);
await audio.stop();
},
{ timeout: 45000 },
);