Archived
Remove embedded music player
The music feature is retired: delete the Media tab, MediaAPI, MediaPlayerController, PinnedMediaResourceLoader, MediaPanelView, the media_state control publish, media scope handling in VoiceSession, the volume setting, and all media tests. App is now Talk + Activity tabs. Backend media API in hope-voice-api is untouched (still serves the web UI).
This commit is contained in:
1 parent
3620a2387b
commit
ed329213de
18 files changed
+55
-2393
No files matched your search
@@ -1,125 +0,0 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Voice
|
||||
|
||||
/// Minimal HTTP/1.1 stub built on BSD sockets that answers every request with a
|
||||
/// successful JSON object body (mirroring the real media server's
|
||||
/// `{"ok": true, ...}` shape). Used to prove MediaAPI no-response POSTs succeed
|
||||
/// against an object body instead of throwing decodeFailed.
|
||||
@MainActor
|
||||
final class MediaAPIStubServer {
|
||||
private(set) var url: URL!
|
||||
nonisolated(unsafe) private var serverFD: Int32 = -1
|
||||
nonisolated(unsafe) private var acceptThread: Thread?
|
||||
nonisolated(unsafe) private var running = false
|
||||
nonisolated(unsafe) private var lastPath: String?
|
||||
|
||||
func start() throws {
|
||||
let fd = socket(AF_INET, SOCK_STREAM, 0)
|
||||
guard fd >= 0 else { throw MediaAPIError.decodeFailed }
|
||||
var yes: Int32 = 1
|
||||
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, socklen_t(MemoryLayout<Int32>.size))
|
||||
|
||||
var addr = sockaddr_in()
|
||||
addr.sin_family = sa_family_t(AF_INET)
|
||||
addr.sin_port = in_port_t(0).bigEndian
|
||||
addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1"))
|
||||
let bindResult = withUnsafePointer(to: &addr) { ptr in
|
||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
||||
bind(fd, sa, socklen_t(MemoryLayout<sockaddr_in>.size))
|
||||
}
|
||||
}
|
||||
guard bindResult == 0 else { close(fd); throw MediaAPIError.decodeFailed }
|
||||
guard listen(fd, 8) == 0 else { close(fd); throw MediaAPIError.decodeFailed }
|
||||
|
||||
var bound = sockaddr_in()
|
||||
var len = socklen_t(MemoryLayout<sockaddr_in>.size)
|
||||
withUnsafeMutablePointer(to: &bound) { ptr in
|
||||
_ = ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
||||
getsockname(fd, sa, &len)
|
||||
}
|
||||
}
|
||||
let port = Int(UInt16(bigEndian: bound.sin_port))
|
||||
serverFD = fd
|
||||
running = true
|
||||
url = URL(string: "http://127.0.0.1:\(port)")!
|
||||
|
||||
let thread = Thread { [weak self] in
|
||||
self?.acceptLoop()
|
||||
}
|
||||
acceptThread = thread
|
||||
thread.start()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
running = false
|
||||
if serverFD >= 0 { close(serverFD) }
|
||||
serverFD = -1
|
||||
acceptThread?.cancel()
|
||||
}
|
||||
|
||||
var lastRequestPath: String? { lastPath }
|
||||
|
||||
nonisolated private func acceptLoop() {
|
||||
while running {
|
||||
var clientAddr = sockaddr_in()
|
||||
var len = socklen_t(MemoryLayout<sockaddr_in>.size)
|
||||
let clientFD = withUnsafeMutablePointer(to: &clientAddr) { ptr in
|
||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
||||
accept(serverFD, sa, &len)
|
||||
}
|
||||
}
|
||||
guard clientFD >= 0 else { continue }
|
||||
handleClient(clientFD)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private func handleClient(_ clientFD: Int32) {
|
||||
defer { close(clientFD) }
|
||||
var buffer = [UInt8](repeating: 0, count: 4096)
|
||||
let bytesRead = read(clientFD, &buffer, buffer.count)
|
||||
guard bytesRead > 0 else { return }
|
||||
guard let request = String(bytes: buffer[0..<bytesRead], encoding: .utf8) else { return }
|
||||
|
||||
var path = ""
|
||||
if let firstLine = request.split(separator: "\r\n").first {
|
||||
let parts = firstLine.split(separator: " ")
|
||||
if parts.count >= 2 { path = String(parts[1]) }
|
||||
}
|
||||
lastPath = path
|
||||
|
||||
let body = #"{"ok": true, "room": "test", "desired": "stopped"}"#
|
||||
let response = """
|
||||
HTTP/1.1 200 OK\r
|
||||
Content-Type: application/json\r
|
||||
Content-Length: \(body.utf8.count)\r
|
||||
Connection: close\r
|
||||
|
||||
\(body)
|
||||
"""
|
||||
_ = response.withCString { cstr in
|
||||
write(clientFD, cstr, response.utf8.count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct MediaAPITests {
|
||||
@Test("successful JSON object response does not throw decodeFailed")
|
||||
func successfulObjectResponse() async throws {
|
||||
let stub = MediaAPIStubServer()
|
||||
try stub.start()
|
||||
defer { stub.stop() }
|
||||
|
||||
// Bypass the https-only parse path: build the config directly.
|
||||
let base = stub.url!
|
||||
let config = ServerConfig(baseURL: base, livekitURL: base)
|
||||
let api = MediaAPI(config: config)
|
||||
|
||||
try await api.control(room: "test", action: "pause")
|
||||
try await api.report(room: "test", state: "paused", trackID: nil, position: 0, duration: nil)
|
||||
try await api.play(room: "test", videoID: nil, title: nil, channel: nil, trackID: nil, startPaused: nil)
|
||||
|
||||
#expect(stub.lastRequestPath == "/media/api/play")
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Voice
|
||||
|
||||
private final class PlaybackProtocol: URLProtocol, @unchecked Sendable {
|
||||
static let lock = NSLock()
|
||||
nonisolated(unsafe) static var status = 200
|
||||
nonisolated(unsafe) static var requests: [URLRequest] = []
|
||||
override class func canInit(with request: URLRequest) -> Bool { true }
|
||||
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
||||
override func startLoading() {
|
||||
Self.lock.lock()
|
||||
Self.requests.append(request)
|
||||
let code = Self.status
|
||||
Self.lock.unlock()
|
||||
let response = HTTPURLResponse(url: request.url!, statusCode: code, httpVersion: nil, headerFields: ["Content-Type": "application/json"])!
|
||||
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
||||
client?.urlProtocol(self, didLoad: Data("{\"ok\":true}".utf8))
|
||||
client?.urlProtocolDidFinishLoading(self)
|
||||
}
|
||||
override func stopLoading() {}
|
||||
}
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor struct MediaPlaybackTests {
|
||||
private func controller(status: Int = 200) -> MediaPlayerController {
|
||||
PlaybackProtocol.lock.withLock {
|
||||
PlaybackProtocol.status = status
|
||||
PlaybackProtocol.requests = []
|
||||
}
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.protocolClasses = [PlaybackProtocol.self]
|
||||
let url = URL(string: "https://media.test")!
|
||||
let api = MediaAPI(config: ServerConfig(baseURL: url, livekitURL: url), session: URLSession(configuration: configuration))
|
||||
return MediaPlayerController(api: api, settings: SettingsStore())
|
||||
}
|
||||
|
||||
@Test("standalone playback sends stop to the default server room")
|
||||
func standaloneStop() async throws {
|
||||
let player = controller()
|
||||
await player.stop()
|
||||
let calls = PlaybackProtocol.lock.withLock { PlaybackProtocol.requests }
|
||||
let stop = try #require(calls.first { $0.url?.path == "/media/api/control" })
|
||||
// URLSession may expose the encoded body as an InputStream to URLProtocol.
|
||||
let body: Data
|
||||
if let data = stop.httpBody { body = data }
|
||||
else {
|
||||
let stream = try #require(stop.httpBodyStream)
|
||||
stream.open()
|
||||
defer { stream.close() }
|
||||
var data = Data()
|
||||
var buffer = [UInt8](repeating: 0, count: 1024)
|
||||
while stream.hasBytesAvailable {
|
||||
let count = stream.read(&buffer, maxLength: buffer.count)
|
||||
if count <= 0 { break }
|
||||
data.append(contentsOf: buffer.prefix(count))
|
||||
}
|
||||
body = data
|
||||
}
|
||||
let json = try #require(JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||
#expect(json["room"] as? String == "default")
|
||||
#expect(json["action"] as? String == "stop")
|
||||
}
|
||||
|
||||
@Test("failed play requests are visible and a successful retry clears the error")
|
||||
func failureAndRetry() async {
|
||||
let player = controller(status: 503)
|
||||
await player.playTrack(id: "song")
|
||||
#expect(player.playbackError != nil)
|
||||
PlaybackProtocol.lock.withLock { PlaybackProtocol.status = 200 }
|
||||
await player.playTrack(id: "song")
|
||||
#expect(player.playbackError == nil)
|
||||
}
|
||||
|
||||
@Test("metadata refreshes when a download becomes ready")
|
||||
func readyMetadata() {
|
||||
let player = controller()
|
||||
func state(_ status: String) -> MediaState {
|
||||
MediaState(room: "default", track: MediaTrack(id: "song", kind: "youtube", videoID: nil, title: status, channel: nil, duration: 180, status: status, error: nil, audioURL: nil, artURL: nil), desired: "paused", reported: nil, seekHint: nil, choices: [], pending: [], version: 1)
|
||||
}
|
||||
player.reconcile(state("downloading"))
|
||||
player.reconcile(state("ready"))
|
||||
#expect(player.track?.status == "ready")
|
||||
#expect(player.track?.title == "ready")
|
||||
player.resetScope()
|
||||
#expect(player.track == nil)
|
||||
#expect(player.player.currentItem == nil)
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Voice
|
||||
|
||||
@MainActor
|
||||
struct MediaReconcileTests {
|
||||
private func makeController() -> MediaPlayerController {
|
||||
let config = ServerConfig(
|
||||
baseURL: URL(string: "http://127.0.0.1:9")!,
|
||||
livekitURL: URL(string: "ws://127.0.0.1:9")!
|
||||
)
|
||||
let api = MediaAPI(config: config)
|
||||
return MediaPlayerController(api: api, settings: SettingsStore())
|
||||
}
|
||||
|
||||
private func makeState(
|
||||
desired: String? = "stopped",
|
||||
trackID: String? = nil,
|
||||
audioURL: String? = nil
|
||||
) -> MediaState {
|
||||
let track: MediaTrack? = trackID != nil ? MediaTrack(
|
||||
id: trackID!,
|
||||
kind: nil,
|
||||
videoID: nil,
|
||||
title: nil,
|
||||
channel: nil,
|
||||
duration: nil,
|
||||
status: nil,
|
||||
error: nil,
|
||||
audioURL: audioURL,
|
||||
artURL: nil
|
||||
) : nil
|
||||
return MediaState(
|
||||
room: "default",
|
||||
track: track,
|
||||
desired: desired,
|
||||
reported: nil,
|
||||
seekHint: nil,
|
||||
choices: [],
|
||||
pending: [],
|
||||
version: 1
|
||||
)
|
||||
}
|
||||
|
||||
@Test("reconcile updates published desired to playing")
|
||||
func reconcileDesiredPlaying() {
|
||||
let controller = makeController()
|
||||
#expect(controller.desired == "stopped")
|
||||
|
||||
controller.reconcile(makeState(desired: "playing"))
|
||||
#expect(controller.desired == "playing")
|
||||
}
|
||||
|
||||
@Test("reconcile updates published desired to paused")
|
||||
func reconcileDesiredPaused() {
|
||||
let controller = makeController()
|
||||
controller.reconcile(makeState(desired: "playing"))
|
||||
#expect(controller.desired == "playing")
|
||||
|
||||
controller.reconcile(makeState(desired: "paused"))
|
||||
#expect(controller.desired == "paused")
|
||||
}
|
||||
|
||||
@Test("reconcile updates published desired to stopped")
|
||||
func reconcileDesiredStopped() {
|
||||
let controller = makeController()
|
||||
controller.reconcile(makeState(desired: "playing"))
|
||||
#expect(controller.desired == "playing")
|
||||
|
||||
controller.reconcile(makeState(desired: "stopped"))
|
||||
#expect(controller.desired == "stopped")
|
||||
}
|
||||
|
||||
@Test("reconcile with nil desired defaults to stopped")
|
||||
func reconcileNilDesiredDefaultsToStopped() {
|
||||
let controller = makeController()
|
||||
controller.reconcile(makeState(desired: "playing"))
|
||||
#expect(controller.desired == "playing")
|
||||
|
||||
controller.reconcile(makeState(desired: nil))
|
||||
#expect(controller.desired == "stopped")
|
||||
}
|
||||
|
||||
@Test("late audio: desired=playing before audio_url, then audio_url arrives")
|
||||
func lateAudioReconciliation() {
|
||||
let controller = makeController()
|
||||
|
||||
// Phase 1: server says playing but the track has no audio_url yet
|
||||
// (still downloading). The source is not wired, so play cannot start.
|
||||
let stateNoURL = makeState(desired: "playing", trackID: "track-1", audioURL: nil)
|
||||
controller.reconcile(stateNoURL)
|
||||
#expect(controller.desired == "playing")
|
||||
#expect(controller.track?.id == "track-1")
|
||||
// Player rate should still be zero because no source was wired.
|
||||
#expect(controller.player.rate.isZero)
|
||||
|
||||
// Phase 2: the same track now has an audio_url. The reconcile path
|
||||
// wires the source (wireSource) and then applyDesiredState retries
|
||||
// play because desired is still "playing" and rate is still zero.
|
||||
let stateWithURL = makeState(desired: "playing", trackID: "track-1", audioURL: "/stream/track-1.mp3")
|
||||
controller.reconcile(stateWithURL)
|
||||
#expect(controller.desired == "playing")
|
||||
// The source should now be wired (loadedAudioPath is set internally).
|
||||
// We can't directly observe loadedAudioPath, but we can verify the
|
||||
// player has a current item after wireSource ran.
|
||||
#expect(controller.player.currentItem != nil)
|
||||
}
|
||||
|
||||
@Test("startDefaultPolling restarts after endConversation keeps scope")
|
||||
func pollingRestartsAfterEndConversation() async {
|
||||
let controller = makeController()
|
||||
controller.startDefaultPolling()
|
||||
await controller.endConversation()
|
||||
controller.startDefaultPolling()
|
||||
try? await Task.sleep(for: .milliseconds(50))
|
||||
}
|
||||
|
||||
@Test("togglePlayPause without a loaded track is a no-op")
|
||||
func toggleWithoutTrack() async {
|
||||
let controller = makeController()
|
||||
await controller.togglePlayPause()
|
||||
#expect(controller.track == nil)
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Voice
|
||||
|
||||
/// Focused tests for PinnedMediaResourceLoader.parseTotalLength, covering
|
||||
/// 200, 206 with known total, 206 with unknown total (*), and malformed
|
||||
/// Content-Range headers (Finding 1 / 11).
|
||||
struct PinnedMediaResourceLoaderTests {
|
||||
|
||||
private func makeResponse(status: Int, contentRange: String? = nil, expectedLength: Int64 = -1) -> HTTPURLResponse {
|
||||
var headers: [String: String] = [:]
|
||||
if let contentRange {
|
||||
headers["Content-Range"] = contentRange
|
||||
}
|
||||
return HTTPURLResponse(
|
||||
url: URL(string: "https://example.com/media/test.mp3")!,
|
||||
statusCode: status,
|
||||
httpVersion: "HTTP/1.1",
|
||||
headerFields: headers
|
||||
)!
|
||||
}
|
||||
|
||||
@Test("206 with known total parses Content-Range total")
|
||||
func parse206KnownTotal() {
|
||||
let response = makeResponse(status: 206, contentRange: "bytes 0-1023/4096", expectedLength: 1024)
|
||||
#expect(PinnedMediaResourceLoader.parseTotalLength(response) == 4096)
|
||||
}
|
||||
|
||||
@Test("206 with unknown total (*) returns nil")
|
||||
func parse206UnknownTotal() {
|
||||
let response = makeResponse(status: 206, contentRange: "bytes 0-1023/*", expectedLength: 1024)
|
||||
#expect(PinnedMediaResourceLoader.parseTotalLength(response) == nil)
|
||||
}
|
||||
|
||||
@Test("200 response returns nil (expectedContentLength is already the full size)")
|
||||
func parse200ReturnsNil() {
|
||||
let response = makeResponse(status: 200, expectedLength: 8192)
|
||||
#expect(PinnedMediaResourceLoader.parseTotalLength(response) == nil)
|
||||
}
|
||||
|
||||
@Test("206 without Content-Range header returns nil")
|
||||
func parse206NoContentRange() {
|
||||
let response = makeResponse(status: 206, expectedLength: 512)
|
||||
#expect(PinnedMediaResourceLoader.parseTotalLength(response) == nil)
|
||||
}
|
||||
|
||||
@Test("malformed Content-Range returns nil")
|
||||
func parseMalformedContentRange() {
|
||||
let response = makeResponse(status: 206, contentRange: "garbage", expectedLength: 100)
|
||||
#expect(PinnedMediaResourceLoader.parseTotalLength(response) == nil)
|
||||
}
|
||||
|
||||
@Test("206 with large total parses correctly")
|
||||
func parseLargeTotal() {
|
||||
let response = makeResponse(status: 206, contentRange: "bytes 1048576-2097151/1073741824", expectedLength: 1048576)
|
||||
#expect(PinnedMediaResourceLoader.parseTotalLength(response) == 1_073_741_824)
|
||||
}
|
||||
|
||||
@Test("streamURL constructs endpoint without downloading")
|
||||
func streamURLConstruction() {
|
||||
let config = ServerConfig(
|
||||
baseURL: URL(string: "https://192.168.86.2:9178")!,
|
||||
livekitURL: URL(string: "ws://192.168.86.2:7880")!
|
||||
)
|
||||
let api = MediaAPI(config: config)
|
||||
let url = api.streamURL(trackID: "track-123")
|
||||
// streamURL builds under the /media/api base (MediaAPI.baseURL).
|
||||
#expect(url.absoluteString == "https://192.168.86.2:9178/media/api/stream/track-123")
|
||||
}
|
||||
|
||||
// MARK: Rewrite / Resolve round-trip (Item 7)
|
||||
|
||||
@Test("rewrite maps https URL to pinned-media scheme and resolve inverts it")
|
||||
func rewriteResolveRoundTrip() {
|
||||
let loader = PinnedMediaResourceLoader(base: URL(string: "https://192.168.86.2:9178")!)
|
||||
let original = URL(string: "https://192.168.86.2:9178/media/track-123.mp3")!
|
||||
let pinned = loader.rewrite(original)
|
||||
#expect(pinned.scheme == "pinned-media")
|
||||
#expect(pinned.absoluteString == "pinned-media://192.168.86.2:9178/media/track-123.mp3")
|
||||
|
||||
let resolved = loader.resolve(pinned)
|
||||
#expect(resolved.absoluteString == original.absoluteString)
|
||||
}
|
||||
|
||||
@Test("rewrite maps path-only URL using base host and port")
|
||||
func rewritePathOnly() {
|
||||
let loader = PinnedMediaResourceLoader(base: URL(string: "https://192.168.86.2:9178")!)
|
||||
let pathURL = URL(string: "/media/track-456.mp3")!
|
||||
let pinned = loader.rewrite(pathURL)
|
||||
#expect(pinned.scheme == "pinned-media")
|
||||
#expect(pinned.absoluteString == "pinned-media://192.168.86.2:9178/media/track-456.mp3")
|
||||
}
|
||||
|
||||
@Test("resolve leaves non-pinned URLs unchanged")
|
||||
func resolveNonPinned() {
|
||||
let loader = PinnedMediaResourceLoader(base: URL(string: "https://192.168.86.2:9178")!)
|
||||
let https = URL(string: "https://example.com/foo.mp3")!
|
||||
#expect(loader.resolve(https) == https)
|
||||
}
|
||||
|
||||
// MARK: Asset lifecycle (Item 7)
|
||||
|
||||
@Test("assetWithPinnedURL creates asset and removeLoader clears mapping")
|
||||
func assetLifecycle() {
|
||||
let loader = PinnedMediaResourceLoader(base: URL(string: "https://192.168.86.2:9178")!)
|
||||
let pinned = loader.rewrite(URL(string: "https://192.168.86.2:9178/media/test.mp3")!)
|
||||
let asset = loader.assetWithPinnedURL(pinned)
|
||||
|
||||
// The asset should have a valid URL matching the pinned scheme.
|
||||
#expect(asset.url.scheme == PinnedMediaResourceLoader.scheme)
|
||||
|
||||
// Remove the loader mapping — subsequent calls should not crash.
|
||||
loader.removeLoader(asset)
|
||||
}
|
||||
|
||||
@Test("removeLoader is idempotent (safe to call twice)")
|
||||
func removeLoaderIdempotent() {
|
||||
let loader = PinnedMediaResourceLoader(base: URL(string: "https://192.168.86.2:9178")!)
|
||||
let pinned = loader.rewrite(URL(string: "https://192.168.86.2:9178/media/test.mp3")!)
|
||||
let asset = loader.assetWithPinnedURL(pinned)
|
||||
|
||||
loader.removeLoader(asset)
|
||||
// Second call should be a no-op, not a crash.
|
||||
loader.removeLoader(asset)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user