Files

240 lines
8.9 KiB
Swift

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<Void>.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<Void> {
let key = UUID()
let (stream, continuation) = AsyncStream<Void>.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<EntityID> {
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<Item> {
let item: Item
let score: Int
func map<Other>(_ transform: (Item) -> Other) -> Ranked<Other> {
Ranked<Other>(item: transform(item), score: score)
}
}
private func ranked<Item>(_ items: [Item], by score: (Item) -> Int?) -> [Ranked<Item>] {
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
}