240 lines
8.9 KiB
Swift
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
|
|
}
|