initial commit
This commit is contained in:
563
IrisCompanion/iris/Orchestrator/ContextOrchestrator.swift
Normal file
563
IrisCompanion/iris/Orchestrator/ContextOrchestrator.swift
Normal file
@@ -0,0 +1,563 @@
|
||||
//
|
||||
// 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 lastWinner: WinnerEnvelope? = nil
|
||||
@Published private(set) var lastError: String? = nil
|
||||
@Published private(set) var lastCandidates: [Candidate] = []
|
||||
@Published private(set) var lastWeatherDiagnostics: [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(candidateId: 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 winner")
|
||||
self?.pushLatestWinnerToBle()
|
||||
}
|
||||
}
|
||||
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.pushLatestWinnerToBle()
|
||||
}
|
||||
}
|
||||
|
||||
let feed = store.getFeed()
|
||||
lastWinner = feed.asWinnerEnvelope()
|
||||
}
|
||||
|
||||
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()
|
||||
if let last = lastRecomputeAt, now.timeIntervalSince(last) > 15 * 60 {
|
||||
return (true, "timer_15m")
|
||||
}
|
||||
if let lastLoc = lastRecomputeLocation {
|
||||
let dist = location.distance(from: lastLoc)
|
||||
if dist > 250 {
|
||||
return (true, "moved_250m")
|
||||
}
|
||||
}
|
||||
if let lastAcc = lastRecomputeAccuracy, location.horizontalAccuracy > 0, lastAcc > 0 {
|
||||
if lastAcc - location.horizontalAccuracy > 50 {
|
||||
return (true, "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) {
|
||||
await self.weatherDataSource.candidatesWithDiagnostics(for: location, now: nowEpoch)
|
||||
}
|
||||
async let calendarResult = withTimeoutResult(seconds: 6) {
|
||||
await self.calendarDataSource.candidatesWithDiagnostics(now: nowEpoch)
|
||||
}
|
||||
async let poiResult = withTimeoutResult(seconds: 6) {
|
||||
try await self.poiDataSource.candidates(for: location, now: nowEpoch)
|
||||
}
|
||||
|
||||
let wxRes = await weatherResult
|
||||
let calRes = await calendarResult
|
||||
let poiRes = await poiResult
|
||||
|
||||
var candidates: [Candidate] = []
|
||||
var fetchFailed = false
|
||||
var wxDiagnostics: [String: String] = [:]
|
||||
var weatherNowCandidate: Candidate? = nil
|
||||
|
||||
switch wxRes {
|
||||
case .success(let wx):
|
||||
candidates.append(contentsOf: wx.candidates)
|
||||
wxDiagnostics = wx.diagnostics
|
||||
weatherNowCandidate = wx.candidates.first(where: { $0.type == .currentWeather }) ?? wx.candidates.first(where: { $0.id.hasPrefix("wx:now:") })
|
||||
if let wxErr = wx.weatherKitError {
|
||||
fetchFailed = true
|
||||
logger.warning("weather fetch error: \(wxErr, privacy: .public)")
|
||||
}
|
||||
case .failure(let error):
|
||||
fetchFailed = true
|
||||
logger.error("weather fetch failed: \(String(describing: error), privacy: .public)")
|
||||
}
|
||||
|
||||
switch poiRes {
|
||||
case .success(let pois):
|
||||
candidates.append(contentsOf: pois)
|
||||
case .failure(let error):
|
||||
fetchFailed = true
|
||||
logger.error("poi fetch failed: \(String(describing: error), privacy: .public)")
|
||||
}
|
||||
|
||||
switch calRes {
|
||||
case .success(let cal):
|
||||
candidates.append(contentsOf: cal.candidates)
|
||||
if let err = cal.error {
|
||||
fetchFailed = true
|
||||
logger.warning("calendar error: \(err, privacy: .public)")
|
||||
}
|
||||
case .failure(let error):
|
||||
fetchFailed = true
|
||||
logger.error("calendar fetch failed: \(String(describing: error), privacy: .public)")
|
||||
}
|
||||
|
||||
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
|
||||
lastPipelineElapsedMs = elapsedMs
|
||||
lastFetchFailed = fetchFailed
|
||||
lastCandidates = candidates
|
||||
lastWeatherDiagnostics = wxDiagnostics
|
||||
|
||||
logger.info("pipeline candidates total=\(candidates.count) fetchFailed=\(fetchFailed) elapsed_ms=\(elapsedMs)")
|
||||
|
||||
if fetchFailed, candidates.isEmpty {
|
||||
let fallbackFeed = store.getFeed(now: nowEpoch)
|
||||
let fallbackWinner = fallbackFeed.asWinnerEnvelope()
|
||||
lastWinner = fallbackWinner
|
||||
lastError = "Fetch failed; using previous winner."
|
||||
server.broadcastWinner(fallbackWinner)
|
||||
ble.sendOpaque((try? JSONEncoder().encode(feedForGlass(base: fallbackFeed, now: nowEpoch))) ?? Data(), msgType: 1)
|
||||
return
|
||||
}
|
||||
|
||||
let unsuppressed = candidates
|
||||
.filter { $0.type != .currentWeather }
|
||||
.filter { !store.isSuppressed(id: $0.id, type: $0.type, now: nowEpoch) }
|
||||
let winner = ranker.pickWinner(from: unsuppressed, now: nowEpoch, context: userContext)
|
||||
let envelope = WinnerEnvelope(schema: 1, generatedAt: nowEpoch, winner: winner, debug: nil)
|
||||
let validated = (try? validateEnvelope(envelope)) ?? envelope
|
||||
|
||||
let feedEnvelope = FeedEnvelope.fromWinnerAndWeather(now: nowEpoch, winner: validated, weather: weatherNowCandidate)
|
||||
store.setFeed(feedEnvelope, now: nowEpoch)
|
||||
lastWinner = validated
|
||||
lastRecomputeAt = Date()
|
||||
lastRecomputeLocation = location
|
||||
lastRecomputeAccuracy = location.horizontalAccuracy
|
||||
lastError = fetchFailed ? "Partial fetch failure." : nil
|
||||
|
||||
logger.info("winner id=\(validated.winner.id, privacy: .public) type=\(validated.winner.type.rawValue, privacy: .public) prio=\(validated.winner.priority, format: .fixed(precision: 2)) ttl=\(validated.winner.ttlSec)")
|
||||
|
||||
server.broadcastWinner(validated)
|
||||
ble.sendOpaque((try? JSONEncoder().encode(feedForGlass(base: feedEnvelope, now: nowEpoch))) ?? Data(), msgType: 1)
|
||||
}
|
||||
|
||||
private func pushLatestWinnerToBle() {
|
||||
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")
|
||||
pushLatestWinnerToBle()
|
||||
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?.asFeedCard(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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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<T>(seconds: Double, operation: @escaping () async throws -> T) async -> Result<T, Error> {
|
||||
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 asFeedCard(baseGeneratedAt: Int, now: Int) -> FeedCard {
|
||||
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 FeedCard(
|
||||
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,
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user