Files
furumi_macos/furumi_macos/CatalogService.swift
T
Ultradesu ab352b9595 fix: send Last.fm now-playing notification when track starts
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>
2026-06-09 10:50:08 +01:00

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 }