// // ContextOrchestrator.swift // iris // // Created by Codex. // import CoreLocation import Foundation import MediaPlayer import MusicKit import os @available(iOS 16.0, *) @MainActor final class ContextOrchestrator: NSObject, ObservableObject { @Published private(set) var authorization: CLAuthorizationStatus = .notDetermined @Published private(set) var lastLocation: CLLocation? = nil @Published private(set) var lastRecomputeAt: Date? = nil @Published private(set) var lastRecomputeReason: String? = nil @Published private(set) var lastFeed: FeedEnvelope? = nil @Published private(set) var lastError: String? = nil @Published private(set) var lastWeatherDiagnostics: [String: String] = [:] @Published private(set) var lastCalendarDiagnostics: [String: String] = [:] @Published private(set) var lastPipelineElapsedMs: Int? = nil @Published private(set) var lastFetchFailed: Bool = false @Published private(set) var musicAuthorization: MusicAuthorization.Status = .notDetermined @Published private(set) var nowPlaying: NowPlayingSnapshot? = nil private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "iris", category: "ContextOrchestrator") private let locationManager = CLLocationManager() private let weatherDataSource = WeatherDataSource() private let calendarDataSource = CalendarDataSource() private let poiDataSource = POIDataSource() private let ranker: HeuristicRanker private let store: FeedStore private let server: LocalServer private let ble: BlePeripheralManager private let nowPlayingMonitor = NowPlayingMonitor() private var lastRecomputeLocation: CLLocation? = nil private var lastRecomputeAccuracy: CLLocationAccuracy? = nil private var recomputeInFlight = false private var lastRecomputeAttemptAt: Date? = nil init(store: FeedStore = FeedStore(), server: LocalServer = LocalServer(), ble: BlePeripheralManager) { self.store = store self.server = server self.ble = ble self.ranker = HeuristicRanker(lastShownAt: { id in store.lastShownAt(feedItemId: id) }) super.init() locationManager.delegate = self locationManager.desiredAccuracy = kCLLocationAccuracyBest locationManager.distanceFilter = kCLDistanceFilterNone locationManager.pausesLocationUpdatesAutomatically = false locationManager.allowsBackgroundLocationUpdates = true ble.onFirstSubscribe = { [weak self] in Task { @MainActor in self?.logger.info("BLE subscribed: pushing latest feed") self?.pushLatestFeedToBle() } } ble.onControlCommand = { [weak self] command in Task { @MainActor in self?.handleBleControl(command) } } nowPlayingMonitor.onUpdate = { [weak self] update in Task { @MainActor in guard let self else { return } self.musicAuthorization = update.authorization self.nowPlaying = update.snapshot self.pushLatestFeedToBle() } } let feed = store.getFeed() lastFeed = feed } func start() { authorization = locationManager.authorizationStatus logger.info("start auth=\(String(describing: self.authorization), privacy: .public)") server.start() nowPlayingMonitor.start() requestPermissionsIfNeeded() locationManager.startUpdatingLocation() } func stop() { locationManager.stopUpdatingLocation() nowPlayingMonitor.stop() } func recomputeNow(reason: String = "manual") { guard let location = lastLocation ?? locationManager.location else { logger.info("recomputeNow skipped: no location") return } maybeRecompute(for: location, reason: reason, force: true) } func sendFixtureFeedNow() { guard let url = Bundle.main.url(forResource: "full_feed_fixture", withExtension: "json", subdirectory: "ProtocolFixtures") ?? Bundle.main.url(forResource: "full_feed_fixture", withExtension: "json"), let data = try? Data(contentsOf: url) else { logger.error("fixture feed missing in bundle") return } ble.sendOpaque(data.trimmedTrailingWhitespace(), msgType: 1) logger.info("sent fixture feed bytes=\(data.count)") } private func requestPermissionsIfNeeded() { switch locationManager.authorizationStatus { case .notDetermined: logger.info("requestWhenInUseAuthorization") locationManager.requestWhenInUseAuthorization() case .authorizedWhenInUse: logger.info("requestAlwaysAuthorization") locationManager.requestAlwaysAuthorization() case .authorizedAlways: break case .restricted, .denied: lastError = "Location permission denied." @unknown default: break } } private func maybeRecompute(for location: CLLocation, reason: String, force: Bool) { let now = Date() let hardThrottleSec: TimeInterval = 60 if !force, let lastAttempt = lastRecomputeAttemptAt, now.timeIntervalSince(lastAttempt) < hardThrottleSec { logger.info("skip recompute (throttle) reason=\(reason, privacy: .public)") return } lastRecomputeAttemptAt = now if recomputeInFlight { logger.info("skip recompute (in-flight) reason=\(reason, privacy: .public)") return } recomputeInFlight = true lastRecomputeReason = reason logger.info("recompute start reason=\(reason, privacy: .public) lat=\(location.coordinate.latitude, format: .fixed(precision: 5)) lon=\(location.coordinate.longitude, format: .fixed(precision: 5)) acc=\(location.horizontalAccuracy, format: .fixed(precision: 1))") Task { await self.recomputePipeline(location: location, reason: reason) } } private func shouldTriggerRecompute(for location: CLLocation) -> (Bool, String) { if lastRecomputeAt == nil { return (true, "initial") } let now = Date() let speed = max(0, location.speed) let moving = speed >= 1.0 let maxInterval: TimeInterval = moving ? 5 * 60 : 15 * 60 if let last = lastRecomputeAt, now.timeIntervalSince(last) > maxInterval { return (true, moving ? "timer_5m_moving" : "timer_15m") } if let lastLoc = lastRecomputeLocation { let dist = location.distance(from: lastLoc) let distanceThreshold: CLLocationDistance = moving ? 100 : 250 if dist > distanceThreshold { return (true, moving ? "moved_100m_moving" : "moved_250m") } } if let lastAcc = lastRecomputeAccuracy, location.horizontalAccuracy > 0, lastAcc > 0 { let improvementThreshold: CLLocationAccuracy = moving ? 30 : 50 if lastAcc - location.horizontalAccuracy > improvementThreshold { return (true, moving ? "accuracy_improved_30m" : "accuracy_improved_50m") } } return (false, "no_trigger") } private func recomputePipeline(location: CLLocation, reason: String) async { defer { Task { @MainActor in self.recomputeInFlight = false } } let nowEpoch = Int(Date().timeIntervalSince1970) let userContext = UserContext(isMoving: location.speed >= 1.0, city: "London") let start = Date() async let weatherResult = withTimeoutResult(seconds: 6) { try await self.weatherDataSource.dataWithDiagnostics(for: location, now: nowEpoch) } async let calendarResult = withTimeoutResult(seconds: 6) { try await self.calendarDataSource.dataWithDiagnostics(now: nowEpoch) } async let poiResult = withTimeoutResult(seconds: 6) { try await self.poiDataSource.data(for: location, now: nowEpoch) } let wxRes = await weatherResult let calRes = await calendarResult let poiRes = await poiResult func calendarTTL(endAt: Int, now: Int) -> Int { let ttl = endAt - now return min(max(ttl, 60), 2 * 60 * 60) } func rainTitle(startAt: Int, now: Int) -> String { let minutes = max(0, Int(((TimeInterval(startAt - now)) / 60.0).rounded())) if minutes <= 0 { return "Rain now" } return "Rain in ~\(minutes) min" } var rightNowCandidates: [HeuristicRanker.Ranked] = [] var calendarItems: [FeedItem] = [] var poiItems: [FeedItem] = [] var weatherNowItem: FeedItem? = nil var fetchFailed = false var wxDiagnostics: [String: String] = [:] var calDiagnostics: [String: String] = [:] switch wxRes { case .success(let snapshot): wxDiagnostics = snapshot.diagnostics let weather = snapshot.data if let current = weather.current { weatherNowItem = FeedItem( id: "wx:now:\(nowEpoch / 60)", type: .currentWeather, title: "Now \(current.temperatureC)°C \(current.condition.description)".truncated(maxLength: TextConstraints.titleMax), subtitle: "Feels \(current.feelsLikeC)°C".truncated(maxLength: TextConstraints.subtitleMax), priority: 0.8, ttlSec: 1800, condition: current.condition, startsAt: nil, bucket: .fyi, actions: ["DISMISS"] ) } if let rainSoon = weather.rainSoon { let title = rainTitle(startAt: rainSoon.startAt, now: nowEpoch).truncated(maxLength: TextConstraints.titleMax) let subtitle = (rainSoon.source == .minutely ? "Carry an umbrella" : "Rain likely soon") .truncated(maxLength: TextConstraints.subtitleMax) let confidence: Double = (rainSoon.source == .minutely) ? 0.9 : 0.6 let item = FeedItem( id: "wx:rain:\(rainSoon.startAt)", type: .weatherAlert, title: title, subtitle: subtitle, priority: confidence, ttlSec: max(1, rainSoon.ttlSec), condition: nil, startsAt: nil, bucket: .rightNow, actions: ["DISMISS"] ) rightNowCandidates.append(.init(item: item, confidence: confidence, isEligibleForRightNow: true)) } if let wind = weather.windAlert { let mph = Int((wind.gustMps * 2.236936).rounded()) let title = "Wind gusts ~\(mph) mph".truncated(maxLength: TextConstraints.titleMax) let subtitle = "Use caution outside".truncated(maxLength: TextConstraints.subtitleMax) let confidence: Double = 0.8 let item = FeedItem( id: "wx:wind:\(nowEpoch):\(Int(wind.thresholdMps * 10))", type: .weatherAlert, title: title, subtitle: subtitle, priority: confidence, ttlSec: max(1, wind.ttlSec), condition: nil, startsAt: nil, bucket: .rightNow, actions: ["DISMISS"] ) rightNowCandidates.append(.init(item: item, confidence: confidence, isEligibleForRightNow: true)) } for warning in weather.warnings { let confidence = min(max(warning.confidence, 0.0), 1.0) let item = FeedItem( id: warning.id, type: .weatherWarning, title: warning.title.truncated(maxLength: TextConstraints.titleMax), subtitle: warning.subtitle.truncated(maxLength: TextConstraints.subtitleMax), priority: confidence, ttlSec: max(1, warning.ttlSec), condition: nil, startsAt: nil, bucket: .rightNow, actions: ["DISMISS"] ) rightNowCandidates.append(.init(item: item, confidence: confidence, isEligibleForRightNow: true)) } if let wxErr = wxDiagnostics["weatherkit_error"] { fetchFailed = true logger.warning("weather fetch error: \(wxErr, privacy: .public)") } case .failure(let error): fetchFailed = true if let weatherError = error as? WeatherDataSource.WeatherError, case .weatherKitFailed(_, let diagnostics) = weatherError { wxDiagnostics = diagnostics logger.warning("weather fetch error: \(weatherError.localizedDescription, privacy: .public)") } else { logger.error("weather fetch failed: \(String(describing: error), privacy: .public)") } } switch poiRes { case .success(let pois): if pois.isEmpty { logger.info("no points of interests found") } for poi in pois.prefix(2) { let subtitle = poiSubtitle(for: poi) let confidence = min(max(poi.confidence, 0.0), 1.0) let item = FeedItem( id: poi.id, type: .poiNearby, title: poi.name.truncated(maxLength: TextConstraints.titleMax), subtitle: subtitle.truncated(maxLength: TextConstraints.subtitleMax), priority: confidence, ttlSec: max(1, poi.ttlSec), condition: nil, startsAt: nil, poiType: poi.poiType, bucket: .fyi, actions: ["DISMISS"] ) poiItems.append(item) rightNowCandidates.append(.init(item: item, confidence: confidence, isEligibleForRightNow: true)) } case .failure(let error): fetchFailed = true logger.error("poi fetch failed: \(String(describing: error), privacy: .public)") } switch calRes { case .success(let snapshot): calDiagnostics = snapshot.diagnostics for event in snapshot.data.events { let isOngoing = event.startAt <= nowEpoch && event.endAt > nowEpoch let startsInSec = event.startAt - nowEpoch let eligibleForRightNow = isOngoing || startsInSec <= 30 * 60 let confidence: Double = isOngoing ? 0.9 : 0.7 let item = FeedItem( id: event.id, type: .calendarEvent, title: event.title.truncated(maxLength: TextConstraints.titleMax), subtitle: "", priority: confidence, ttlSec: calendarTTL(endAt: event.endAt, now: nowEpoch), condition: nil, startsAt: event.startAt, bucket: .fyi, actions: ["DISMISS"] ) calendarItems.append(item) rightNowCandidates.append(.init(item: item, confidence: confidence, isEligibleForRightNow: eligibleForRightNow)) } case .failure(let error): fetchFailed = true if let calendarError = error as? CalendarDataSource.CalendarError { calDiagnostics = calendarError.diagnostics logger.warning("calendar error: \(calendarError.localizedDescription, privacy: .public)") } else { logger.error("calendar fetch failed: \(String(describing: error), privacy: .public)") } } let elapsedMs = Int(Date().timeIntervalSince(start) * 1000) lastPipelineElapsedMs = elapsedMs lastFetchFailed = fetchFailed lastWeatherDiagnostics = wxDiagnostics lastCalendarDiagnostics = calDiagnostics logger.info("pipeline right_now_candidates=\(rightNowCandidates.count) calendar_items=\(calendarItems.count) poi_items=\(poiItems.count) fetchFailed=\(fetchFailed) elapsed_ms=\(elapsedMs)") if fetchFailed, rightNowCandidates.isEmpty, calendarItems.isEmpty, weatherNowItem == nil { let fallbackFeed = store.getFeed(now: nowEpoch) lastFeed = fallbackFeed lastError = "Fetch failed; using previous feed." server.broadcastFeed(fallbackFeed) ble.sendOpaque((try? JSONEncoder().encode(feedForGlass(base: fallbackFeed, now: nowEpoch))) ?? Data(), msgType: 1) return } let eligibleUnsuppressed = rightNowCandidates.filter { ranked in !store.isSuppressed(id: ranked.item.id, type: ranked.item.type, now: nowEpoch) } let winnerSelection = ranker.pickWinner(from: eligibleUnsuppressed, now: nowEpoch, context: userContext) let winnerItem = winnerSelection?.item ?? FeedEnvelope.allQuiet(now: nowEpoch).feed[0] let fyiCalendar = calendarItems .filter { $0.id != winnerItem.id } .filter { !store.isSuppressed(id: $0.id, type: $0.type, now: nowEpoch) } .sorted(by: { ($0.startsAt ?? Int.max) < ($1.startsAt ?? Int.max) }) .prefix(3) var fyi: [FeedItem] = [] fyi.append(contentsOf: fyiCalendar.map { item in FeedItem( id: item.id, type: item.type, title: item.title.truncated(maxLength: TextConstraints.titleMax), subtitle: item.subtitle.truncated(maxLength: TextConstraints.subtitleMax), priority: min(max(item.priority, 0.0), 1.0), ttlSec: max(1, item.ttlSec), condition: item.condition, startsAt: item.startsAt, poiType: item.poiType, bucket: .fyi, actions: ["DISMISS"] ) }) let fyiPOI = poiItems .filter { $0.id != winnerItem.id } .filter { !store.isSuppressed(id: $0.id, type: $0.type, now: nowEpoch) } .prefix(2) fyi.append(contentsOf: fyiPOI.map { item in FeedItem( id: item.id, type: item.type, title: item.title.truncated(maxLength: TextConstraints.titleMax), subtitle: item.subtitle.truncated(maxLength: TextConstraints.subtitleMax), priority: min(max(item.priority, 0.0), 1.0), ttlSec: max(1, item.ttlSec), condition: item.condition, startsAt: item.startsAt, poiType: item.poiType, bucket: .fyi, actions: ["DISMISS"] ) }) if let weatherNowItem, weatherNowItem.id != winnerItem.id, !store.isSuppressed(id: weatherNowItem.id, type: weatherNowItem.type, now: nowEpoch) { fyi.append(weatherNowItem) } let items = [winnerItem] + fyi let feedEnvelope = FeedEnvelope( schema: 1, generatedAt: nowEpoch, feed: items, meta: FeedMeta(winnerId: winnerItem.id, unreadCount: items.count) ) store.setFeed(feedEnvelope, now: nowEpoch) lastFeed = feedEnvelope lastRecomputeAt = Date() lastRecomputeLocation = location lastRecomputeAccuracy = location.horizontalAccuracy lastError = fetchFailed ? "Partial fetch failure." : nil logger.info("winner id=\(winnerItem.id, privacy: .public) type=\(winnerItem.type.rawValue, privacy: .public) prio=\(winnerItem.priority, format: .fixed(precision: 2)) ttl=\(winnerItem.ttlSec)") server.broadcastFeed(feedEnvelope) ble.sendOpaque((try? JSONEncoder().encode(feedForGlass(base: feedEnvelope, now: nowEpoch))) ?? Data(), msgType: 1) } private func pushLatestFeedToBle() { let nowEpoch = Int(Date().timeIntervalSince1970) let feed = store.getFeed(now: nowEpoch) ble.sendOpaque((try? JSONEncoder().encode(feedForGlass(base: feed, now: nowEpoch))) ?? Data(), msgType: 1) } private func handleBleControl(_ command: String) { guard !command.isEmpty else { return } if command == "REQ_FULL" { logger.info("BLE control REQ_FULL") pushLatestFeedToBle() return } if command.hasPrefix("ACK:") { logger.info("BLE control \(command, privacy: .public)") return } logger.info("BLE control unknown=\(command, privacy: .public)") } private func feedForGlass(base: FeedEnvelope, now: Int) -> FeedEnvelope { guard let nowPlayingCard = nowPlaying?.asFeedItem(baseGeneratedAt: base.generatedAt, now: now) else { return base } var cards = base.feed.filter { $0.type != .nowPlaying } // Append after existing FYI cards (e.g. weather). cards.append(nowPlayingCard) return FeedEnvelope( schema: base.schema, generatedAt: base.generatedAt, feed: cards, meta: FeedMeta(winnerId: base.meta.winnerId, unreadCount: cards.count) ) } private func poiSubtitle(for poi: POIDataSource.POI) -> String { let distance = distanceText(meters: poi.distanceMeters) let walk = "\(poi.walkingMinutes) min walk" let parts = [poi.category, distance, walk] .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } return parts.joined(separator: " • ") } private func distanceText(meters: CLLocationDistance) -> String { if meters >= 1000 { return String(format: "%.1f km", meters / 1000.0) } return "\(Int(meters.rounded())) m" } } extension ContextOrchestrator: CLLocationManagerDelegate { func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { authorization = manager.authorizationStatus logger.info("auth changed=\(String(describing: self.authorization), privacy: .public)") requestPermissionsIfNeeded() } func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { logger.error("location error: \(String(describing: error), privacy: .public)") lastError = "Location error: \(error)" } func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { guard let best = locations.sorted(by: { $0.horizontalAccuracy < $1.horizontalAccuracy }).first else { return } lastLocation = best let (should, reason) = shouldTriggerRecompute(for: best) if should { maybeRecompute(for: best, reason: reason, force: false) } } } enum TimeoutError: Error { case timedOut } func withTimeoutResult(seconds: Double, operation: @escaping () async throws -> T) async -> Result { do { let value = try await withThrowingTaskGroup(of: T.self) { group in group.addTask { try await operation() } group.addTask { try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) throw TimeoutError.timedOut } let result = try await group.next()! group.cancelAll() return result } return .success(value) } catch { return .failure(error) } } @available(iOS 16.0, *) struct NowPlayingSnapshot: Equatable, Sendable { let itemId: String let title: String let artist: String? let album: String? let playbackStatus: MusicKit.MusicPlayer.PlaybackStatus 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 ? "Apple Music" : subtitleParts.joined(separator: " • ") return FeedItem( id: "music:now:\(itemId)", type: .nowPlaying, title: title.truncated(maxLength: TextConstraints.titleMax), subtitle: subtitle.truncated(maxLength: TextConstraints.subtitleMax), priority: playbackStatus == .playing ? 0.35 : 0.2, ttlSec: ttl, condition: nil, startsAt: nil, bucket: .fyi, actions: ["DISMISS"] ) } } @available(iOS 16.0, *) @MainActor final class NowPlayingMonitor { struct Update: Sendable { let authorization: MusicAuthorization.Status let snapshot: NowPlayingSnapshot? } var onUpdate: ((Update) -> Void)? = nil private let player = SystemMusicPlayer.shared private let mpController = MPMusicPlayerController.systemMusicPlayer private var observers: [NSObjectProtocol] = [] private var pollTimer: DispatchSourceTimer? private var isRunning = false private var authorization: MusicAuthorization.Status = .notDetermined private var lastSnapshot: NowPlayingSnapshot? = nil func start() { guard !isRunning else { return } isRunning = true mpController.beginGeneratingPlaybackNotifications() observers.append( NotificationCenter.default.addObserver( forName: .MPMusicPlayerControllerNowPlayingItemDidChange, object: mpController, queue: .main ) { [weak self] _ in Task { @MainActor in self?.refresh(reason: "mp_now_playing_changed") } } ) observers.append( NotificationCenter.default.addObserver( forName: .MPMusicPlayerControllerPlaybackStateDidChange, object: mpController, queue: .main ) { [weak self] _ in Task { @MainActor in self?.refresh(reason: "mp_playback_state_changed") } } ) startPolling() Task { @MainActor in await ensureAuthorization() refresh(reason: "start") } } func stop() { guard isRunning else { return } isRunning = false pollTimer?.cancel() pollTimer = nil for token in observers { NotificationCenter.default.removeObserver(token) } observers.removeAll() mpController.endGeneratingPlaybackNotifications() } private func startPolling() { guard pollTimer == nil else { return } let timer = DispatchSource.makeTimerSource(queue: .main) timer.schedule(deadline: .now() + 2, repeating: 2) timer.setEventHandler { [weak self] in guard let self else { return } self.refresh(reason: "poll") } timer.resume() pollTimer = timer } private func ensureAuthorization() async { if authorization == .notDetermined { authorization = await MusicAuthorization.request() onUpdate?(Update(authorization: authorization, snapshot: lastSnapshot)) } } private func refresh(reason: String) { guard authorization == .authorized else { if lastSnapshot != nil { lastSnapshot = nil onUpdate?(Update(authorization: authorization, snapshot: nil)) } return } let playback = player.state.playbackStatus guard playback != .stopped else { if lastSnapshot != nil { lastSnapshot = nil onUpdate?(Update(authorization: authorization, snapshot: nil)) } return } let mpItem = mpController.nowPlayingItem let musicKitItem = player.queue.currentEntry?.item let itemId = sanitizeId( musicKitItem.map { String(describing: $0.id) } ?? mpItem.map { "mp:\($0.persistentID)" } ?? UUID().uuidString ) let musicKitTitle = musicKitItem.map(nowPlayingTitle(from:)) let title = normalizeTitle(musicKitTitle) ?? normalizeTitle(mpItem?.title) ?? "Now Playing" let artist = normalizePart(musicKitItem.flatMap(nowPlayingArtist(from:))) ?? normalizePart(mpItem?.artist) let album = normalizePart(musicKitItem.flatMap(nowPlayingAlbum(from:))) ?? normalizePart(mpItem?.albumTitle) let snapshot = NowPlayingSnapshot(itemId: itemId, title: title, artist: artist, album: album, playbackStatus: playback) guard snapshot != lastSnapshot else { return } lastSnapshot = snapshot onUpdate?(Update(authorization: authorization, snapshot: snapshot)) } private func nowPlayingTitle(from item: MusicItem) -> String { if let song = item as? Song { return song.title } if let album = item as? Album { return album.title } if let playlist = item as? Playlist { return playlist.name } return "Now Playing" } private func nowPlayingArtist(from item: MusicItem) -> String? { if let song = item as? Song { return song.artistName } if let album = item as? Album { return album.artistName } return nil } private func nowPlayingAlbum(from item: MusicItem) -> String? { if let song = item as? Song { return song.albumTitle } return nil } private func sanitizeId(_ raw: String) -> String { raw .replacingOccurrences(of: " ", with: "_") .replacingOccurrences(of: "\n", with: "_") .replacingOccurrences(of: "\t", with: "_") } private func normalizePart(_ raw: String?) -> String? { guard let raw else { return nil } let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } private func normalizeTitle(_ raw: String?) -> String? { guard let value = normalizePart(raw) else { return nil } if value == "Now Playing" { return nil } return value } }