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
156 lines
3.9 KiB
TypeScript
156 lines
3.9 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|