NavidromeApp/Shared/API/SubsonicClient.swift
2026-04-12 12:57:42 -07:00

353 lines
16 KiB
Swift

import Foundation
import CryptoKit
// MARK: - Subsonic API Client
/// Full Subsonic/Navidrome API client supporting all endpoints
class SubsonicClient: ObservableObject {
private let session: URLSession
private let apiVersion = "1.16.1"
private let clientName = "NavidromePlayer"
@Published var currentServer: ServerConfig?
@Published var isAuthenticated = false
@Published var lastError: String?
init() {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 30
config.timeoutIntervalForResource = 300
self.session = URLSession(configuration: config)
}
// MARK: - Authentication Helpers
private func authParams(for server: ServerConfig? = nil) -> [URLQueryItem] {
guard let srv = server ?? currentServer else { return [] }
// Use token-based auth (salt + token)
let salt = randomSalt()
let token = md5Hash(srv.password + salt)
return [
URLQueryItem(name: "u", value: srv.username),
URLQueryItem(name: "t", value: token),
URLQueryItem(name: "s", value: salt),
URLQueryItem(name: "v", value: apiVersion),
URLQueryItem(name: "c", value: clientName),
URLQueryItem(name: "f", value: "json")
]
}
private func buildURL(server: ServerConfig? = nil, endpoint: String, params: [URLQueryItem] = []) -> URL? {
guard let srv = server ?? currentServer,
let baseURL = srv.baseURL else { return nil }
var components = URLComponents(url: baseURL.appendingPathComponent("rest/\(endpoint)"), resolvingAgainstBaseURL: false)
components?.queryItems = authParams(for: srv) + params
return components?.url
}
private func randomSalt(length: Int = 12) -> String {
let chars = "abcdefghijklmnopqrstuvwxyz0123456789"
return String((0..<length).map { _ in chars.randomElement()! })
}
private func md5Hash(_ string: String) -> String {
let data = Data(string.utf8)
let hash = Insecure.MD5.hash(data: data)
return hash.map { String(format: "%02x", $0) }.joined()
}
// MARK: - Generic Request
private func requestBody(endpoint: String, params: [URLQueryItem] = [], server: ServerConfig? = nil) async throws -> SubsonicResponseBody {
guard let url = buildURL(server: server, endpoint: endpoint, params: params) else {
throw APIError.invalidURL
}
let (data, response) = try await session.data(from: url)
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
throw APIError.httpError(httpResponse.statusCode)
}
let decoder = JSONDecoder()
let subsonicResp = try decoder.decode(SubsonicResponse.self, from: data)
if let error = subsonicResp.subsonicResponse.error {
throw APIError.subsonicError(error.code, error.message)
}
return subsonicResp.subsonicResponse
}
// MARK: - Raw Data Request (for streaming/downloading)
func requestData(endpoint: String, params: [URLQueryItem] = [], server: ServerConfig? = nil) async throws -> (Data, URLResponse) {
guard let url = buildURL(server: server, endpoint: endpoint, params: params) else {
throw APIError.invalidURL
}
return try await session.data(from: url)
}
func streamURL(songId: String, format: String? = nil, maxBitRate: Int? = nil, server: ServerConfig? = nil) -> URL? {
var params: [URLQueryItem] = [URLQueryItem(name: "id", value: songId)]
if let fmt = format { params.append(URLQueryItem(name: "format", value: fmt)) }
if let br = maxBitRate { params.append(URLQueryItem(name: "maxBitRate", value: "\(br)")) }
return buildURL(server: server, endpoint: "stream", params: params)
}
func coverArtURL(id: String, size: Int? = nil, server: ServerConfig? = nil) -> URL? {
var params: [URLQueryItem] = [URLQueryItem(name: "id", value: id)]
if let s = size { params.append(URLQueryItem(name: "size", value: "\(s)")) }
return buildURL(server: server, endpoint: "getCoverArt", params: params)
}
// MARK: - System
func ping(server: ServerConfig? = nil) async throws -> Bool {
let body = try await requestBody(endpoint: "ping", server: server)
return body.status == "ok"
}
// MARK: - Browsing
func getIndexes(musicFolderId: String? = nil, ifModifiedSince: Int64? = nil) async throws -> [ArtistIndex] {
var params: [URLQueryItem] = []
if let fid = musicFolderId { params.append(URLQueryItem(name: "musicFolderId", value: fid)) }
if let since = ifModifiedSince { params.append(URLQueryItem(name: "ifModifiedSince", value: "\(since)")) }
let body = try await requestBody(endpoint: "getIndexes", params: params)
return body.indexes?.index ?? []
}
func getGenres() async throws -> [Genre] {
let body = try await requestBody(endpoint: "getGenres")
return body.genres?.genre ?? []
}
func getArtists(musicFolderId: String? = nil) async throws -> [ArtistIndex] {
var params: [URLQueryItem] = []
if let fid = musicFolderId { params.append(URLQueryItem(name: "musicFolderId", value: fid)) }
let body = try await requestBody(endpoint: "getArtists", params: params)
return body.artists?.index ?? []
}
func getArtist(id: String) async throws -> ArtistWithAlbums? {
let params = [URLQueryItem(name: "id", value: id)]
let body = try await requestBody(endpoint: "getArtist", params: params)
return body.artist
}
func getAlbum(id: String) async throws -> AlbumWithSongs? {
let params = [URLQueryItem(name: "id", value: id)]
let body = try await requestBody(endpoint: "getAlbum", params: params)
return body.album
}
func getSong(id: String) async throws -> Song? {
let params = [URLQueryItem(name: "id", value: id)]
let body = try await requestBody(endpoint: "getSong", params: params)
return body.song
}
// MARK: - Album Lists
func getAlbumList2(type: String, size: Int = 20, offset: Int = 0, fromYear: Int? = nil, toYear: Int? = nil, genre: String? = nil, musicFolderId: String? = nil) async throws -> [Album] {
var params: [URLQueryItem] = [
URLQueryItem(name: "type", value: type),
URLQueryItem(name: "size", value: "\(size)"),
URLQueryItem(name: "offset", value: "\(offset)")
]
if let fy = fromYear { params.append(URLQueryItem(name: "fromYear", value: "\(fy)")) }
if let ty = toYear { params.append(URLQueryItem(name: "toYear", value: "\(ty)")) }
if let g = genre { params.append(URLQueryItem(name: "genre", value: g)) }
if let fid = musicFolderId { params.append(URLQueryItem(name: "musicFolderId", value: fid)) }
let body = try await requestBody(endpoint: "getAlbumList2", params: params)
return body.albumList2?.album ?? []
}
func getRandomSongs(size: Int = 20, genre: String? = nil, fromYear: Int? = nil, toYear: Int? = nil, musicFolderId: String? = nil) async throws -> [Song] {
var params: [URLQueryItem] = [URLQueryItem(name: "size", value: "\(size)")]
if let g = genre { params.append(URLQueryItem(name: "genre", value: g)) }
if let fy = fromYear { params.append(URLQueryItem(name: "fromYear", value: "\(fy)")) }
if let ty = toYear { params.append(URLQueryItem(name: "toYear", value: "\(ty)")) }
if let fid = musicFolderId { params.append(URLQueryItem(name: "musicFolderId", value: fid)) }
let body = try await requestBody(endpoint: "getRandomSongs", params: params)
return body.randomSongs?.song ?? []
}
func getStarred2(musicFolderId: String? = nil) async throws -> Starred2Container? {
var params: [URLQueryItem] = []
if let fid = musicFolderId { params.append(URLQueryItem(name: "musicFolderId", value: fid)) }
let body = try await requestBody(endpoint: "getStarred2", params: params)
return body.starred2
}
func getLyrics(artist: String? = nil, title: String? = nil) async throws -> LyricsResult? {
var params: [URLQueryItem] = []
if let a = artist { params.append(URLQueryItem(name: "artist", value: a)) }
if let t = title { params.append(URLQueryItem(name: "title", value: t)) }
let body = try await requestBody(endpoint: "getLyrics", params: params)
return body.lyrics
}
/// Instant Mix returns similar songs to seed song
func getSimilarSongs2(id: String, count: Int = 50) async throws -> [Song] {
let params = [
URLQueryItem(name: "id", value: id),
URLQueryItem(name: "count", value: "\(count)")
]
let body = try await requestBody(endpoint: "getSimilarSongs2", params: params)
return body.similarSongs2?.song ?? []
}
/// Add a new internet radio station
func createInternetRadioStation(streamUrl: String, name: String, homepageUrl: String? = nil) async throws {
var params = [
URLQueryItem(name: "streamUrl", value: streamUrl),
URLQueryItem(name: "name", value: name)
]
if let hp = homepageUrl { params.append(URLQueryItem(name: "homepageUrl", value: hp)) }
_ = try await requestBody(endpoint: "createInternetRadioStation", params: params)
}
/// Delete an internet radio station
func deleteInternetRadioStation(id: String) async throws {
_ = try await requestBody(endpoint: "deleteInternetRadioStation", params: [URLQueryItem(name: "id", value: id)])
}
// MARK: - Search
func search3(query: String, artistCount: Int = 20, artistOffset: Int = 0, albumCount: Int = 20, albumOffset: Int = 0, songCount: Int = 20, songOffset: Int = 0, musicFolderId: String? = nil) async throws -> SearchResult3? {
var params: [URLQueryItem] = [
URLQueryItem(name: "query", value: query),
URLQueryItem(name: "artistCount", value: "\(artistCount)"),
URLQueryItem(name: "artistOffset", value: "\(artistOffset)"),
URLQueryItem(name: "albumCount", value: "\(albumCount)"),
URLQueryItem(name: "albumOffset", value: "\(albumOffset)"),
URLQueryItem(name: "songCount", value: "\(songCount)"),
URLQueryItem(name: "songOffset", value: "\(songOffset)")
]
if let fid = musicFolderId { params.append(URLQueryItem(name: "musicFolderId", value: fid)) }
let body = try await requestBody(endpoint: "search3", params: params)
return body.searchResult3
}
// MARK: - Playlists
func getPlaylists(username: String? = nil) async throws -> [Playlist] {
var params: [URLQueryItem] = []
if let u = username { params.append(URLQueryItem(name: "username", value: u)) }
let body = try await requestBody(endpoint: "getPlaylists", params: params)
return body.playlists?.playlist ?? []
}
func getPlaylist(id: String) async throws -> PlaylistWithSongs? {
let params = [URLQueryItem(name: "id", value: id)]
let body = try await requestBody(endpoint: "getPlaylist", params: params)
return body.playlist
}
func createPlaylist(name: String, songIds: [String] = []) async throws {
var params: [URLQueryItem] = [URLQueryItem(name: "name", value: name)]
for sid in songIds {
params.append(URLQueryItem(name: "songId", value: sid))
}
_ = try await requestBody(endpoint: "createPlaylist", params: params)
}
func updatePlaylist(id: String, name: String? = nil, comment: String? = nil, isPublic: Bool? = nil, songIdsToAdd: [String] = [], songIndexesToRemove: [Int] = []) async throws {
var params: [URLQueryItem] = [URLQueryItem(name: "playlistId", value: id)]
if let n = name { params.append(URLQueryItem(name: "name", value: n)) }
if let c = comment { params.append(URLQueryItem(name: "comment", value: c)) }
if let p = isPublic { params.append(URLQueryItem(name: "public", value: p ? "true" : "false")) }
for sid in songIdsToAdd { params.append(URLQueryItem(name: "songIdToAdd", value: sid)) }
for idx in songIndexesToRemove { params.append(URLQueryItem(name: "songIndexToRemove", value: "\(idx)")) }
_ = try await requestBody(endpoint: "updatePlaylist", params: params)
}
/// Reorders a playlist to match the supplied song array.
/// Removes all current entries then re-adds in new order atomic in one API call.
/// Subsonic applies removes first, then adds, so the result is exactly `songs` in order.
func reorderPlaylist(id: String, songs: [Song]) async throws {
let allIndices = Array(0..<songs.count)
let songIds = songs.map { $0.id }
try await updatePlaylist(id: id, songIdsToAdd: songIds, songIndexesToRemove: allIndices)
}
func deletePlaylist(id: String) async throws {
let params = [URLQueryItem(name: "id", value: id)]
_ = try await requestBody(endpoint: "deletePlaylist", params: params)
}
// MARK: - Media Annotation
func star(id: String? = nil, albumId: String? = nil, artistId: String? = nil) async throws {
var params: [URLQueryItem] = []
if let i = id { params.append(URLQueryItem(name: "id", value: i)) }
if let ai = albumId { params.append(URLQueryItem(name: "albumId", value: ai)) }
if let ari = artistId { params.append(URLQueryItem(name: "artistId", value: ari)) }
_ = try await requestBody(endpoint: "star", params: params)
}
func unstar(id: String? = nil, albumId: String? = nil, artistId: String? = nil) async throws {
var params: [URLQueryItem] = []
if let i = id { params.append(URLQueryItem(name: "id", value: i)) }
if let ai = albumId { params.append(URLQueryItem(name: "albumId", value: ai)) }
if let ari = artistId { params.append(URLQueryItem(name: "artistId", value: ari)) }
_ = try await requestBody(endpoint: "unstar", params: params)
}
func scrobble(id: String, time: Int64? = nil, submission: Bool = true) async throws {
var params: [URLQueryItem] = [
URLQueryItem(name: "id", value: id),
URLQueryItem(name: "submission", value: submission ? "true" : "false")
]
if let t = time { params.append(URLQueryItem(name: "time", value: "\(t)")) }
_ = try await requestBody(endpoint: "scrobble", params: params)
}
// MARK: - Media Retrieval
func downloadSong(id: String) async throws -> (Data, URLResponse) {
let params = [URLQueryItem(name: "id", value: id)]
return try await requestData(endpoint: "download", params: params)
}
// MARK: - Internet Radio
func getInternetRadioStations() async throws -> [RadioStation] {
let body = try await requestBody(endpoint: "getInternetRadioStations")
return body.internetRadioStations?.internetRadioStation ?? []
}
}
// MARK: - API Errors
enum APIError: LocalizedError {
case invalidURL
case invalidResponse
case httpError(Int)
case subsonicError(Int, String)
case noServer
case downloadFailed
var errorDescription: String? {
switch self {
case .invalidURL: return "Invalid server URL"
case .invalidResponse: return "Invalid response from server"
case .httpError(let code): return "HTTP error: \(code)"
case .subsonicError(_, let msg): return msg
case .noServer: return "No server configured"
case .downloadFailed: return "Download failed"
}
}
}