From 6f07349a1e2c6bb35a1c7117ad7db223dbe512e7 Mon Sep 17 00:00:00 2001 From: Laurent Date: Sat, 30 May 2026 10:27:58 +0200 Subject: [PATCH] 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"). --- Music/Streaming/StreamingServer.swift | 128 ++++++++++++++-- MusicTests/StreamingIntegrationTests.swift | 139 ++++++++++++++++++ .../StreamingPlaybackEndToEndTests.swift | 82 +++++++++++ 3 files changed, 340 insertions(+), 9 deletions(-) create mode 100644 MusicTests/StreamingPlaybackEndToEndTests.swift diff --git a/Music/Streaming/StreamingServer.swift b/Music/Streaming/StreamingServer.swift index a41dd17..21711d3 100644 --- a/Music/Streaming/StreamingServer.swift +++ b/Music/Streaming/StreamingServer.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.. 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.. 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" diff --git a/MusicTests/StreamingIntegrationTests.swift b/MusicTests/StreamingIntegrationTests.swift index d3f7bb7..2c350c6 100644 --- a/MusicTests/StreamingIntegrationTests.swift +++ b/MusicTests/StreamingIntegrationTests.swift @@ -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/, + // 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-/, 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.. Bool) async -> Bool { + let slices = Int(timeout / 0.05) + for _ in 0.. 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) + } +}