ab352b9595
Add POST /api/player/lastfm/now-playing call on every track start via onTrackStarted callback in PlayerManager, matching other clients. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
261 lines
10 KiB
Swift
261 lines
10 KiB
Swift
//
|
|
// CatalogService.swift
|
|
// furumi_macos
|
|
//
|
|
|
|
import Foundation
|
|
|
|
enum CatalogError: LocalizedError {
|
|
case httpError(statusCode: Int, message: String)
|
|
case decodingError(String)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .httpError(let code, let message):
|
|
return message.isEmpty ? "Server error \(code)" : "Server error \(code): \(message)"
|
|
case .decodingError(let detail):
|
|
return "Response parsing failed: \(detail)"
|
|
}
|
|
}
|
|
}
|
|
|
|
struct CatalogService {
|
|
let baseUrl: String
|
|
let authHeaderProvider: () async -> String
|
|
let onUnauthorized: () async -> Bool
|
|
|
|
private let decoder: JSONDecoder = {
|
|
let d = JSONDecoder()
|
|
d.keyDecodingStrategy = .convertFromSnakeCase
|
|
return d
|
|
}()
|
|
|
|
private func makeRequest(path: String, query: [URLQueryItem] = []) async throws -> URLRequest {
|
|
guard var components = URLComponents(string: baseUrl) else {
|
|
throw URLError(.badURL)
|
|
}
|
|
components.path = "/" + path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
|
if !query.isEmpty {
|
|
components.queryItems = query
|
|
}
|
|
guard let url = components.url else { throw URLError(.badURL) }
|
|
var req = URLRequest(url: url)
|
|
req.httpMethod = "GET"
|
|
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
req.setValue(await authHeaderProvider(), forHTTPHeaderField: "Authorization")
|
|
return req
|
|
}
|
|
|
|
private func checkResponse(_ response: URLResponse, data: Data) throws {
|
|
guard let http = response as? HTTPURLResponse else {
|
|
throw URLError(.badServerResponse)
|
|
}
|
|
guard (200..<300).contains(http.statusCode) else {
|
|
let message = (try? decoder.decode(ErrorResponse.self, from: data))?.error
|
|
?? (String(data: data, encoding: .utf8).map { $0.prefix(200) }.map(String.init) ?? "")
|
|
throw CatalogError.httpError(statusCode: http.statusCode, message: message)
|
|
}
|
|
}
|
|
|
|
private func decode<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {
|
|
do {
|
|
return try decoder.decode(type, from: data)
|
|
} catch let e as DecodingError {
|
|
throw CatalogError.decodingError(e.localizedDescription)
|
|
}
|
|
}
|
|
|
|
// Fetches data with a single 401-triggered refresh+retry
|
|
private func post(path: String, body: Data) async throws -> Data {
|
|
guard var components = URLComponents(string: baseUrl) else { throw URLError(.badURL) }
|
|
components.path = "/" + path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
|
guard let url = components.url else { throw URLError(.badURL) }
|
|
|
|
func makeReq() async -> URLRequest {
|
|
var r = URLRequest(url: url)
|
|
r.httpMethod = "POST"
|
|
r.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
r.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
r.httpBody = body
|
|
return r
|
|
}
|
|
|
|
var req = await makeReq()
|
|
req.setValue(await authHeaderProvider(), forHTTPHeaderField: "Authorization")
|
|
let (data, response) = try await URLSession.shared.data(for: req)
|
|
|
|
if let http = response as? HTTPURLResponse, http.statusCode == 401 {
|
|
guard await onUnauthorized() else {
|
|
throw CatalogError.httpError(statusCode: 401, message: "Unauthorized")
|
|
}
|
|
var retry = await makeReq()
|
|
retry.setValue(await authHeaderProvider(), forHTTPHeaderField: "Authorization")
|
|
let (data2, response2) = try await URLSession.shared.data(for: retry)
|
|
try checkResponse(response2, data: data2)
|
|
return data2
|
|
}
|
|
try checkResponse(response, data: data)
|
|
return data
|
|
}
|
|
|
|
private func fetch(path: String, query: [URLQueryItem] = []) async throws -> Data {
|
|
let req = try await makeRequest(path: path, query: query)
|
|
let (data, response) = try await URLSession.shared.data(for: req)
|
|
|
|
if let http = response as? HTTPURLResponse, http.statusCode == 401 {
|
|
guard await onUnauthorized() else {
|
|
throw CatalogError.httpError(statusCode: 401, message: "Unauthorized")
|
|
}
|
|
let retryReq = try await makeRequest(path: path, query: query)
|
|
let (data2, response2) = try await URLSession.shared.data(for: retryReq)
|
|
try checkResponse(response2, data: data2)
|
|
return data2
|
|
}
|
|
|
|
try checkResponse(response, data: data)
|
|
return data
|
|
}
|
|
|
|
func artists(page: Int, limit: Int, mine: Bool) async throws -> ArtistPage {
|
|
let data = try await fetch(
|
|
path: "api/player/artists",
|
|
query: [
|
|
URLQueryItem(name: "page", value: String(page)),
|
|
URLQueryItem(name: "limit", value: String(limit)),
|
|
URLQueryItem(name: "mine", value: mine ? "true" : "false")
|
|
]
|
|
)
|
|
return try decode(ArtistPageDTO.self, from: data).toDomain(baseUrl: baseUrl)
|
|
}
|
|
|
|
func artistDetail(id: Int64) async throws -> ArtistDetail {
|
|
let data = try await fetch(path: "api/player/artists/\(id)")
|
|
return try decode(ArtistDetailDTO.self, from: data).toDomain(baseUrl: baseUrl)
|
|
}
|
|
|
|
func releaseDetail(id: Int64) async throws -> ReleaseDetail {
|
|
let data = try await fetch(path: "api/player/releases/\(id)")
|
|
return try decode(ReleaseDetailDTO.self, from: data).toDomain(baseUrl: baseUrl)
|
|
}
|
|
|
|
func search(query: String, limit: Int) async throws -> SearchResult {
|
|
let data = try await fetch(
|
|
path: "api/player/search",
|
|
query: [
|
|
URLQueryItem(name: "q", value: query),
|
|
URLQueryItem(name: "limit", value: String(limit))
|
|
]
|
|
)
|
|
return try decode(SearchResponseDTO.self, from: data).toDomain(baseUrl: baseUrl)
|
|
}
|
|
|
|
// MARK: - Device Hub
|
|
|
|
private static let deviceEncoder: JSONEncoder = {
|
|
let e = JSONEncoder()
|
|
e.keyEncodingStrategy = .convertToSnakeCase
|
|
return e
|
|
}()
|
|
|
|
func devicePoll(deviceId: String, userAgent: String, playbackState: DevicePlaybackState?) async throws -> Data {
|
|
struct Body: Encodable {
|
|
let deviceId: String
|
|
let userAgent: String?
|
|
let currentJamId: String?
|
|
let playbackState: DevicePlaybackState?
|
|
}
|
|
let body = try Self.deviceEncoder.encode(Body(deviceId: deviceId, userAgent: userAgent, currentJamId: nil, playbackState: playbackState))
|
|
return try await post(path: "api/player/devices/poll", body: body)
|
|
}
|
|
|
|
func deviceSelect(currentDeviceId: String, targetDeviceId: String) async throws -> Data {
|
|
struct Body: Encodable {
|
|
let deviceId: String
|
|
let currentDeviceId: String?
|
|
}
|
|
let body = try Self.deviceEncoder.encode(Body(deviceId: targetDeviceId, currentDeviceId: currentDeviceId))
|
|
return try await post(path: "api/player/devices/active", body: body)
|
|
}
|
|
|
|
func deviceCommand(command: String, targetDeviceId: String?, payloadData: Data) async throws {
|
|
// Build JSON manually to embed arbitrary payload
|
|
var obj: [String: Any] = ["command": command]
|
|
if let tid = targetDeviceId { obj["target_device_id"] = tid }
|
|
if let payloadObj = try? JSONSerialization.jsonObject(with: payloadData) {
|
|
obj["payload"] = payloadObj
|
|
} else {
|
|
obj["payload"] = [String: Any]()
|
|
}
|
|
let body = try JSONSerialization.data(withJSONObject: obj)
|
|
_ = try await post(path: "api/player/devices/command", body: body)
|
|
}
|
|
|
|
func lastfmNowPlaying(trackId: Int64) async throws {
|
|
struct Body: Encodable { let trackId: Int64 }
|
|
let body = try Self.deviceEncoder.encode(Body(trackId: trackId))
|
|
_ = try await post(path: "api/player/lastfm/now-playing", body: body)
|
|
}
|
|
|
|
func recordHistory(trackId: Int64, startedAt: Int64?, durationListened: Int32, completed: Bool) async throws {
|
|
struct Body: Encodable {
|
|
let trackId: Int64
|
|
let startedAt: Int64?
|
|
let durationListened: Int32
|
|
let completed: Bool
|
|
}
|
|
let body = try Self.deviceEncoder.encode(Body(
|
|
trackId: trackId, startedAt: startedAt,
|
|
durationListened: durationListened, completed: completed
|
|
))
|
|
_ = try await post(path: "api/player/history", body: body)
|
|
}
|
|
|
|
func playlists() async throws -> [PlaylistCard] {
|
|
let data = try await fetch(path: "api/player/playlists")
|
|
return try decode([PlaylistCardDTO].self, from: data).map { $0.toDomain() }
|
|
}
|
|
|
|
func playlistDetail(id: Int64) async throws -> PlaylistDetail {
|
|
let data = try await fetch(path: "api/player/playlists/\(id)")
|
|
return try decode(PlaylistDetailDTO.self, from: data).toDomain(baseUrl: baseUrl)
|
|
}
|
|
|
|
func likedIds() async throws -> Set<Int64> {
|
|
let data = try await fetch(path: "api/player/likes")
|
|
let dto = try decode(LikedIdsDTO.self, from: data)
|
|
return Set(dto.trackIds)
|
|
}
|
|
|
|
// Returns new liked state after toggle
|
|
func toggleLike(trackId: Int64) async throws -> Bool {
|
|
guard var components = URLComponents(string: baseUrl) else { throw URLError(.badURL) }
|
|
components.path = "/api/player/likes/toggle/\(trackId)"
|
|
guard let url = components.url else { throw URLError(.badURL) }
|
|
|
|
func makeReq() async -> URLRequest {
|
|
var r = URLRequest(url: url)
|
|
r.httpMethod = "POST"
|
|
r.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
r.setValue(await authHeaderProvider(), forHTTPHeaderField: "Authorization")
|
|
return r
|
|
}
|
|
|
|
let req = await makeReq()
|
|
let (data, response) = try await URLSession.shared.data(for: req)
|
|
|
|
if let http = response as? HTTPURLResponse, http.statusCode == 401 {
|
|
guard await onUnauthorized() else {
|
|
throw CatalogError.httpError(statusCode: 401, message: "Unauthorized")
|
|
}
|
|
let retryReq = await makeReq()
|
|
let (data2, response2) = try await URLSession.shared.data(for: retryReq)
|
|
try checkResponse(response2, data: data2)
|
|
return try decode(LikeStatusDTO.self, from: data2).liked
|
|
}
|
|
try checkResponse(response, data: data)
|
|
return try decode(LikeStatusDTO.self, from: data).liked
|
|
}
|
|
}
|
|
|
|
private struct LikeStatusDTO: Decodable { let liked: Bool }
|