381 lines
17 KiB
Swift
381 lines
17 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: - Play Queue (cross-device sync)
|
|
|
|
/// Save the current play queue to the server for cross-device resume.
|
|
func savePlayQueue(songIds: [String], current: String? = nil, position: Int64 = 0) async throws {
|
|
var params: [URLQueryItem] = songIds.map { URLQueryItem(name: "id", value: $0) }
|
|
if let c = current { params.append(URLQueryItem(name: "current", value: c)) }
|
|
params.append(URLQueryItem(name: "position", value: "\(position)"))
|
|
_ = try await requestBody(endpoint: "savePlayQueue", params: params)
|
|
}
|
|
|
|
/// Get the saved play queue from the server (saved by any device).
|
|
func getPlayQueue() async throws -> PlayQueue? {
|
|
let body = try await requestBody(endpoint: "getPlayQueue")
|
|
return body.playQueue
|
|
}
|
|
|
|
// MARK: - Shares (public links)
|
|
|
|
/// Create a sharing link for a song, album, or playlist.
|
|
/// Returns the created share, or nil if the server doesn't support sharing.
|
|
func createShare(id: String, description: String? = nil, expires: Int64? = nil) async throws -> ShareItem? {
|
|
var params = [URLQueryItem(name: "id", value: id)]
|
|
if let d = description { params.append(URLQueryItem(name: "description", value: d)) }
|
|
if let e = expires { params.append(URLQueryItem(name: "expires", value: "\(e)")) }
|
|
let body = try await requestBody(endpoint: "createShare", params: params)
|
|
return body.shares?.share?.first
|
|
}
|
|
|
|
}
|
|
|
|
// 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"
|
|
}
|
|
}
|
|
}
|