import Domain import Foundation public actor MemoryLibrary: Library { private var artistsByID: [EntityID: Artist] = [:] private var albumsByID: [EntityID: Album] = [:] private var tracksByID: [EntityID: Track] = [:] private var playlistsByID: [EntityID: Playlist] = [:] private var entriesByPlaylist: [EntityID: [PlaylistEntry]] = [:] private var stationsByID: [EntityID: RadioStation] = [:] private var annotations: [EntityID: Annotation] = [:] private var cursors: [SourceID: Cursor] = [:] private var observers: [UUID: AsyncStream.Continuation] = [:] public init() {} public func apply(_ changes: [Change]) { for change in changes { switch change { case let .upsert(.artist(artist)): artistsByID[artist.id] = artist case let .upsert(.album(album)): albumsByID[album.id] = album case let .upsert(.track(track)): tracksByID[track.id] = track case let .upsert(.playlist(playlist, entries)): playlistsByID[playlist.id] = playlist entriesByPlaylist[playlist.id] = entries case let .upsert(.station(station)): stationsByID[station.id] = station case let .remove(kind, id): remove(kind, id) case let .annotate(id, annotation): annotations[id] = annotation case .checkpoint: break } } notify() } private func remove(_ kind: EntityKind, _ id: EntityID) { switch kind { case .artist: artistsByID[id] = nil case .album: albumsByID[id] = nil case .track: tracksByID[id] = nil case .playlist: playlistsByID[id] = nil entriesByPlaylist[id] = nil case .station: stationsByID[id] = nil } annotations[id] = nil } public func cursor(for source: SourceID) -> Cursor? { cursors[source] } public func setCursor(_ cursor: Cursor, for source: SourceID) { cursors[source] = cursor } public func updates() -> AsyncStream { let key = UUID() let (stream, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) observers[key] = continuation continuation.onTermination = { [weak self] _ in Task { await self?.removeObserver(key) } } return stream } private func removeObserver(_ key: UUID) { observers[key] = nil } private func notify() { observers.values.forEach { $0.yield() } } public func artists() -> [Artist] { artistsByID.values.sorted { $0.sortName.localizedStandardCompare($1.sortName) == .orderedAscending } } public func artist(_ id: EntityID) -> Artist? { artistsByID[id] } public func albums(sortedBy sort: AlbumSort) -> [Album] { let all = Array(albumsByID.values) switch sort { case .recentlyAdded: return all.sorted { $0.addedAt > $1.addedAt } case .title: return all.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending } case .artist: return all.sorted { let order = $0.artistName.localizedStandardCompare($1.artistName) return order == .orderedSame ? ($0.year ?? 0) < ($1.year ?? 0) : order == .orderedAscending } case .year: return all.sorted { ($0.year ?? 0) > ($1.year ?? 0) } } } public func albums(byArtist id: EntityID) -> [Album] { albumsByID.values.filter { $0.artistID == id }.sorted { ($0.year ?? 0) > ($1.year ?? 0) } } public func albums(inGenre genre: String) -> [Album] { albums(sortedBy: .artist).filter { $0.genre == genre } } public func album(_ id: EntityID) -> Album? { albumsByID[id] } public func tracks() -> [Track] { tracksByID.values.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending } } public func tracks(inAlbum id: EntityID) -> [Track] { tracksByID.values.filter { $0.albumID == id }.sorted { ($0.discNumber, $0.trackNumber) < ($1.discNumber, $1.trackNumber) } } public func tracks(byArtist id: EntityID) -> [Track] { tracksByID.values.filter { $0.artistID == id } .sorted { (annotations[$0.id]?.playCount ?? 0, $1.title) > (annotations[$1.id]?.playCount ?? 0, $0.title) } } public func tracks(inGenre genre: String) -> [Track] { tracksByID.values.filter { $0.genre == genre } } public func tracks(inPlaylist id: EntityID) -> [Track] { (entriesByPlaylist[id] ?? []).compactMap { tracksByID[$0.track] } } public func playlists() -> [Playlist] { playlistsByID.values.sorted { $0.updatedAt > $1.updatedAt } } public func stations() -> [RadioStation] { stationsByID.values.sorted { $0.name < $1.name } } public func genres() -> [Genre] { Dictionary(grouping: albumsByID.values.filter { $0.genre != nil }, by: { $0.genre ?? "" }) .map { Genre(name: $0.key, albumCount: $0.value.count) } .sorted { $0.name < $1.name } } private func albumsRanked(limit: Int, by score: (Annotation) -> Double?) -> [Album] { var best: [EntityID: Double] = [:] for (id, annotation) in annotations { guard let value = score(annotation), let album = tracksByID[id]?.albumID else { continue } best[album] = max(best[album] ?? -.infinity, value) } return best.sorted { $0.value > $1.value }.prefix(limit).compactMap { albumsByID[$0.key] } } public func recentlyPlayed(limit: Int) -> [Album] { albumsRanked(limit: limit) { $0.lastPlayedAt?.timeIntervalSince1970 } } public func mostPlayed(limit: Int) -> [Album] { albumsRanked(limit: limit) { $0.playCount > 0 ? Double($0.playCount) : nil } } public func favoriteTracks() -> [Track] { tracksByID.values .filter { annotations[$0.id]?.favoritedAt != nil } .sorted { (annotations[$0.id]?.favoritedAt ?? .distantPast) > (annotations[$1.id]?.favoritedAt ?? .distantPast) } } public func favorites() -> Set { Set(annotations.filter { $0.value.favoritedAt != nil }.keys) } public func setFavorite(_ isFavorite: Bool, for id: EntityID) { annotations[id, default: Annotation()].favoritedAt = isFavorite ? Date() : nil notify() } public func recordPlay(of track: Track, at date: Date) { annotations[track.id, default: Annotation()].playCount += 1 annotations[track.id, default: Annotation()].lastPlayedAt = date notify() } public func search(_ query: String) -> SearchResults { let needle = query.trimmingCharacters(in: .whitespaces) guard !needle.isEmpty else { return SearchResults() } func matches(_ text: String) -> Bool { text.range(of: needle, options: [.caseInsensitive, .diacriticInsensitive]) != nil } var results = SearchResults() results.artists = artists().filter { matches($0.name) } results.albums = albums(sortedBy: .title).filter { matches($0.title) } results.tracks = Array(tracks().filter { matches($0.title) }.prefix(50)) results.playlists = playlists().filter { matches($0.name) } return results } }