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