Files
QueueCube/ios/QueueCube/Backend/API.swift

299 lines
7.8 KiB
Swift

//
// API.swift
// QueueCube
//
// Created by James Magahern on 3/3/25.
//
import Foundation
struct MediaItem: Codable
{
let filename: String?
let title: String?
let id: Int
let current: Bool?
let playing: Bool?
let metadata: Metadata?
let playbackError: String?
var displayTitle: String {
metadata?.title ?? title ?? displayFilename ?? "item \(id)"
}
private var displayFilename: String? {
guard let filename else { return nil }
if let url = URL(string: filename) {
return url.lastPathComponent
}
return filename
}
// MARK: - Types
struct Metadata: Codable
{
let title: String?
let description: String?
let siteName: String?
}
}
struct SearchResultItem: Codable
{
var type: String
var title: String
var author: String
var mediaUrl: String
var thumbnailUrl: String
}
struct FetchResult<T: Codable>: Codable
{
let success: Bool
let results: T?
let error: String?
}
struct NowPlayingInfo: Codable
{
let playingItem: MediaItem?
let isPaused: Bool
let volume: Int
}
actor API
{
let baseURL: URL
private var pingTask: Task<(), any Swift.Error>? = nil
init(baseURL: URL) {
self.baseURL = baseURL
}
public func fetchNowPlayingInfo() async throws -> NowPlayingInfo {
try await request()
.path("/nowplaying")
.json()
}
public func fetchPlaylist() async throws -> [MediaItem] {
try await request()
.path("/playlist")
.json()
}
public func fetchFavorites() async throws -> [MediaItem] {
try await request()
.path("/favorites")
.json()
}
public func play() async throws {
try await request()
.path("/play")
.post()
}
public func pause() async throws {
try await request()
.path("/pause")
.post()
}
public func stop() async throws {
try await request()
.path("/stop")
.post()
}
public func skip(_ to: Int? = nil) async throws {
let path = if let to { "/skip/\(to)" } else { "/skip" }
try await request()
.path(path)
.post()
}
public func previous() async throws {
try await request()
.path("/previous")
.post()
}
public func add(mediaURL: String) async throws {
try await request()
.path("/playlist")
.body([ "url" : mediaURL ])
.post()
}
public func replace(mediaURL: String) async throws {
try await request()
.path("/playlist/replace")
.body([ "url" : mediaURL ])
.post()
}
public func addFavorite(mediaURL: String) async throws {
try await request()
.path("/favorites")
.body([ "filename" : mediaURL ])
.post()
}
public func deleteFavorite(mediaURL: String) async throws {
try await request()
.pathString("/favorites/\(mediaURL.uriEncoded())")
.method(.delete)
.execute()
}
public func renameFavorite(mediaURL: String, title: String) async throws {
try await request()
.pathString("/favorites/\(mediaURL.uriEncoded())/title")
.body([ "title": title ])
.method(.put)
.execute()
}
public func delete(index: Int) async throws {
try await request()
.path("/playlist/\(index)")
.method(.delete)
.execute()
}
public func setVolume(_ value: Double) async throws {
try await request()
.path("/volume")
.body([ "volume" : Int(value * 100) ])
.post()
}
public func search(query: String) async throws -> FetchResult<[SearchResultItem]> {
try await request()
.pathString("/search?q=\(query.uriEncoded())")
.json()
}
public func events() async throws -> AsyncStream<StreamEvent> {
let requestBuilder: () -> RequestBuilder = request
return AsyncStream { continuation in
let websocketTask: URLSessionWebSocketTask = API.spawnWebsocketTask(requestBuilder: requestBuilder, with: continuation)
Task {
var pingLoopEnabled = true
while pingLoopEnabled {
try await Task.sleep(for: .seconds(5))
websocketTask.sendPing { error in
if let error {
API.notifyError(error, continuation: continuation)
pingLoopEnabled = false
} else {
continuation.yield(.event(Event(type: .receivedWebsocketPong)))
}
}
}
}
}
}
private static func spawnWebsocketTask(
requestBuilder: () -> RequestBuilder,
with continuation: AsyncStream<StreamEvent>.Continuation
) -> URLSessionWebSocketTask
{
let url = requestBuilder()
.path("/events")
.websocket()
let websocketTask = URLSession.shared.webSocketTask(with: url)
websocketTask.resume()
Task {
do {
let event = { (data: Data) in
try JSONDecoder().decode(Event.self, from: data)
}
while websocketTask.state == .running {
switch try await websocketTask.receive() {
case .string(let string):
let event = try event(string.data(using: .utf8)!)
continuation.yield(.event(event))
case .data(let data):
let event = try event(data)
continuation.yield(.event(event))
default:
break
}
}
} catch {
notifyError(error, continuation: continuation)
}
}
return websocketTask
}
private static func notifyError(_ error: any Swift.Error, continuation: AsyncStream<StreamEvent>.Continuation) {
print("Websocket Error: \(error)")
// Always notify observers of WebSocket errors so reconnection can happen
// The UI layer can decide whether to show the error to the user
continuation.yield(.error(.websocketError(error)))
}
private func request() -> RequestBuilder {
RequestBuilder(url: baseURL)
}
// MARK: - Types
enum Error: Swift.Error
{
case apiNotConfigured
case websocketError(Swift.Error)
}
enum StreamEvent {
case event(Event)
case error(API.Error)
}
struct Event: Decodable
{
let type: EventType
enum CodingKeys: String, CodingKey {
case type = "event"
}
enum EventType: String, Decodable {
case playlistUpdate = "playlist_update"
case nowPlayingUpdate = "now_playing_update"
case volumeUpdate = "volume_update"
case favoritesUpdate = "favorites_update"
case metadataUpdate = "metadata_update"
case mpdUpdate = "mpd_update"
case playbackError = "playback_error"
// Private UI events
case receivedWebsocketPong
case websocketReconnected
}
}
}
extension String
{
func uriEncoded() -> Self {
return addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)!
}
}