import Foundation /// What a pick or a voice request turns into: a queue, where to start, and whether to shuffle it. struct Resolved { var tracks: [Track] var start = 0 var shuffle = false } /// A row in the car's lists; CarPlayScene turns these into CPListItems. struct CarItem { enum Art: Equatable { case none case thumb(String) case letter(String) case shuffle } var id: String var title: String var subtitle: String? var art: Art = .none var playable: Bool /// Section header the row sits under, nil for the list's first unnamed section. var group: String? var downloaded = false var trackKey: String? } /// The browse tree the car draws. CarPlay renders the lists itself, so designing for the car /// means shaping this tree: four tabs, shallow folders, Shuffle as the first row, A–Z folders /// instead of long lists. /// /// Away from home (Plex and stemserve unreachable) everything comes from the HTTP cache and /// only songs that will actually play offline are listed, with Downloads as the first tab. final class CarLibrary: @unchecked Sendable { private static let cacheNs: UInt64 = 120_000_000_000 private let plex: PlexClient private let reach: HomeReach private let downloads: Downloads private let stems: StemServer private let pm: PlaybackManager private struct Cached { let at: UInt64; let tracks: [Track] } private let lock = UnfairLock() private var trackCache: [String: Cached] = [:] private var order: [String] = [] init(plex: PlexClient, reach: HomeReach, downloads: Downloads, stems: StemServer, pm: PlaybackManager) { self.plex = plex self.reach = reach self.downloads = downloads self.stems = stems self.pm = pm } private func away() async -> Bool { !(await reach.reachable()) } // ---- tree ----------------------------------------------------------------------- /// Tab ids and titles, Downloads first when away from home. func tabs() async -> [(String, String)] { let tabs = [(CarIds.playlists, "Playlists"), (CarIds.albums, "Albums"), (CarIds.artists, "Artists"), (CarIds.downloads, "Downloads")] return await away() ? [tabs[3]] + tabs.prefix(3) : tabs } /// Children of [id], or nil if the id means nothing to us. func children(_ id: String) async throws -> [CarItem]? { let away = await away() switch id { case CarIds.downloads: return await songs(CarIds.downloads, try tracksOf(CarIds.downloads), shuffleRow: true, showArtist: true) case CarIds.playlists: return try await playlists(away) case CarIds.albums: return try await albumsTab(away) case CarIds.artists: return try await artistsTab(away) default: guard let (kind, key) = CarIds.split(id) else { return nil } switch kind { case "playlist", "stemplaylist": return await songs(id, try tracksOf(id), shuffleRow: true, showArtist: true) case "album": return await songs(id, try tracksOf(id), shuffleRow: false, showArtist: false) case "artist": return try await artistPage(key, away) case "albums": return try await albumsFor(away).filter { CarTree.letterOf($0.title) == key }.map { albumItem($0) } case "artists": return try await artistsFor(away).filter { CarTree.letterOf($0.sortTitle) == key }.map { artistItem($0) } case "search": return try await searchItems(key) default: return nil } } } private func playlists(_ away: Bool) async throws -> [CarItem] { let all = try await plex.playlists() var shown: [Playlist] = [] if away { for p in all where ((try? await tracksOf(CarIds.playlist(p.ratingKey)))?.isEmpty == false) { shown.append(p) } } else { shown = all } let stem = await MainActor.run { AppGraph.shared.stemPlaylists.lists.filter { !$0.items.isEmpty } } let stemItems = stem.map { l in CarItem(id: "stemplaylist:\(l.id)", title: l.title, subtitle: CarTree.countLabel(l.items.count, "song", "songs"), art: artOr(l.thumb, l.title), playable: false, group: "Stem Playlists") } let plexItems = shown.map { playlistItem($0, group: stemItems.isEmpty ? nil : "Plex Playlists") } return stemItems + plexItems } private func albumsTab(_ away: Bool) async throws -> [CarItem] { let albums = try await albumsFor(away) if away || albums.count <= CarTree.flatMax { return albums.map { albumItem($0) } } let recent = (try? await plex.recentlyAdded(count: CarTree.recentAlbums)) ?? [] return recent.map { albumItem($0, group: "Recently added") } + CarTree.buckets(albums, \.title).map { letter, list in CarItem(id: CarIds.albumLetter(letter), title: letter, subtitle: CarTree.countLabel(list.count, "album", "albums"), art: .letter(letter), playable: false, group: "All albums") } } private func artistsTab(_ away: Bool) async throws -> [CarItem] { let artists = try await artistsFor(away) if artists.count <= CarTree.flatMax { return artists.map { artistItem($0) } } return CarTree.buckets(artists, \.sortTitle).map { letter, list in CarItem(id: CarIds.artistLetter(letter), title: letter, subtitle: CarTree.countLabel(list.count, "artist", "artists"), art: .letter(letter), playable: false) } } private func artistPage(_ key: String, _ away: Bool) async throws -> [CarItem] { let id = CarIds.artist(key) let albums = away ? albumsOf(downloadedTracks().filter { $0.artistKey == key }) : try await plex.artistAlbums(key) let shuffle = ((try? await tracksOf(id))?.isEmpty == false) ? [shuffleItem(id, "Shuffle artist")] : [] return shuffle + albums.map { albumItem($0) } } /// Away from home, the library is what's downloaded. private func albumsFor(_ away: Bool) async throws -> [Album] { away ? albumsOf(downloadedTracks()).sorted { $0.title.lowercased() < $1.title.lowercased() } : try await plex.albums() } private func artistsFor(_ away: Bool) async throws -> [Artist] { guard away else { return try await plex.artists() } var seen = Set() return downloadedTracks().compactMap { t -> Artist? in guard let k = t.artistKey, seen.insert(k).inserted else { return nil } return Artist(ratingKey: k, title: t.artist, sortTitle: t.artist) }.sorted { $0.sortTitle.lowercased() < $1.sortTitle.lowercased() } } private func albumsOf(_ tracks: [Track]) -> [Album] { var seen = Set() return tracks.compactMap { t in guard let k = t.albumKey, seen.insert(k).inserted else { return nil } return Album(ratingKey: k, title: t.album, artist: t.artist, artistKey: t.artistKey, year: t.year, thumb: t.thumb) } } func downloadedTracks() -> [Track] { downloads.records.values.filter(\.done).map(\.track).sorted { ($0.artist.lowercased(), $0.album.lowercased(), $0.disc ?? 0, $0.index ?? 0) < ($1.artist.lowercased(), $1.album.lowercased(), $1.disc ?? 0, $1.index ?? 0) } } /// A container's songs, minus those that can't play right now. private func tracksOf(_ container: String) async throws -> [Track] { let now = DispatchTime.now().uptimeNanoseconds let away = await away() // Home and away lists differ (away keeps only what plays offline), so they cache apart. let ck = "\(away)|\(container)" if let hit = lock.with({ trackCache[ck] }), now - hit.at < Self.cacheNs, !hit.tracks.isEmpty { return hit.tracks } let all: [Track] if container == CarIds.downloads { all = downloadedTracks() } else { guard let (kind, key) = CarIds.split(container) else { return [] } switch kind { case "playlist": all = try await plex.playlistTracks(key) case "stemplaylist": all = await MainActor.run { AppGraph.shared.stemPlaylists.list(key)?.tracks ?? [] } case "album": all = try await plex.albumTracks(key) case "artist": all = away ? downloadedTracks().filter { $0.artistKey == key } : try await plex.artistTracks(key) case "search": all = try await searchTracks(key, away) default: all = [] } } let playable = away ? await MainActor.run { all.filter { pm.playableOffline($0) } } : all lock.with { trackCache[ck] = Cached(at: now, tracks: playable) order.removeAll { $0 == ck } order.append(ck) if order.count > 24 { trackCache[order.removeFirst()] = nil } } return playable } // ---- picks ----------------------------------------------------------------------- /// Turns a tapped row into a queue. func resolve(_ id: String) async throws -> Resolved? { guard let pick = CarIds.parsePick(id) else { return nil } let tracks = try await tracksOf(pick.container) if tracks.isEmpty { return nil } switch pick { case .shuffle: return Resolved(tracks: tracks, shuffle: true) case .song(_, let key): return Resolved(tracks: tracks, start: tracks.firstIndex { $0.ratingKey == key } ?? 0) } } /// "Hey Siri, play … on Stemify". Prefers a playlist, then an artist (shuffled), then an /// album whose name matches; otherwise plays the song hits. Away from home it searches downloads. func resolveSearch(_ query: String) async throws -> Resolved? { if query.trimmingCharacters(in: .whitespaces).isEmpty { let dl = try await tracksOf(CarIds.downloads) return dl.isEmpty ? nil : Resolved(tracks: dl, shuffle: true) } if await away() { let hits = offlineSearch(query) return hits.isEmpty ? nil : Resolved(tracks: hits) } let r = try await plex.search(query) if let p = r.playlists.first(where: { CarTree.matches($0.title, query) }), let t = try? await tracksOf(CarIds.playlist(p.ratingKey)), !t.isEmpty { return Resolved(tracks: t) } if let a = r.artists.first(where: { CarTree.matches($0.title, query) }), let t = try? await tracksOf(CarIds.artist(a.ratingKey)), !t.isEmpty { return Resolved(tracks: t, shuffle: true) } if let a = r.albums.first(where: { CarTree.matches($0.title, query) }), let t = try? await tracksOf(CarIds.album(a.ratingKey)), !t.isEmpty { return Resolved(tracks: t) } if !r.tracks.isEmpty { return Resolved(tracks: r.tracks) } if let p = r.playlists.first { return Resolved(tracks: try await tracksOf(CarIds.playlist(p.ratingKey))) } if let a = r.artists.first { return Resolved(tracks: try await tracksOf(CarIds.artist(a.ratingKey)), shuffle: true) } if let a = r.albums.first { return Resolved(tracks: try await tracksOf(CarIds.album(a.ratingKey))) } return nil } private func searchTracks(_ query: String, _ away: Bool) async throws -> [Track] { away ? offlineSearch(query) : try await plex.search(query).tracks } func offlineSearch(_ query: String) -> [Track] { let q = CarTree.norm(query) if q.isEmpty { return [] } let dl = downloadedTracks() let byTitle = dl.filter { CarTree.norm($0.title).contains(q) } let byOther = dl.filter { !CarTree.norm($0.title).contains(q) && (CarTree.norm($0.artist).contains(q) || CarTree.norm($0.album).contains(q)) } return byTitle + byOther } /// Results for a search (from Siri, or the phone's last query). func searchItems(_ query: String) async throws -> [CarItem] { let container = CarIds.search(query) if await away() { return await songs(container, try tracksOf(container), shuffleRow: false, showArtist: true, group: "Downloaded songs") } let r = try await plex.search(query) return await songs(container, try tracksOf(container), shuffleRow: false, showArtist: true, group: "Songs") + r.artists.map { artistItem($0, group: "Artists") } + r.albums.map { albumItem($0, group: "Albums") } + r.playlists.map { playlistItem($0, group: "Playlists") } } // ---- items ----------------------------------------------------------------------- private func songs(_ container: String, _ tracks: [Track], shuffleRow: Bool, showArtist: Bool, group: String? = nil) async -> [CarItem] { let rows = await MainActor.run { tracks.map { songItem(container, $0, showArtist, group) } } return shuffleRow && tracks.count > 1 ? [shuffleItem(container, "Shuffle all", group)] + rows : rows } private func songItem(_ container: String, _ t: Track, _ showArtist: Bool, _ group: String?) -> CarItem { // Stems are the norm, so only the exceptions are marked. let noStems = stems.index.size > 0 && !pm.hasStems(t) let sub = [noStems ? "No stems" : nil, showArtist ? t.artist : nil].compactMap { $0 }.joined(separator: " · ") return CarItem(id: CarIds.play(container, t.ratingKey), title: t.title, subtitle: sub.isEmpty ? nil : sub, art: t.thumb.map { .thumb($0) } ?? .letter(CarTree.letterOf(t.album)), playable: true, group: group, downloaded: pm.playableOffline(t), trackKey: t.ratingKey) } private func shuffleItem(_ container: String, _ title: String, _ group: String? = nil) -> CarItem { CarItem(id: CarIds.shuffle(container), title: title, art: .shuffle, playable: true, group: group) } private func playlistItem(_ p: Playlist, group: String? = nil) -> CarItem { CarItem(id: CarIds.playlist(p.ratingKey), title: p.title, subtitle: CarTree.countLabel(p.trackCount, "song", "songs"), art: artOr(p.thumb, p.title), playable: false, group: group) } private func albumItem(_ a: Album, group: String? = nil) -> CarItem { CarItem(id: CarIds.album(a.ratingKey), title: a.title, subtitle: a.artist, art: artOr(a.thumb, a.title), playable: false, group: group) } private func artistItem(_ a: Artist, group: String? = nil) -> CarItem { CarItem(id: CarIds.artist(a.ratingKey), title: a.title, art: artOr(a.thumb, a.sortTitle), playable: false, group: group) } /// Plex art, or a letter tile so rows never sit blank. private func artOr(_ thumb: String?, _ title: String) -> CarItem.Art { thumb.map { .thumb($0) } ?? .letter(CarTree.letterOf(title)) } }