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

297 lines
7.8 KiB
Swift
Raw Normal View History

2025-03-03 20:21:30 -08:00
//
// API.swift
// QueueCube
//
// Created by James Magahern on 3/3/25.
//
import Foundation
struct MediaItem: Codable
{
2025-05-30 16:45:09 -07:00
let filename: String?
2025-03-03 20:21:30 -08:00
let title: String?
let id: Int
let current: Bool?
let playing: Bool?
let metadata: Metadata?
2025-06-11 13:00:09 -07:00
var displayTitle: String {
2025-11-15 17:13:11 -08:00
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
2025-06-11 13:00:09 -07:00
}
2025-03-03 20:21:30 -08:00
// MARK: - Types
struct Metadata: Codable
{
let title: String?
let description: String?
let siteName: String?
}
}
2025-06-11 21:16:59 -07:00
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?
}
2025-03-03 20:21:30 -08:00
struct NowPlayingInfo: Codable
{
let playingItem: MediaItem?
let isPaused: Bool
let volume: Int
}
2025-10-05 18:19:51 -07:00
actor API
2025-03-03 20:21:30 -08:00
{
let baseURL: URL
2025-10-05 18:19:51 -07:00
private var pingTask: Task<(), any Swift.Error>? = nil
2025-03-03 20:21:30 -08:00
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()
}
2025-03-03 20:21:30 -08:00
public func play() async throws {
try await request()
.path("/play")
.post()
}
public func pause() async throws {
try await request()
.path("/pause")
.post()
}
2025-06-11 19:33:20 -07:00
public func stop() async throws {
try await request()
.path("/stop")
.post()
}
2025-03-03 21:08:47 -08:00
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()
}
2025-06-20 18:22:31 -07:00
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()
}
2025-06-11 15:08:17 -07:00
public func deleteFavorite(mediaURL: String) async throws {
try await request()
.pathString("/favorites/\(mediaURL.uriEncoded())")
.method(.delete)
.execute()
}
2025-06-20 18:50:06 -07:00
public func renameFavorite(mediaURL: String, title: String) async throws {
try await request()
.pathString("/favorites/\(mediaURL.uriEncoded())/title")
.body([ "title": title ])
.method(.put)
.execute()
}
2025-03-03 21:08:47 -08:00
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()
}
2025-06-11 21:16:59 -07:00
public func search(query: String) async throws -> FetchResult<[SearchResultItem]> {
try await request()
.pathString("/search?q=\(query.uriEncoded())")
.json()
}
2025-05-30 16:45:09 -07:00
public func events() async throws -> AsyncStream<StreamEvent> {
2025-10-05 18:19:51 -07:00
let requestBuilder: () -> RequestBuilder = request
2025-03-03 20:21:30 -08:00
return AsyncStream { continuation in
2025-10-05 18:19:51 -07:00
let websocketTask: URLSessionWebSocketTask = API.spawnWebsocketTask(requestBuilder: requestBuilder, with: continuation)
2025-03-03 20:21:30 -08:00
Task {
2025-10-05 18:19:51 -07:00
var pingLoopEnabled = true
while pingLoopEnabled {
2025-05-30 16:45:09 -07:00
try await Task.sleep(for: .seconds(5))
2025-03-03 20:21:30 -08:00
2025-05-30 16:45:09 -07:00
websocketTask.sendPing { error in
if let error {
2025-10-05 18:19:51 -07:00
API.notifyError(error, continuation: continuation)
pingLoopEnabled = false
2025-05-30 16:45:09 -07:00
} else {
continuation.yield(.event(Event(type: .receivedWebsocketPong)))
2025-03-03 20:21:30 -08:00
}
}
}
}
}
}
2025-10-05 18:19:51 -07:00
private static func spawnWebsocketTask(
requestBuilder: () -> RequestBuilder,
2025-05-30 16:45:09 -07:00
with continuation: AsyncStream<StreamEvent>.Continuation
) -> URLSessionWebSocketTask
{
2025-10-05 18:19:51 -07:00
let url = requestBuilder()
2025-05-30 16:45:09 -07:00
.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 {
2025-06-20 14:55:55 -07:00
notifyError(error, continuation: continuation)
2025-05-30 16:45:09 -07:00
}
}
return websocketTask
}
2025-10-05 18:19:51 -07:00
private static func notifyError(_ error: any Swift.Error, continuation: AsyncStream<StreamEvent>.Continuation) {
2025-06-20 14:55:55 -07:00
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)))
2025-06-20 14:55:55 -07:00
}
2025-03-03 20:21:30 -08:00
private func request() -> RequestBuilder {
2025-05-02 21:27:46 -07:00
RequestBuilder(url: baseURL)
2025-03-03 20:21:30 -08:00
}
// MARK: - Types
2025-05-02 21:27:46 -07:00
enum Error: Swift.Error
{
case apiNotConfigured
2025-05-30 16:45:09 -07:00
case websocketError(Swift.Error)
}
enum StreamEvent {
case event(Event)
case error(API.Error)
2025-05-02 21:27:46 -07:00
}
2025-03-03 20:21:30 -08:00
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"
2025-05-30 16:45:09 -07:00
// Private UI events
case receivedWebsocketPong
2025-06-20 14:55:55 -07:00
case websocketReconnected
2025-03-03 20:21:30 -08:00
}
}
}
2025-06-11 15:08:17 -07:00
extension String
{
func uriEncoded() -> Self {
return addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)!
}
}