AVPlayer treats a progressive HTTP stream as a seekable, finite asset only when
the server honors Range requests. /file always returned 200 with the whole body
and no Accept-Ranges, so AVPlayer never resolved a duration and reported
end-of-track as AVPlayerItem.status == .failed ("Operation Stopped") instead of
AVPlayerItemDidPlayToEndTime. Because the .failed path never calls
onTrackFinished, the queue stalled and the next track never played.
Honor single-range requests (bytes=N-M, bytes=N-, suffix bytes=-N) with 206 +
Content-Range, advertise Accept-Ranges: bytes on every response, and return 416
for out-of-range; partial reads stream only the requested slice via FileHandle.
Add an end-to-end test that plays a track to its natural end through the real
provider + server and asserts a clean finish (no "Operation Stopped").
feat/music-streaming
parent
98f11658ad
commit
6f07349a1e
@ -0,0 +1,82 @@ |
|||||||
|
import Testing |
||||||
|
import Foundation |
||||||
|
import AVFoundation |
||||||
|
import MusicShared |
||||||
|
@testable import Music |
||||||
|
|
||||||
|
// End-to-end reproduction of the reported bug: |
||||||
|
// "After streaming a full track, AVPlayer fails with 'Operation Stopped' |
||||||
|
// and the player does NOT auto-advance to the next track." |
||||||
|
// |
||||||
|
// These tests drive the *real* StreamingPlaybackProvider against the *real* |
||||||
|
// StreamingServer, exactly as the app does, and assert that playing a track to |
||||||
|
// its natural end results in a clean finish (onTrackFinished fires, no error). |
||||||
|
@MainActor |
||||||
|
struct StreamingPlaybackEndToEndTests { |
||||||
|
static let testAPIKey = "e2e-test-key" |
||||||
|
|
||||||
|
// Spins the main run loop (where AVPlayer delivers its callbacks) until |
||||||
|
// `condition` is true or `timeout` seconds elapse. Returns true if the |
||||||
|
// condition was met. Polls in small slices so an async @MainActor test |
||||||
|
// lets AVPlayer's main-queue observers run. |
||||||
|
private func waitUntil(timeout: Double, _ condition: () -> Bool) async -> Bool { |
||||||
|
let slices = Int(timeout / 0.05) |
||||||
|
for _ in 0..<slices { |
||||||
|
if condition() { return true } |
||||||
|
try? await Task.sleep(nanoseconds: 50_000_000) // 50 ms |
||||||
|
} |
||||||
|
return condition() |
||||||
|
} |
||||||
|
|
||||||
|
// Reproduces the auto-advance failure at end-of-track. |
||||||
|
// Steps: |
||||||
|
// 1. Create an in-memory DB and insert a track pointing at a real audio |
||||||
|
// fixture, stored the way the production scanner stores it (absoluteString). |
||||||
|
// 2. Start the real StreamingServer on an OS-assigned port. |
||||||
|
// 3. Create the real StreamingPlaybackProvider pointed at that server. |
||||||
|
// 4. Install an onTrackFinished callback that records the clean-finish signal |
||||||
|
// (this is the callback PlayerViewModel uses to advance to the next track). |
||||||
|
// 5. Begin playback of the track's /file URL and let it play to the end. |
||||||
|
// 6. Wait until EITHER a clean finish fires OR a playback error appears. |
||||||
|
// 7. Assert: no playback error occurred AND the clean-finish callback fired. |
||||||
|
// On the current (buggy) server this fails — playback ends in an error |
||||||
|
// instead of a clean finish, so the next track never plays. |
||||||
|
@Test(.timeLimit(.minutes(1))) |
||||||
|
func playingTrackToEndFiresCleanFinish() async throws { |
||||||
|
// 1. DB + fixture track (stored as absoluteString, like the real scanner). |
||||||
|
let db = try DatabaseService(inMemory: true) |
||||||
|
let fixtureURL = try TestFixtures.shortMP3URL() |
||||||
|
var track = Track.fixture(id: nil, fileURL: fixtureURL.absoluteString, title: "E2E Track") |
||||||
|
try db.insert(&track) |
||||||
|
|
||||||
|
// 2. Start the real streaming server. |
||||||
|
let server = StreamingServer(db: db, apiKey: Self.testAPIKey, port: 0) |
||||||
|
try await server.start() |
||||||
|
defer { server.stop() } |
||||||
|
let port = try #require(server.actualPort) |
||||||
|
|
||||||
|
// 3. Real provider against the real server. |
||||||
|
let provider = StreamingPlaybackProvider(hostURL: "http://127.0.0.1:\(port)", apiKey: Self.testAPIKey) |
||||||
|
|
||||||
|
// 4. Record the clean-finish signal (drives PlayerViewModel.next()). |
||||||
|
var finishedCleanly = false |
||||||
|
provider.onTrackFinished = { finishedCleanly = true } |
||||||
|
|
||||||
|
// 5. Start playback of the track. |
||||||
|
let url = try #require(provider.urlForTrack(track)) |
||||||
|
provider.play(url: url) |
||||||
|
|
||||||
|
// 6. Wait for a terminal outcome: clean finish or error. The fixture is a |
||||||
|
// few seconds long; allow generous headroom for buffering. |
||||||
|
_ = await waitUntil(timeout: 25) { |
||||||
|
finishedCleanly || provider.playbackError != nil |
||||||
|
} |
||||||
|
|
||||||
|
// --- Diagnostics (printed regardless of pass/fail) --- |
||||||
|
print("E2E DIAGNOSTIC -> finishedCleanly=\(finishedCleanly), playbackError=\(String(describing: provider.playbackError)), providerDuration=\(provider.duration), currentTime=\(provider.currentTime)") |
||||||
|
|
||||||
|
// 7. A fully streamed track must end cleanly so the queue can advance. |
||||||
|
#expect(provider.playbackError == nil) |
||||||
|
#expect(finishedCleanly == true) |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue