Add Spotify integration with toggleable music source
- Add OAuth 2.0 PKCE authentication for Spotify Web API - Create SpotifyNowPlayingMonitor for polling current track - Add Settings tab with music source toggle (Apple Music/Spotify) - Store tokens securely in Keychain - Display current track on Glass as NOW_PLAYING card 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
61
IrisCompanion/iris/Spotify/KeychainHelper.swift
Normal file
61
IrisCompanion/iris/Spotify/KeychainHelper.swift
Normal file
@@ -0,0 +1,61 @@
|
||||
//
|
||||
// KeychainHelper.swift
|
||||
// iris
|
||||
//
|
||||
// Secure token storage using iOS Keychain.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
enum KeychainHelper {
|
||||
private static let service = "sh.nym.iris.spotify"
|
||||
|
||||
static func save(key: String, data: Data) throws {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
|
||||
let attributes: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecValueData as String: data,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
|
||||
]
|
||||
let status = SecItemAdd(attributes as CFDictionary, nil)
|
||||
guard status == errSecSuccess else {
|
||||
throw KeychainError.saveFailed(status)
|
||||
}
|
||||
}
|
||||
|
||||
static func load(key: String) -> Data? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne
|
||||
]
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
guard status == errSecSuccess else { return nil }
|
||||
return result as? Data
|
||||
}
|
||||
|
||||
static func delete(key: String) {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
|
||||
enum KeychainError: Error {
|
||||
case saveFailed(OSStatus)
|
||||
}
|
||||
}
|
||||
77
IrisCompanion/iris/Spotify/SpotifyAPIClient.swift
Normal file
77
IrisCompanion/iris/Spotify/SpotifyAPIClient.swift
Normal file
@@ -0,0 +1,77 @@
|
||||
//
|
||||
// SpotifyAPIClient.swift
|
||||
// iris
|
||||
//
|
||||
// Spotify Web API client for fetching currently playing track.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
@MainActor
|
||||
final class SpotifyAPIClient {
|
||||
private let authManager: SpotifyAuthManager
|
||||
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "iris", category: "SpotifyAPI")
|
||||
private let baseUrl = "https://api.spotify.com/v1"
|
||||
|
||||
init(authManager: SpotifyAuthManager) {
|
||||
self.authManager = authManager
|
||||
}
|
||||
|
||||
func getCurrentPlayback() async throws -> SpotifyPlaybackState? {
|
||||
try await getCurrentPlayback(isRetry: false)
|
||||
}
|
||||
|
||||
private func getCurrentPlayback(isRetry: Bool) async throws -> SpotifyPlaybackState? {
|
||||
let refreshed = await authManager.refreshTokenIfNeeded()
|
||||
guard refreshed else {
|
||||
throw SpotifyAPIError.notAuthenticated
|
||||
}
|
||||
|
||||
guard let token = authManager.accessToken else {
|
||||
throw SpotifyAPIError.notAuthenticated
|
||||
}
|
||||
|
||||
var request = URLRequest(url: URL(string: "\(baseUrl)/me/player/currently-playing")!)
|
||||
request.httpMethod = "GET"
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw SpotifyAPIError.invalidResponse
|
||||
}
|
||||
|
||||
switch httpResponse.statusCode {
|
||||
case 200:
|
||||
return try JSONDecoder().decode(SpotifyPlaybackState.self, from: data)
|
||||
case 204:
|
||||
return nil
|
||||
case 401:
|
||||
guard !isRetry else {
|
||||
throw SpotifyAPIError.tokenExpired
|
||||
}
|
||||
let refreshed = await authManager.refreshTokenIfNeeded()
|
||||
if refreshed {
|
||||
return try await getCurrentPlayback(isRetry: true)
|
||||
}
|
||||
throw SpotifyAPIError.tokenExpired
|
||||
case 429:
|
||||
let retryAfter = httpResponse.value(forHTTPHeaderField: "Retry-After")
|
||||
logger.warning("Rate limited, retry after: \(retryAfter ?? "unknown")")
|
||||
throw SpotifyAPIError.rateLimited
|
||||
default:
|
||||
logger.error("API error: \(httpResponse.statusCode)")
|
||||
throw SpotifyAPIError.apiError(httpResponse.statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
enum SpotifyAPIError: Error {
|
||||
case notAuthenticated
|
||||
case tokenExpired
|
||||
case invalidResponse
|
||||
case rateLimited
|
||||
case apiError(Int)
|
||||
}
|
||||
}
|
||||
263
IrisCompanion/iris/Spotify/SpotifyAuthManager.swift
Normal file
263
IrisCompanion/iris/Spotify/SpotifyAuthManager.swift
Normal file
@@ -0,0 +1,263 @@
|
||||
//
|
||||
// SpotifyAuthManager.swift
|
||||
// iris
|
||||
//
|
||||
// OAuth 2.0 PKCE authentication for Spotify Web API.
|
||||
//
|
||||
|
||||
import AuthenticationServices
|
||||
import CommonCrypto
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
enum SpotifyConfig {
|
||||
static let clientId = "YOUR_SPOTIFY_CLIENT_ID"
|
||||
static let redirectUri = "iris-spotify-auth://callback"
|
||||
static let scopes = "user-read-playback-state user-read-currently-playing"
|
||||
static let authUrl = "https://accounts.spotify.com/authorize"
|
||||
static let tokenUrl = "https://accounts.spotify.com/api/token"
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
@MainActor
|
||||
final class SpotifyAuthManager: NSObject, ObservableObject {
|
||||
@Published private(set) var isConnected: Bool = false
|
||||
@Published private(set) var isAuthenticating: Bool = false
|
||||
@Published private(set) var error: String? = nil
|
||||
|
||||
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "iris", category: "SpotifyAuth")
|
||||
private let tokensKey = "spotify_tokens"
|
||||
private var codeVerifier: String? = nil
|
||||
private var authSession: ASWebAuthenticationSession? = nil
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
loadTokens()
|
||||
}
|
||||
|
||||
var accessToken: String? {
|
||||
loadStoredTokens()?.accessToken
|
||||
}
|
||||
|
||||
func startAuth() {
|
||||
guard !isAuthenticating else { return }
|
||||
isAuthenticating = true
|
||||
error = nil
|
||||
|
||||
let verifier = generateCodeVerifier()
|
||||
codeVerifier = verifier
|
||||
let challenge = generateCodeChallenge(verifier: verifier)
|
||||
|
||||
var components = URLComponents(string: SpotifyConfig.authUrl)!
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "client_id", value: SpotifyConfig.clientId),
|
||||
URLQueryItem(name: "response_type", value: "code"),
|
||||
URLQueryItem(name: "redirect_uri", value: SpotifyConfig.redirectUri),
|
||||
URLQueryItem(name: "scope", value: SpotifyConfig.scopes),
|
||||
URLQueryItem(name: "code_challenge_method", value: "S256"),
|
||||
URLQueryItem(name: "code_challenge", value: challenge)
|
||||
]
|
||||
|
||||
guard let authUrl = components.url else {
|
||||
error = "Failed to build auth URL"
|
||||
isAuthenticating = false
|
||||
return
|
||||
}
|
||||
|
||||
let session = ASWebAuthenticationSession(
|
||||
url: authUrl,
|
||||
callbackURLScheme: "iris-spotify-auth"
|
||||
) { [weak self] callbackUrl, authError in
|
||||
Task { @MainActor in
|
||||
self?.handleAuthCallback(callbackUrl: callbackUrl, error: authError)
|
||||
}
|
||||
}
|
||||
session.presentationContextProvider = self
|
||||
session.prefersEphemeralWebBrowserSession = false
|
||||
authSession = session
|
||||
|
||||
if !session.start() {
|
||||
error = "Failed to start auth session"
|
||||
isAuthenticating = false
|
||||
}
|
||||
}
|
||||
|
||||
func handleCallback(url: URL) {
|
||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
let code = components.queryItems?.first(where: { $0.name == "code" })?.value else {
|
||||
error = "Invalid callback URL"
|
||||
isAuthenticating = false
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
await exchangeCodeForTokens(code: code)
|
||||
}
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
KeychainHelper.delete(key: tokensKey)
|
||||
isConnected = false
|
||||
logger.info("Disconnected from Spotify")
|
||||
}
|
||||
|
||||
func refreshTokenIfNeeded() async -> Bool {
|
||||
guard let tokens = loadStoredTokens() else { return false }
|
||||
|
||||
if !tokens.expiresWithinMinutes {
|
||||
return true
|
||||
}
|
||||
|
||||
return await refreshToken(refreshToken: tokens.refreshToken)
|
||||
}
|
||||
|
||||
private func handleAuthCallback(callbackUrl: URL?, error authError: Error?) {
|
||||
isAuthenticating = false
|
||||
authSession = nil
|
||||
|
||||
if let authError = authError as? ASWebAuthenticationSessionError,
|
||||
authError.code == .canceledLogin {
|
||||
logger.info("User cancelled Spotify login")
|
||||
return
|
||||
}
|
||||
|
||||
if let authError {
|
||||
error = authError.localizedDescription
|
||||
return
|
||||
}
|
||||
|
||||
guard let callbackUrl else {
|
||||
error = "No callback URL received"
|
||||
return
|
||||
}
|
||||
|
||||
handleCallback(url: callbackUrl)
|
||||
}
|
||||
|
||||
private func exchangeCodeForTokens(code: String) async {
|
||||
guard let verifier = codeVerifier else {
|
||||
error = "Missing code verifier"
|
||||
isAuthenticating = false
|
||||
return
|
||||
}
|
||||
|
||||
let body = [
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": SpotifyConfig.redirectUri,
|
||||
"client_id": SpotifyConfig.clientId,
|
||||
"code_verifier": verifier
|
||||
]
|
||||
|
||||
do {
|
||||
let tokenResponse = try await postTokenRequest(body: body)
|
||||
let tokens = SpotifyTokens(
|
||||
accessToken: tokenResponse.accessToken,
|
||||
refreshToken: tokenResponse.refreshToken ?? "",
|
||||
expiresAt: Date().addingTimeInterval(TimeInterval(tokenResponse.expiresIn))
|
||||
)
|
||||
try saveTokens(tokens)
|
||||
isConnected = true
|
||||
logger.info("Successfully authenticated with Spotify")
|
||||
} catch {
|
||||
self.error = "Token exchange failed: \(error.localizedDescription)"
|
||||
logger.error("Token exchange failed: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
codeVerifier = nil
|
||||
isAuthenticating = false
|
||||
}
|
||||
|
||||
private func refreshToken(refreshToken: String) async -> Bool {
|
||||
let body = [
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refreshToken,
|
||||
"client_id": SpotifyConfig.clientId
|
||||
]
|
||||
|
||||
do {
|
||||
let tokenResponse = try await postTokenRequest(body: body)
|
||||
let newRefreshToken = tokenResponse.refreshToken ?? refreshToken
|
||||
let tokens = SpotifyTokens(
|
||||
accessToken: tokenResponse.accessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
expiresAt: Date().addingTimeInterval(TimeInterval(tokenResponse.expiresIn))
|
||||
)
|
||||
try saveTokens(tokens)
|
||||
logger.info("Refreshed Spotify token")
|
||||
return true
|
||||
} catch {
|
||||
logger.error("Token refresh failed: \(error.localizedDescription)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func postTokenRequest(body: [String: String]) async throws -> SpotifyTokenResponse {
|
||||
var request = URLRequest(url: URL(string: SpotifyConfig.tokenUrl)!)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let bodyString = body.map { "\($0.key)=\($0.value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? $0.value)" }
|
||||
.joined(separator: "&")
|
||||
request.httpBody = bodyString.data(using: .utf8)
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 else {
|
||||
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
|
||||
throw SpotifyAuthError.tokenRequestFailed(statusCode)
|
||||
}
|
||||
|
||||
return try JSONDecoder().decode(SpotifyTokenResponse.self, from: data)
|
||||
}
|
||||
|
||||
private func loadTokens() {
|
||||
isConnected = loadStoredTokens() != nil
|
||||
}
|
||||
|
||||
private func loadStoredTokens() -> SpotifyTokens? {
|
||||
guard let data = KeychainHelper.load(key: tokensKey) else { return nil }
|
||||
return try? JSONDecoder().decode(SpotifyTokens.self, from: data)
|
||||
}
|
||||
|
||||
private func saveTokens(_ tokens: SpotifyTokens) throws {
|
||||
let data = try JSONEncoder().encode(tokens)
|
||||
try KeychainHelper.save(key: tokensKey, data: data)
|
||||
}
|
||||
|
||||
private func generateCodeVerifier() -> String {
|
||||
var bytes = [UInt8](repeating: 0, count: 32)
|
||||
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
||||
return Data(bytes).base64URLEncodedString()
|
||||
}
|
||||
|
||||
private func generateCodeChallenge(verifier: String) -> String {
|
||||
guard let data = verifier.data(using: .utf8) else { return "" }
|
||||
var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
|
||||
data.withUnsafeBytes { buffer in
|
||||
_ = CC_SHA256(buffer.baseAddress, CC_LONG(data.count), &hash)
|
||||
}
|
||||
return Data(hash).base64URLEncodedString()
|
||||
}
|
||||
|
||||
enum SpotifyAuthError: Error {
|
||||
case tokenRequestFailed(Int)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
extension SpotifyAuthManager: ASWebAuthenticationPresentationContextProviding {
|
||||
nonisolated func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
|
||||
ASPresentationAnchor()
|
||||
}
|
||||
}
|
||||
|
||||
extension Data {
|
||||
func base64URLEncodedString() -> String {
|
||||
base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
}
|
||||
91
IrisCompanion/iris/Spotify/SpotifyModels.swift
Normal file
91
IrisCompanion/iris/Spotify/SpotifyModels.swift
Normal file
@@ -0,0 +1,91 @@
|
||||
//
|
||||
// SpotifyModels.swift
|
||||
// iris
|
||||
//
|
||||
// Codable models for Spotify Web API responses.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct SpotifyTokens: Codable {
|
||||
let accessToken: String
|
||||
let refreshToken: String
|
||||
let expiresAt: Date
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case accessToken = "access_token"
|
||||
case refreshToken = "refresh_token"
|
||||
case expiresAt = "expires_at"
|
||||
}
|
||||
|
||||
var isExpired: Bool {
|
||||
Date() >= expiresAt
|
||||
}
|
||||
|
||||
var expiresWithinMinutes: Bool {
|
||||
Date().addingTimeInterval(5 * 60) >= expiresAt
|
||||
}
|
||||
}
|
||||
|
||||
struct SpotifyTokenResponse: Codable {
|
||||
let accessToken: String
|
||||
let tokenType: String
|
||||
let expiresIn: Int
|
||||
let refreshToken: String?
|
||||
let scope: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case accessToken = "access_token"
|
||||
case tokenType = "token_type"
|
||||
case expiresIn = "expires_in"
|
||||
case refreshToken = "refresh_token"
|
||||
case scope
|
||||
}
|
||||
}
|
||||
|
||||
struct SpotifyPlaybackState: Codable {
|
||||
let isPlaying: Bool
|
||||
let progressMs: Int?
|
||||
let item: SpotifyTrack?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case isPlaying = "is_playing"
|
||||
case progressMs = "progress_ms"
|
||||
case item
|
||||
}
|
||||
}
|
||||
|
||||
struct SpotifyTrack: Codable {
|
||||
let id: String
|
||||
let name: String
|
||||
let artists: [SpotifyArtist]
|
||||
let album: SpotifyAlbum
|
||||
let durationMs: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, artists, album
|
||||
case durationMs = "duration_ms"
|
||||
}
|
||||
}
|
||||
|
||||
struct SpotifyArtist: Codable {
|
||||
let id: String
|
||||
let name: String
|
||||
}
|
||||
|
||||
struct SpotifyAlbum: Codable {
|
||||
let id: String
|
||||
let name: String
|
||||
}
|
||||
|
||||
enum MusicSource: String, CaseIterable, Codable {
|
||||
case appleMusic = "apple_music"
|
||||
case spotify = "spotify"
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .appleMusic: return "Apple Music"
|
||||
case .spotify: return "Spotify"
|
||||
}
|
||||
}
|
||||
}
|
||||
145
IrisCompanion/iris/Spotify/SpotifyNowPlayingMonitor.swift
Normal file
145
IrisCompanion/iris/Spotify/SpotifyNowPlayingMonitor.swift
Normal file
@@ -0,0 +1,145 @@
|
||||
//
|
||||
// SpotifyNowPlayingMonitor.swift
|
||||
// iris
|
||||
//
|
||||
// Polling monitor for Spotify currently playing track.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
struct SpotifyNowPlaying: Equatable, Sendable {
|
||||
let itemId: String
|
||||
let title: String
|
||||
let artist: String?
|
||||
let album: String?
|
||||
let isPlaying: Bool
|
||||
|
||||
func asFeedItem(baseGeneratedAt: Int, now: Int) -> FeedItem {
|
||||
let desiredLifetimeSec = 30
|
||||
let ttl = max(1, (now - baseGeneratedAt) + desiredLifetimeSec)
|
||||
|
||||
let subtitleParts = [artist, album]
|
||||
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
let subtitle = subtitleParts.isEmpty ? "Spotify" : subtitleParts.joined(separator: " • ")
|
||||
|
||||
return FeedItem(
|
||||
id: "spotify:now:\(itemId)",
|
||||
type: .nowPlaying,
|
||||
title: title.truncated(maxLength: TextConstraints.titleMax),
|
||||
subtitle: subtitle.truncated(maxLength: TextConstraints.subtitleMax),
|
||||
priority: isPlaying ? 0.35 : 0.2,
|
||||
ttlSec: ttl,
|
||||
condition: nil,
|
||||
startsAt: nil,
|
||||
bucket: .fyi,
|
||||
actions: ["DISMISS"]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
@MainActor
|
||||
final class SpotifyNowPlayingMonitor {
|
||||
struct Update: Sendable {
|
||||
let isConnected: Bool
|
||||
let snapshot: SpotifyNowPlaying?
|
||||
}
|
||||
|
||||
var onUpdate: ((Update) -> Void)? = nil
|
||||
|
||||
private let authManager: SpotifyAuthManager
|
||||
private let apiClient: SpotifyAPIClient
|
||||
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "iris", category: "SpotifyNowPlaying")
|
||||
|
||||
private var pollTimer: DispatchSourceTimer?
|
||||
private var isRunning = false
|
||||
private var lastSnapshot: SpotifyNowPlaying? = nil
|
||||
|
||||
init(authManager: SpotifyAuthManager) {
|
||||
self.authManager = authManager
|
||||
self.apiClient = SpotifyAPIClient(authManager: authManager)
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard !isRunning else { return }
|
||||
isRunning = true
|
||||
logger.info("Spotify monitor started")
|
||||
startPolling()
|
||||
|
||||
Task { @MainActor in
|
||||
await refresh(reason: "start")
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard isRunning else { return }
|
||||
isRunning = false
|
||||
logger.info("Spotify monitor stopped")
|
||||
|
||||
pollTimer?.cancel()
|
||||
pollTimer = nil
|
||||
}
|
||||
|
||||
private func startPolling() {
|
||||
guard pollTimer == nil else { return }
|
||||
let timer = DispatchSource.makeTimerSource(queue: .main)
|
||||
timer.schedule(deadline: .now() + 4, repeating: 4)
|
||||
timer.setEventHandler { [weak self] in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
await self.refresh(reason: "poll")
|
||||
}
|
||||
}
|
||||
timer.resume()
|
||||
pollTimer = timer
|
||||
}
|
||||
|
||||
private func refresh(reason: String) async {
|
||||
guard authManager.isConnected else {
|
||||
if lastSnapshot != nil {
|
||||
lastSnapshot = nil
|
||||
onUpdate?(Update(isConnected: false, snapshot: nil))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let playback = try await apiClient.getCurrentPlayback()
|
||||
|
||||
guard let track = playback?.item, playback?.isPlaying == true else {
|
||||
if lastSnapshot != nil {
|
||||
lastSnapshot = nil
|
||||
onUpdate?(Update(isConnected: true, snapshot: nil))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let artistName = track.artists.first?.name
|
||||
let snapshot = SpotifyNowPlaying(
|
||||
itemId: track.id,
|
||||
title: track.name,
|
||||
artist: artistName,
|
||||
album: track.album.name,
|
||||
isPlaying: playback?.isPlaying ?? false
|
||||
)
|
||||
|
||||
guard snapshot != lastSnapshot else { return }
|
||||
lastSnapshot = snapshot
|
||||
onUpdate?(Update(isConnected: true, snapshot: snapshot))
|
||||
logger.debug("Spotify now playing: \(snapshot.title)")
|
||||
|
||||
} catch SpotifyAPIClient.SpotifyAPIError.notAuthenticated {
|
||||
if lastSnapshot != nil {
|
||||
lastSnapshot = nil
|
||||
onUpdate?(Update(isConnected: false, snapshot: nil))
|
||||
}
|
||||
} catch SpotifyAPIClient.SpotifyAPIError.rateLimited {
|
||||
logger.warning("Spotify rate limited, skipping this poll")
|
||||
} catch {
|
||||
logger.error("Spotify fetch error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user