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 { (plays($0), $1.title) > (plays($1), $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 named(_ name: String) -> Int? { relevance(of: needle, in: name) } func credited(_ title: String, _ artist: String) -> Int? { [named(title), named(artist).map { $0 + 1 }].compactMap(\.self).min() } let popular = tracks().sorted { (plays($0), $1.title) > (plays($1), $0.title) } let artists = ranked(artists()) { named($0.name) } let tracks = Array(ranked(popular) { credited($0.title, $0.artistName) }.prefix(50)) let albums = ranked(albums(sortedBy: .title)) { credited($0.title, $0.artistName) } let playlists = ranked(playlists()) { named($0.name) } let candidates = artists.prefix(3).map { $0.map(SearchHit.artist) } + tracks.prefix(12).map { $0.map(SearchHit.track) } + albums.prefix(4).map { $0.map(SearchHit.album) } + playlists.prefix(3).map { $0.map(SearchHit.playlist) } var results = SearchResults() results.top = ranked(candidates) { $0.score }.map(\.item.item) results.artists = artists.map(\.item) results.albums = albums.map(\.item) results.tracks = tracks.map(\.item) results.playlists = playlists.map(\.item) return results } private func plays(_ track: Track) -> Int { annotations[track.id]?.playCount ?? 0 } } private struct Ranked { let item: Item let score: Int func map(_ transform: (Item) -> Other) -> Ranked { Ranked(item: transform(item), score: score) } } private func ranked(_ items: [Item], by score: (Item) -> Int?) -> [Ranked] { items.enumerated() .compactMap { offset, item in score(item).map { (Ranked(item: item, score: $0), offset) } } .sorted { ($0.0.score, $0.1) < ($1.0.score, $1.1) } .map(\.0) } private func relevance(of needle: String, in text: String) -> Int? { let options: String.CompareOptions = [.caseInsensitive, .diacriticInsensitive] guard let range = text.range(of: needle, options: options) else { return nil } if text.compare(needle, options: options) == .orderedSame { return 0 } if range.lowerBound == text.startIndex { return 1 } return text[text.index(before: range.lowerBound)].isLetter ? 3 : 2 }