// // 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)") } } }