import Foundation import Observation /// How many of an album's (or artist's) songs have stems. struct Coverage: Equatable { var total: Int var withStems: Int var none: Bool { total > 0 && withStems == 0 } var partial: Bool { withStems >= 1 && withStems < total } } /// Which albums and artists have stems. Plex albums carry no file paths, so this keeps a slim /// copy of every track's path (fetched in pages, cached on disk, refreshed every few hours) and /// matches it against the stems index whenever that changes. Until the first full listing /// arrives [ready] is false and nothing is flagged. @Observable final class StemCoverage: @unchecked Sendable { private struct Slim: Codable { var k: String; var al: String?; var ar: String?; var f: String?; var d: Int64 = 0 } private struct Listing: Codable { var section: String; var at: Double; var tracks: [Slim] } @ObservationIgnored private let plex: PlexClient @ObservationIgnored private let stems: StemServer @ObservationIgnored private let settings: Settings @ObservationIgnored private let file: URL @ObservationIgnored private let lock = AsyncLock() @ObservationIgnored private var listing: Listing? private static let page = 1000 private static let staleSeconds: Double = 6 * 3600 private(set) var albums: [String: Coverage] = [:] private(set) var artists: [String: Coverage] = [:] /// True once the whole library has been listed and matched against a non-empty stems index. private(set) var ready = false init(plex: PlexClient, stems: StemServer, settings: Settings, dir: URL) { self.plex = plex self.stems = stems self.settings = settings file = dir.appendingPathComponent("library_tracks.json") listing = (try? Data(contentsOf: file)).flatMap { try? JSONDecoder().decode(Listing.self, from: $0) } settings.onChange("musicSection") { [weak self] in self?.sectionChanged() } stems.onIndexChange { [weak self] in self?.recompute() } sectionChanged() recompute() } private func sectionChanged() { let section = settings.musicSection guard !section.isEmpty else { return } if let l = listing, l.section == section, Date().timeIntervalSince1970 - l.at <= Self.staleSeconds { return } Task { await refresh() } } /// Lists every track in the library again (every few hours). func refresh() async { await lock.run { let section = settings.musicSection guard !section.isEmpty, plex.signedIn else { return } var all: [Slim] = [] do { var start = 0 while true { let (page, total) = try await plex.tracksPage(start: start, size: Self.page) all += page.map { Slim(k: $0.ratingKey, al: $0.albumKey, ar: $0.artistKey, f: $0.partFile, d: $0.durationMs) } start += page.count if page.isEmpty || start >= total { break } } } catch { return } let l = Listing(section: section, at: Date().timeIntervalSince1970, tracks: all) if let data = try? JSONEncoder().encode(l) { try? data.write(to: file, options: .atomic) } await MainActor.run { self.listing = l self.recompute() } } } private func recompute() { guard let l = listing, l.section == settings.musicSection, stems.index.size > 0 else { return } let tracks = l.tracks Task.detached { [self] in var al: [String: (Int, Int)] = [:] var ar: [String: (Int, Int)] = [:] for t in tracks { let has = stems.find(Track(ratingKey: t.k, title: "", artist: "", album: "", durationMs: t.d, partFile: t.f)) != nil if let k = t.al { let c = al[k] ?? (0, 0); al[k] = (c.0 + 1, c.1 + (has ? 1 : 0)) } if let k = t.ar { let c = ar[k] ?? (0, 0); ar[k] = (c.0 + 1, c.1 + (has ? 1 : 0)) } } let albums = al.mapValues { Coverage(total: $0.0, withStems: $0.1) } let artists = ar.mapValues { Coverage(total: $0.0, withStems: $0.1) } await MainActor.run { self.albums = albums self.artists = artists self.ready = true } } } }