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.. 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 request(endpoint: String, params: [URLQueryItem] = [], server: ServerConfig? = nil) async throws -> T { 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) } // Re-decode to extract the specific type return try decoder.decode(T.self, from: data) } 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" } func getLicense(server: ServerConfig? = nil) async throws -> SubsonicResponseBody { return try await requestBody(endpoint: "getLicense", server: server) } func getScanStatus() async throws -> ScanStatus? { let body = try await requestBody(endpoint: "getScanStatus") return body.scanStatus } func startScan() async throws { _ = try await requestBody(endpoint: "startScan") } // MARK: - Browsing func getMusicFolders() async throws -> [MusicFolder] { let body = try await requestBody(endpoint: "getMusicFolders") return body.musicFolders?.musicFolder ?? [] } 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 getMusicDirectory(id: String) async throws -> DirectoryContainer? { let params = [URLQueryItem(name: "id", value: id)] let body = try await requestBody(endpoint: "getMusicDirectory", params: params) return body.directory } 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 getNowPlaying() async throws -> [NowPlayingEntry] { let body = try await requestBody(endpoint: "getNowPlaying") return body.nowPlaying?.entry ?? [] } 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) } 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 setRating(id: String, rating: Int) async throws { let params = [ URLQueryItem(name: "id", value: id), URLQueryItem(name: "rating", value: "\(rating)") ] _ = try await requestBody(endpoint: "setRating", 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: - User Management func getUser(username: String) async throws -> SubsonicResponseBody { let params = [URLQueryItem(name: "username", value: username)] return try await requestBody(endpoint: "getUser", params: params) } func getUsers() async throws -> SubsonicResponseBody { return try await requestBody(endpoint: "getUsers") } // 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) } func getAvatar(username: String) async throws -> (Data, URLResponse) { let params = [URLQueryItem(name: "username", value: username)] return try await requestData(endpoint: "getAvatar", params: params) } // MARK: - Bookmarks func getBookmarks() async throws -> SubsonicResponseBody { return try await requestBody(endpoint: "getBookmarks") } func createBookmark(id: String, position: Int64, comment: String? = nil) async throws { var params: [URLQueryItem] = [ URLQueryItem(name: "id", value: id), URLQueryItem(name: "position", value: "\(position)") ] if let c = comment { params.append(URLQueryItem(name: "comment", value: c)) } _ = try await requestBody(endpoint: "createBookmark", params: params) } func deleteBookmark(id: String) async throws { let params = [URLQueryItem(name: "id", value: id)] _ = try await requestBody(endpoint: "deleteBookmark", params: params) } // MARK: - Internet Radio func getInternetRadioStations() async throws -> [RadioStation] { let body = try await requestBody(endpoint: "getInternetRadioStations") return body.internetRadioStations?.internetRadioStation ?? [] } // MARK: - Shares func getShares() async throws -> SubsonicResponseBody { return try await requestBody(endpoint: "getShares") } func createShare(ids: [String], description: String? = nil, expires: Int64? = nil) async throws -> SubsonicResponseBody { var params: [URLQueryItem] = [] for id in ids { params.append(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)")) } return try await requestBody(endpoint: "createShare", params: params) } func deleteShare(id: String) async throws { let params = [URLQueryItem(name: "id", value: id)] _ = try await requestBody(endpoint: "deleteShare", params: params) } } // 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" } } }