fix: support HTTP byte-range requests on /file so streamed tracks auto-advance

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
Laurent 1 month ago
parent 98f11658ad
commit 6f07349a1e
  1. 128
      Music/Streaming/StreamingServer.swift
  2. 139
      MusicTests/StreamingIntegrationTests.swift
  3. 82
      MusicTests/StreamingPlaybackEndToEndTests.swift

@ -118,6 +118,14 @@ final class StreamingServer {
}
// GET /file?id=TRACKID&token=APIKEY direct file streaming (progressive download)
//
// Supports HTTP byte-range requests (RFC 7233). AVPlayer (plain AVURLAsset,
// no custom resource loader) only treats a progressive HTTP stream as
// *seekable* when the server answers `Range` requests with `206 Partial
// Content` + `Content-Range` + `Accept-Ranges: bytes`. We therefore always
// advertise `Accept-Ranges: bytes` and serve a single requested slice when a
// valid `Range: bytes=START-END` header is present, reading only those bytes
// off disk via a FileHandle (never loading the whole file for a partial read).
router.get("file") { [db] request, _ -> Response in
let hasBearer = request.headers[.authorization] == "Bearer \(apiKey)"
let hasToken = request.uri.queryParameters.get("token") == apiKey
@ -140,17 +148,68 @@ final class StreamingServer {
throw HTTPError(.notFound, message: "File not found on disk")
}
let data = try Data(contentsOf: fileURL)
let contentType = Self.audioContentType(for: fileURL.pathExtension)
return Response(
status: .ok,
headers: [
.contentType: contentType,
.contentLength: String(data.count),
],
body: .init(byteBuffer: ByteBuffer(bytes: data))
)
// Total size without loading the file into memory.
let attrs = try FileManager.default.attributesOfItem(atPath: fileURL.path)
let fileSize = (attrs[.size] as? NSNumber)?.int64Value ?? 0
// Serves the entire file with 200 OK + Accept-Ranges advertisement.
// Used when there's no Range header, or when the header is malformed
// or asks for multiple ranges (graceful fallback never crash).
func fullBodyResponse() throws -> Response {
let data = try Data(contentsOf: fileURL)
return Response(
status: .ok,
headers: [
.contentType: contentType,
.contentLength: String(data.count),
.acceptRanges: "bytes",
],
body: .init(byteBuffer: ByteBuffer(bytes: data))
)
}
// No Range header full body, but still advertise range support.
guard let rangeHeader = request.headers[.range] else {
return try fullBodyResponse()
}
// Multiple ranges (comma-separated) are unsupported here fall back.
guard !rangeHeader.contains(",") else {
return try fullBodyResponse()
}
switch Self.parseByteRange(rangeHeader, fileSize: fileSize) {
case .full:
// Malformed/unparseable Range graceful 200 full-body fallback.
return try fullBodyResponse()
case .unsatisfiable:
return Response(
status: .rangeNotSatisfiable,
headers: [.contentRange: "bytes */\(fileSize)"]
)
case let .range(start, end):
// Read ONLY the requested slice off disk.
let handle = try FileHandle(forReadingFrom: fileURL)
defer { try? handle.close() }
try handle.seek(toOffset: UInt64(start))
let length = Int(end - start + 1)
let slice = try handle.read(upToCount: length) ?? Data()
return Response(
status: .partialContent,
headers: [
.contentType: contentType,
.contentLength: String(slice.count),
.acceptRanges: "bytes",
.contentRange: "bytes \(start)-\(end)/\(fileSize)",
],
body: .init(byteBuffer: ByteBuffer(bytes: slice))
)
}
}
// GET /tracks/:trackId/stream.m3u8
@ -255,6 +314,57 @@ final class StreamingServer {
segmenterCache.clear()
}
/// Outcome of parsing a single-range `Range` header against a known file size.
private enum ByteRangeResult {
/// Serve the whole file (no range / unparseable header).
case full
/// Serve `start...end` inclusive (both already clamped to `0..<fileSize`).
case range(start: Int64, end: Int64)
/// Range is syntactically a range but lies outside the file 416.
case unsatisfiable
}
/// Parses a single-range HTTP `Range` header of the form `bytes=START-END`,
/// resolving the three accepted shapes against `fileSize`:
/// - `bytes=10-19` start=10, end=19 (inclusive)
/// - `bytes=10-` start=10, end=fileSize-1 (open-ended)
/// - `bytes=-500` last 500 bytes: start=fileSize-500, end=fileSize-1 (suffix)
/// `end` is clamped to `fileSize-1`. Any malformed/unparseable header returns
/// `.full` so the caller can fall back to a 200 full-body response.
nonisolated private static func parseByteRange(_ header: String, fileSize: Int64) -> ByteRangeResult {
let trimmed = header.trimmingCharacters(in: .whitespaces)
guard trimmed.hasPrefix("bytes=") else { return .full }
let spec = trimmed.dropFirst("bytes=".count)
guard let dashIndex = spec.firstIndex(of: "-") else { return .full }
let startPart = spec[spec.startIndex..<dashIndex].trimmingCharacters(in: .whitespaces)
let endPart = spec[spec.index(after: dashIndex)...].trimmingCharacters(in: .whitespaces)
if startPart.isEmpty {
// Suffix form: bytes=-N last N bytes.
guard let suffixLength = Int64(endPart), suffixLength > 0 else { return .full }
guard fileSize > 0 else { return .unsatisfiable }
let start = max(0, fileSize - suffixLength)
return .range(start: start, end: fileSize - 1)
}
guard let start = Int64(startPart) else { return .full }
// Out-of-bounds start is unsatisfiable per RFC 7233.
guard start < fileSize else { return .unsatisfiable }
let end: Int64
if endPart.isEmpty {
// Open-ended: bytes=N- through EOF.
end = fileSize - 1
} else {
guard let parsedEnd = Int64(endPart) else { return .full }
end = min(parsedEnd, fileSize - 1)
}
guard start <= end else { return .unsatisfiable }
return .range(start: start, end: end)
}
nonisolated private static func audioContentType(for ext: String) -> String {
switch ext.lowercased() {
case "mp3": return "audio/mpeg"

@ -201,4 +201,143 @@ struct StreamingIntegrationTests {
#expect(http.statusCode == 200)
#expect(data.count > 0)
}
// Reproduces the "seeking does not work in streaming mode" bug.
//
// The client plays the /file endpoint through a plain AVURLAsset/AVPlayer
// with no custom resource loader, so AVPlayer relies on the OS's default
// HTTP transport. AVPlayer only treats a progressive HTTP asset as
// *seekable* when the server honors HTTP byte-range requests: a `Range`
// request must be answered with `206 Partial Content`, a matching
// `Content-Range`, `Accept-Ranges: bytes`, a `Content-Length` equal to the
// slice length, and a body containing ONLY the requested slice. The /file
// endpoint currently ignores the Range header and always returns the whole
// file with `200 OK`, so AVPlayer concludes the stream has no random access
// and silently refuses to seek.
//
// Steps:
// 1. Insert a track pointing at a real audio fixture and start the server.
// 2. Download the full file once to learn its total byte length and exact
// bytes (the ground truth we slice against).
// 3. Request a middle byte range (Range: bytes=10-19) from /file.
// 4. Expect 206 Partial Content, Content-Range: bytes 10-19/<total>,
// Accept-Ranges: bytes, Content-Length: 10, and a body byte-for-byte
// equal to fullBytes[10...19].
@Test(.timeLimit(.minutes(1)))
func fileEndpointHonorsRangeRequests() async throws {
// 1. Insert a track + start the server.
let db = try DatabaseService(inMemory: true)
let fixtureURL = try TestFixtures.shortMP3URL()
var track = Track.fixture(id: nil, fileURL: fixtureURL.absoluteString, title: "Range Test")
try db.insert(&track)
let trackId = try #require(track.id)
let server = StreamingServer(db: db, apiKey: Self.testAPIKey, port: 0)
try await server.start()
defer { server.stop() }
let port = try #require(server.actualPort)
let baseURL = "http://127.0.0.1:\(port)"
let fileURL = URL(string: "\(baseURL)/file?id=\(trackId)&token=\(Self.testAPIKey)")!
// 2. Download the full file to establish ground-truth length + bytes.
let (fullData, fullResp) = try await URLSession.shared.data(for: URLRequest(url: fileURL))
let fullHTTP = try #require(fullResp as? HTTPURLResponse)
#expect(fullHTTP.statusCode == 200)
let total = fullData.count
#expect(total > 20) // fixture must be large enough to slice a middle range
// 3. Request bytes 10-19 (10 bytes, inclusive range).
var rangeReq = URLRequest(url: fileURL)
rangeReq.setValue("bytes=10-19", forHTTPHeaderField: "Range")
let (rangeData, rangeResp) = try await URLSession.shared.data(for: rangeReq)
let rangeHTTP = try #require(rangeResp as? HTTPURLResponse)
// 4. Assert proper Partial Content semantics.
#expect(rangeHTTP.statusCode == 206)
#expect(rangeHTTP.value(forHTTPHeaderField: "Accept-Ranges") == "bytes")
#expect(rangeHTTP.value(forHTTPHeaderField: "Content-Range") == "bytes 10-19/\(total)")
#expect(rangeHTTP.value(forHTTPHeaderField: "Content-Length") == "10")
#expect(rangeData.count == 10)
#expect(rangeData == fullData.subdata(in: 10..<20))
}
// A plain GET with no Range header must still succeed AND advertise
// `Accept-Ranges: bytes` up front, so AVPlayer knows before scrubbing that
// the stream supports random access. Without this header AVPlayer never
// enables seeking even if it would have gotten 206s.
//
// Steps:
// 1. Insert a track + start the server.
// 2. GET /file with no Range header.
// 3. Expect 200 OK, Accept-Ranges: bytes, and the full body.
@Test(.timeLimit(.minutes(1)))
func fileEndpointAdvertisesByteRangesWithoutRangeHeader() async throws {
// 1. Insert a track + start the server.
let db = try DatabaseService(inMemory: true)
let fixtureURL = try TestFixtures.shortMP3URL()
var track = Track.fixture(id: nil, fileURL: fixtureURL.absoluteString, title: "Accept-Ranges Test")
try db.insert(&track)
let trackId = try #require(track.id)
let server = StreamingServer(db: db, apiKey: Self.testAPIKey, port: 0)
try await server.start()
defer { server.stop() }
let port = try #require(server.actualPort)
let baseURL = "http://127.0.0.1:\(port)"
// 2. Plain GET, no Range header.
let fileURL = URL(string: "\(baseURL)/file?id=\(trackId)&token=\(Self.testAPIKey)")!
let (data, resp) = try await URLSession.shared.data(for: URLRequest(url: fileURL))
let http = try #require(resp as? HTTPURLResponse)
// 3. Full body returned, but server advertises range support.
#expect(http.statusCode == 200)
#expect(http.value(forHTTPHeaderField: "Accept-Ranges") == "bytes")
#expect(data.count > 0)
}
// An open-ended range (Range: bytes=N-) means "from byte N to the end"
// the form AVPlayer most commonly issues while streaming forward. The
// server must return 206 with the tail of the file and a Content-Range
// whose end is total-1.
//
// Steps:
// 1. Insert a track + start the server.
// 2. Download the full file for ground-truth length + bytes.
// 3. Request Range: bytes=10- (byte 10 through EOF).
// 4. Expect 206, Content-Range: bytes 10-<total-1>/<total>, and a body
// equal to fullBytes[10...].
@Test(.timeLimit(.minutes(1)))
func fileEndpointHonorsOpenEndedRange() async throws {
// 1. Insert a track + start the server.
let db = try DatabaseService(inMemory: true)
let fixtureURL = try TestFixtures.shortMP3URL()
var track = Track.fixture(id: nil, fileURL: fixtureURL.absoluteString, title: "Open Range Test")
try db.insert(&track)
let trackId = try #require(track.id)
let server = StreamingServer(db: db, apiKey: Self.testAPIKey, port: 0)
try await server.start()
defer { server.stop() }
let port = try #require(server.actualPort)
let baseURL = "http://127.0.0.1:\(port)"
let fileURL = URL(string: "\(baseURL)/file?id=\(trackId)&token=\(Self.testAPIKey)")!
// 2. Ground-truth full file.
let (fullData, _) = try await URLSession.shared.data(for: URLRequest(url: fileURL))
let total = fullData.count
#expect(total > 10)
// 3. Open-ended range from byte 10.
var rangeReq = URLRequest(url: fileURL)
rangeReq.setValue("bytes=10-", forHTTPHeaderField: "Range")
let (rangeData, rangeResp) = try await URLSession.shared.data(for: rangeReq)
let rangeHTTP = try #require(rangeResp as? HTTPURLResponse)
// 4. Tail of the file, correctly described.
#expect(rangeHTTP.statusCode == 206)
#expect(rangeHTTP.value(forHTTPHeaderField: "Content-Range") == "bytes 10-\(total - 1)/\(total)")
#expect(rangeData.count == total - 10)
#expect(rangeData == fullData.subdata(in: 10..<total))
}
}

@ -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…
Cancel
Save