From 9ddfd216853676e02f3804c10fe8fc2a15f02b7a Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Mon, 17 Aug 2026 20:50:05 -0400 Subject: [PATCH] feat(audio): auto-advance to next episode in source queue on track end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/hooks/useAudio.ts | 232 +++++++++++++++++++----------------- src/utils/audio-player.ts | 29 ++++- src/utils/audio-queue.ts | 88 ++++++++++++++ tests/audio-backend.test.ts | 70 +++++++++++ tests/audio-queue.test.ts | 155 ++++++++++++++++++++++++ tests/auto-advance.test.ts | 197 ++++++++++++++++++++++++++++++ 6 files changed, 656 insertions(+), 115 deletions(-) create mode 100644 src/utils/audio-queue.ts create mode 100644 tests/audio-queue.test.ts create mode 100644 tests/auto-advance.test.ts diff --git a/src/hooks/useAudio.ts b/src/hooks/useAudio.ts index d937061..3927513 100644 --- a/src/hooks/useAudio.ts +++ b/src/hooks/useAudio.ts @@ -55,10 +55,15 @@ import { saveLastPlayerSync, } from "../utils/app-persistence"; import type { Episode, Progress } from "../types/episode"; -import type { Feed } from "../types/feed"; -import { useAudioNavStore, AudioSource } from "../stores/audio-nav"; +import { useAudioNavStore } from "../stores/audio-nav"; import { useDownloadStore } from "../stores/download"; import { useFeedStore } from "../stores/feed"; +import { useSearchStore } from "../stores/search"; +import { + nextStep, + prevStep, + queueForSource, +} from "../utils/audio-queue"; export interface AudioControls { // Signals (reactive getters) @@ -180,8 +185,10 @@ const PAUSE_WATCH_TICKS = 7; /** The player process died while we believed playback was live — track * ended (mpv quits at EOF) or the process crashed. Persist the final - * position and stop polling. */ -function finalizeTrackEnd(): void { + * position and stop polling. `autoAdvance` is true only when the track + * reached its natural end with the player still alive and no stream error + * — the signal to keep the queue going. */ +function finalizeTrackEnd(autoAdvance: boolean): void { setIsPlaying(false); stopPolling(); const ep = currentEpisode(); @@ -189,6 +196,12 @@ function finalizeTrackEnd(): void { const progressStore = useProgressStore(); progressStore.update(ep.id, position(), duration(), speed()); } + if (autoAdvance) { + // The episode finished: play the next one from the source that + // started it (search results / show / feed). No-op at the end of + // the list or when the episode isn't in the source list anymore. + void next().catch(() => {}); + } } /** mpv paused itself OUTSIDE PodTUI — system sleep/lock, AirPod removal, @@ -235,7 +248,12 @@ function startPolling(): void { // and reports pause=true there, which would otherwise be // mistaken for an external pause and never finalize. if (!backend.isPlaying()) { - finalizeTrackEnd(); + // Natural EOF (player alive, no stream error) auto-advances + // to the next episode; a crashed/killed daemon or a failed + // stream must not start the next episode on its own. + finalizeTrackEnd( + backend.isAlive() && !backend.getPlaybackError(), + ); return; } @@ -270,7 +288,7 @@ function startPolling(): void { // still alive: a dead player while we thought we were paused // means the track ended (mpv quits at EOF) or it crashed. if (!backend.isAlive()) { - finalizeTrackEnd(); + finalizeTrackEnd(false); return; } const paused = await backend.getPauseState(); @@ -335,21 +353,52 @@ async function play(episode: Episode): Promise { return; } - try { - const appStore = useAppStore(); - const progressStore = useProgressStore(); - const storeSpeed = appStore.state().settings.playbackSpeed; - const vol = volume(); - const spd = storeSpeed || speed(); + const appStore = useAppStore(); + const progressStore = useProgressStore(); + const storeSpeed = appStore.state().settings.playbackSpeed; + const vol = volume(); + const spd = storeSpeed || speed(); - const feedStore = useFeedStore(); - const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId); - const podcastTitle = feed?.customName || feed?.podcast.title || ""; - // Play the downloaded file when present (offline + no network stalls); - // otherwise stream. Cover resolves to the feed art, falling back to the - // episode's own image (feeds added by URL may lack a channel cover). - const downloadStore = useDownloadStore(); - const url = downloadStore.getDownloadedFilePath(episode.id) ?? episode.audioUrl; + const feedStore = useFeedStore(); + const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId); + const podcastTitle = feed?.customName || feed?.podcast.title || ""; + // Play the downloaded file when present (offline + no network stalls); + // otherwise stream. Cover resolves to the feed art, falling back to the + // episode's own image (feeds added by URL may lack a channel cover). + const downloadStore = useDownloadStore(); + const url = downloadStore.getDownloadedFilePath(episode.id) ?? episode.audioUrl; + + // Resume from saved progress if available and not completed + const savedProgress = progressStore.get(episode.id); + let startPos = 0; + if (savedProgress && !progressStore.isCompleted(episode.id)) { + startPos = savedProgress.position; + } + + // Present the new episode in the UI IMMEDIATELY, before the backend load + // (cover fetch + loadfile can take a few hundred ms): the player tab, + // status bar, and OS Now Playing must not keep showing the previous + // episode during the swap. The previous track's poll is stopped so it + // can't attribute its position/progress to the new episode; polling + // restarts once the backend is actually playing. Mirrors load()'s + // synchronous presentation. + stopPolling(); + setCurrentEpisode(episode); + setIsPlaying(false); + startedPlayback = false; + setPosition(startPos); + setSpeed(spd); + if (episode.duration) setDuration(episode.duration); + const media = useMediaRegistry(); + media.setNowPlaying({ + title: episode.title, + artist: podcastTitle || episode.podcastId, + duration: episode.duration, + }); + media.setPlaybackState(false); + if (startPos > 0) media.setPosition(startPos); + + try { // Cover art only applies at file LOAD (the runtime video-add fallback // never becomes an albumart track), so a cold-cache play must wait for // the fetch or play artless. Serve the disk cache synchronously; on a @@ -360,13 +409,6 @@ async function play(episode: Episode): Promise { "bounded", ); - // Resume from saved progress if available and not completed - const savedProgress = progressStore.get(episode.id); - let startPos = 0; - if (savedProgress && !progressStore.isCompleted(episode.id)) { - startPos = savedProgress.position; - } - await b.play(url, { volume: vol, speed: spd, @@ -375,10 +417,8 @@ async function play(episode: Episode): Promise { coverArtPath: coverArtPath ?? undefined, }); - setCurrentEpisode(episode); setIsPlaying(true); setPosition(startPos); - setSpeed(spd); if (episode.duration) setDuration(episode.duration); startedPlayback = true; @@ -387,12 +427,6 @@ async function play(episode: Episode): Promise { saveLastPlayerToFile({ episodeId: episode.id, timestamp: new Date() }); // Register with platform media controls - const media = useMediaRegistry(); - media.setNowPlaying({ - title: episode.title, - artist: podcastTitle || episode.podcastId, - duration: episode.duration, - }); media.setPlaybackState(true); if (startPos > 0) media.setPosition(startPos); @@ -728,6 +762,60 @@ export async function restoreLastSession(): Promise { * Returns a singleton — all components share the same playback state. * Registers event bus listeners and cleans them up with onCleanup. */ + +// ── Episode queue navigation ────────────────────────────────────────────── +// `next`/`prev` (and the end-of-episode auto-advance in finalizeTrackEnd) +// move within the ordered list of the source that STARTED the current +// episode: the Feed's chronological list, the current show's episodes, or +// the search results (see utils/audio-queue). Module-level so +// finalizeTrackEnd can auto-advance without a mounted hook owner. + +const audioNav = useAudioNavStore(); + +/** The ordered playable episodes for the source that started playback. */ +function queueForCurrentSource(): Episode[] { + const feedStore = useFeedStore(); + return queueForSource( + audioNav.getSource(), + audioNav.getPodcastId(), + feedStore.feeds(), + feedStore.getAllEpisodesChronological(), + useSearchStore().results(), + ); +} + +async function next(): Promise { + const current = currentEpisode(); + if (!current) return; + const step = nextStep(queueForCurrentSource(), current.id); + // A duplicated queue entry (same episode id twice) must not make + // "next" replay the CURRENT episode — that would reload it from + // saved progress and audibly repeat already-played audio. + if (!step || step.episode.id === current.id) return; + await play(step.episode); + audioNav.next(step.index); +} + +async function prev(): Promise { + const current = currentEpisode(); + if (!current) return; + + // Standard transport behavior: past 30s in, "prev" restarts the current + // episode; before that it steps back within the source queue. + const NAV_START_THRESHOLD = 30; + const currentPos = position(); + const currentDur = duration(); + if (currentPos > NAV_START_THRESHOLD && currentDur > 0) { + await seek(NAV_START_THRESHOLD); + return; + } + + const step = prevStep(queueForCurrentSource(), current.id); + if (!step) return; + await play(step.episode); + audioNav.prev(step.index); +} + export function useAudio(): AudioControls { // Initialize backend on first use ensureBackend(); @@ -793,80 +881,6 @@ export function useAudio(): AudioControls { await doSetSpeed(next); }); - const audioNav = useAudioNavStore(); - const feedStore = useFeedStore(); - - async function prev(): Promise { - const current = currentEpisode(); - if (!current) return; - - const currentPos = position(); - const currentDur = duration(); - - const NAV_START_THRESHOLD = 30; - - if (currentPos > NAV_START_THRESHOLD && currentDur > 0) { - await seek(NAV_START_THRESHOLD); - } else { - const source = audioNav.getSource(); - let episodes: Array<{ episode: Episode; feed: Feed }> = []; - - if (source === AudioSource.FEED) { - episodes = feedStore.getAllEpisodesChronological(); - } else if (source === AudioSource.MY_SHOWS) { - const podcastId = audioNav.getPodcastId(); - if (!podcastId) return; - - const feed = feedStore - .getFilteredFeeds() - .find((f) => f.podcast.id === podcastId); - if (!feed) return; - - episodes = feed.episodes.map((ep) => ({ episode: ep, feed })); - } - - const currentIndex = audioNav.getCurrentIndex(); - const newIndex = Math.max(0, currentIndex - 1); - - if (newIndex < episodes.length && episodes[newIndex]) { - const { episode } = episodes[newIndex]; - await play(episode); - audioNav.prev(newIndex); - } - } - } - - async function next(): Promise { - const current = currentEpisode(); - if (!current) return; - - const source = audioNav.getSource(); - let episodes: Array<{ episode: Episode; feed: Feed }> = []; - - if (source === AudioSource.FEED) { - episodes = feedStore.getAllEpisodesChronological(); - } else if (source === AudioSource.MY_SHOWS) { - const podcastId = audioNav.getPodcastId(); - if (!podcastId) return; - - const feed = feedStore - .getFilteredFeeds() - .find((f) => f.podcast.id === podcastId); - if (!feed) return; - - episodes = feed.episodes.map((ep) => ({ episode: ep, feed })); - } - - const currentIndex = audioNav.getCurrentIndex(); - const newIndex = Math.min(episodes.length - 1, currentIndex + 1); - - if (newIndex >= 0 && episodes[newIndex]) { - const { episode } = episodes[newIndex]; - await play(episode); - audioNav.next(newIndex); - } - } - onCleanup(() => { refCount--; unsubPlay(); diff --git a/src/utils/audio-player.ts b/src/utils/audio-player.ts index c41bc71..4c792e9 100644 --- a/src/utils/audio-player.ts +++ b/src/utils/audio-player.ts @@ -82,6 +82,11 @@ export interface AudioBackend { getPauseState(): Promise; /** True while the player process is running (regardless of pause). */ isAlive(): boolean; + /** Last playback error (end-file reason "error"), or null when the last + * track ended cleanly (or nothing has failed yet). Lets callers + * distinguish a natural end-of-file from a stream failure — a failed + * episode must not auto-advance the queue. */ + getPlaybackError(): string | null; dispose(): void; } @@ -591,13 +596,22 @@ export class MpvBackend implements AudioBackend { // play checks it and skips its own stale paused-load. this._intentPlaying = true; await this.runLoadExclusive(async () => { - // Fast path: this exact URL was PRELOADED paused (boot restore) — - // mpv has been buffering it since boot, so flipping pause off starts - // audio ~instantly. Re-acquire the start position only when it - // moved meaningfully since the preload (progress saved meanwhile). - if (this._loadedUrl === url && this._loadedPaused && !this._ended) { + // Same episode re-selected (Enter in a list, key-repeat, a + // second tap on the playing row): the file is ALREADY in the + // player. Reloading with start= would audibly + // skip BACK and repeat already-played audio (saved progress + // lags the live position by up to the 5s persist interval), so + // align in place instead: + // - preload park (loaded paused at boot restore): seek only + // when the caller's target moved materially since load; + // - user-paused: unpause at the CURRENT position (saved + // progress is stale and must not become a backward seek); + // - already playing: unpause is a no-op — nothing to do. + // A genuinely finished episode (_ended) still falls through to + // a fresh load, which replays from the top via isCompleted. + if (this._loadedUrl === url && !this._ended) { const target = opts?.startPosition ?? this._position; - if (Math.abs(target - this._position) > 2) { + if (this._loadedPaused && Math.abs(target - this._position) > 2) { await this.send(["set_property", "time-pos", target]); this._position = target; } @@ -782,6 +796,9 @@ class NoopBackend implements AudioBackend { isAlive(): boolean { return false; } + getPlaybackError(): string | null { + return null; + } dispose(): void {} } diff --git a/src/utils/audio-queue.ts b/src/utils/audio-queue.ts new file mode 100644 index 0000000..f15417b --- /dev/null +++ b/src/utils/audio-queue.ts @@ -0,0 +1,88 @@ +/** + * audio-queue — ordered episode queue for "what plays next" navigation. + * + * Pure selection logic for source-based auto-advance (and manual next/prev): + * given the navigation source that STARTED the current episode, which + * episodes come after it? + * + * FEED — the global chronological Feed list (newest first), so "next" + * walks toward older episodes — further down the list. + * MY_SHOWS — the current show's episode list (newest first), scoped to the + * podcast that started playback. + * SEARCH — the current search results, in display order (episode-kind + * results only — a show result has nothing to play). + * + * Kept dependency-light (pure functions over plain data) so the ordering and + * bounds contract is unit-testable without stores or audio. + */ + +import type { Episode } from "../types/episode"; +import type { Feed } from "../types/feed"; +import type { SearchResult } from "../types/source"; +import { AudioSource } from "../stores/audio-nav"; + +/** The ordered playable queue for a navigation source. Empty when the + * source's context is missing (no podcastId, no search results, no feeds). */ +export function queueForSource( + source: AudioSource, + podcastId: string | undefined, + feeds: Feed[], + allEpisodes: Array<{ episode: Episode; feed: Feed }>, + searchResults: SearchResult[], +): Episode[] { + if (source === AudioSource.FEED) { + // Dedupe by episode id: the same episode can appear twice after a + // refresh merge or when two feeds list it — a duplicate would make + // next/auto-advance step onto the CURRENT episode and replay it. + const seen = new Set(); + const unique: Episode[] = []; + for (const e of allEpisodes) { + if (seen.has(e.episode.id)) continue; + seen.add(e.episode.id); + unique.push(e.episode); + } + return unique; + } + if (source === AudioSource.MY_SHOWS) { + const feed = feeds.find((f) => f.podcast.id === podcastId); + return feed ? feed.episodes : []; + } + if (source === AudioSource.SEARCH) { + return searchResults + .filter((r) => r.kind === "episode") + .map((r) => r.episode); + } + return []; +} + +/** Index of an episode in the queue, or -1 when the episode isn't in it. */ +export function queueIndex(queue: Episode[], episodeId: string): number { + return queue.findIndex((e) => e.id === episodeId); +} + +export interface QueueStep { + episode: Episode; + index: number; +} + +/** The episode after `episodeId` in the queue, with its index. Null when + * the episode isn't in the queue or is already the last one. */ +export function nextStep( + queue: Episode[], + episodeId: string, +): QueueStep | null { + const idx = queueIndex(queue, episodeId); + if (idx < 0 || idx + 1 >= queue.length) return null; + return { episode: queue[idx + 1], index: idx + 1 }; +} + +/** The episode before `episodeId` in the queue, with its index. Null when + * the episode isn't in the queue or is already the first one. */ +export function prevStep( + queue: Episode[], + episodeId: string, +): QueueStep | null { + const idx = queueIndex(queue, episodeId); + if (idx <= 0) return null; + return { episode: queue[idx - 1], index: idx - 1 }; +} diff --git a/tests/audio-backend.test.ts b/tests/audio-backend.test.ts index be2b2f8..8c77405 100644 --- a/tests/audio-backend.test.ts +++ b/tests/audio-backend.test.ts @@ -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 () => { diff --git a/tests/audio-queue.test.ts b/tests/audio-queue.test.ts new file mode 100644 index 0000000..6d1457e --- /dev/null +++ b/tests/audio-queue.test.ts @@ -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); +}); diff --git a/tests/auto-advance.test.ts b/tests/auto-advance.test.ts new file mode 100644 index 0000000..5c0d292 --- /dev/null +++ b/tests/auto-advance.test.ts @@ -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 | 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 ` + +Auto Advance Show +auto-advance test feed + +Episode One +2026-08-10T00:00:00Z + + + +Episode Two +2026-08-01T00:00:00Z + + +`; +} +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 { + 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 }, +);