Files
terebi/Sources/App/CastMediaServer.swift
T

146 lines
5.6 KiB
Swift

import Foundation
struct CastMediaInput: Sendable {
let videoURL: String
let videoHeaders: String
let audioURL: String?
let audioHeaders: String?
let local: Bool
let restricted: Bool
let live: Bool
let duration: Double
let subtitles: [ResolvedSubtitle]
}
struct CastMediaDescription: Sendable {
let url: URL
let contentType: String
let duration: Double
let live: Bool
let tracks: [CastTextTrack]
}
struct CastTextTrack: Sendable {
let id: Int
let url: URL
let language: String
let label: String
}
private struct CastDeliveryError: LocalizedError {
let errorDescription: String? = "Could not deliver cast media. Check the connection and media compatibility. Private details are redacted."
}
// A start task retains this owner until native probing returns. Cancellation never
// frees a handle in use; destruction and worker joins always run off the main actor.
private final class CastMediaLifetime: @unchecked Sendable {
let handle: OpaquePointer
init(input: CastMediaInput) throws {
var error = [CChar](repeating: 0, count: 512)
let result = input.videoURL.withCString { video in
input.videoHeaders.withCString { headers in
(input.audioURL ?? "").withCString { audio in
(input.audioHeaders ?? "").withCString { audioHeaders in
var source = CastMediaSource(video_url: video, video_headers: headers,
audio_url: audio, audio_headers: audioHeaders, local: input.local,
restricted: input.restricted, live: input.live, duration: input.duration)
return cast_media_create(&source, &error)
}
}
}
}
guard let result else { throw CastDeliveryError() }
handle = result
}
func cancel() { cast_media_cancel(handle) }
func start(host: String) throws -> (String, Double) {
var url = [CChar](repeating: 0, count: 512)
var error = [CChar](repeating: 0, count: 512)
var duration = 0.0
guard host.withCString({ cast_media_start(handle, $0, &url, &duration, &error) }) else {
throw CastDeliveryError()
}
return (url.withUnsafeBufferPointer { String(cString: $0.baseAddress!) }, duration)
}
func subtitle(id: Int, data: Data) throws -> URL {
var url = [CChar](repeating: 0, count: 512)
let success = data.withUnsafeBytes {
cast_media_subtitle(handle, Int32(id), $0.baseAddress, $0.count, &url)
}
guard success, let result = URL(string: url.withUnsafeBufferPointer { String(cString: $0.baseAddress!) }) else { throw CastDeliveryError() }
return result
}
deinit {
// Capture an explicitly Sendable owner instead of crossing an actor with a pointer.
let cleanup = CastMediaCleanup(handle: handle)
DispatchQueue.global(qos: .utility).async { cast_media_destroy(cleanup.handle) }
}
}
private struct CastMediaCleanup: @unchecked Sendable { let handle: OpaquePointer }
@MainActor final class CastMediaServer {
private var lifetime: CastMediaLifetime?
private var operation: Task<CastMediaDescription, Error>?
init() {}
func start(input: CastMediaInput, receiverHost: String) async throws -> CastMediaDescription {
stop()
guard input.subtitles.count <= 32 else { throw CastDeliveryError() }
let owner = try CastMediaLifetime(input: input)
lifetime = owner
let task = Task.detached(priority: .userInitiated) {
do {
let (text, duration) = try owner.start(host: receiverHost)
try Task.checkCancellation()
guard let url = URL(string: text) else { throw CastDeliveryError() }
var tracks: [CastTextTrack] = []
for (index, subtitle) in input.subtitles.enumerated() {
try Task.checkCancellation()
let data = try await WebVTTDocument.loadData(url: subtitle.url, headers: subtitle.headers)
guard let text = String(data: data, encoding: .utf8) else { throw CastDeliveryError() }
_ = try WebVTTDocument.parse(text)
let trackURL = try owner.subtitle(id: index + 1, data: data)
tracks.append(CastTextTrack(id: index + 1, url: trackURL,
language: subtitle.language, label: subtitle.label))
}
try Task.checkCancellation()
return CastMediaDescription(url: url, contentType: "application/vnd.apple.mpegurl",
duration: duration, live: input.live, tracks: tracks)
} catch {
owner.cancel()
if Task.isCancelled { throw CancellationError() }
throw CastDeliveryError()
}
}
operation = task
do {
let result = try await withTaskCancellationHandler {
try await task.value
} onCancel: {
owner.cancel()
task.cancel()
}
guard lifetime === owner else { throw CancellationError() }
operation = nil
return result
} catch {
if lifetime === owner { stop() }
if error is CancellationError { throw error }
throw CastDeliveryError()
}
}
func stop() {
operation?.cancel()
operation = nil
lifetime?.cancel()
lifetime = nil
}
deinit {
operation?.cancel()
lifetime?.cancel()
}
}