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:
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user