562 lines
22 KiB
Swift
562 lines
22 KiB
Swift
//
|
|
// MALibraryManager.swift
|
|
// Mobile Music Assistant
|
|
//
|
|
// Created by Sven Hanold on 26.03.26.
|
|
//
|
|
|
|
import Foundation
|
|
import OSLog
|
|
|
|
private let logger = Logger(subsystem: "com.musicassistant.mobile", category: "Library")
|
|
|
|
/// Manages library data with in-memory state and JSON disk cache.
|
|
@Observable
|
|
final class MALibraryManager {
|
|
// MARK: - Properties
|
|
|
|
private weak var service: MAService?
|
|
|
|
// Published library data
|
|
private(set) var artists: [MAArtist] = []
|
|
private(set) var albumArtists: [MAArtist] = []
|
|
private(set) var albums: [MAAlbum] = []
|
|
private(set) var playlists: [MAPlaylist] = []
|
|
private(set) var podcasts: [MAPodcast] = []
|
|
private(set) var genres: [MAGenre] = []
|
|
/// Deduplicated, non-empty genres ready for display. Populated after loadGenres completes.
|
|
private(set) var displayGenres: [MAGenre] = []
|
|
|
|
// Pagination
|
|
private var artistsOffset = 0
|
|
private var albumArtistsOffset = 0
|
|
private var albumsOffset = 0
|
|
private var hasMoreArtists = true
|
|
private var hasMoreAlbumArtists = true
|
|
private var hasMoreAlbums = true
|
|
private let pageSize = 50
|
|
|
|
// Loading states
|
|
private(set) var isLoadingArtists = false
|
|
private(set) var isLoadingAlbumArtists = false
|
|
private(set) var isLoadingAlbums = false
|
|
private(set) var isLoadingPlaylists = false
|
|
private(set) var isLoadingPodcasts = false
|
|
private(set) var isLoadingGenres = false
|
|
|
|
/// URIs currently marked as favorites — source of truth for UI.
|
|
/// Populated from decoded model data, then mutated optimistically on toggle.
|
|
private(set) var favoriteURIs: Set<String> = []
|
|
|
|
// Last refresh timestamps (persisted in UserDefaults)
|
|
private(set) var lastArtistsRefresh: Date?
|
|
private(set) var lastAlbumArtistsRefresh: Date?
|
|
private(set) var lastAlbumsRefresh: Date?
|
|
private(set) var lastPlaylistsRefresh: Date?
|
|
private(set) var lastPodcastsRefresh: Date?
|
|
|
|
// MARK: - Disk Cache
|
|
|
|
/// Increment this whenever the model format changes to invalidate stale caches.
|
|
private static let cacheVersion = 3
|
|
|
|
private let cacheDirectory: URL = {
|
|
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
|
let dir = caches.appendingPathComponent("MMLibrary", isDirectory: true)
|
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
return dir
|
|
}()
|
|
|
|
// MARK: - Initialization
|
|
|
|
init(service: MAService?) {
|
|
self.service = service
|
|
migrateIfNeeded()
|
|
loadFromDisk()
|
|
}
|
|
|
|
/// Clears all disk-cached library data when the model format changes.
|
|
private func migrateIfNeeded() {
|
|
let storedVersion = UserDefaults.standard.integer(forKey: "lib.cacheVersion")
|
|
guard storedVersion < Self.cacheVersion else { return }
|
|
|
|
logger.info("Cache version mismatch (\(storedVersion) → \(Self.cacheVersion)), clearing library cache")
|
|
try? FileManager.default.removeItem(at: cacheDirectory)
|
|
try? FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
|
|
for key in ["lib.lastArtistsRefresh", "lib.lastAlbumArtistsRefresh", "lib.lastAlbumsRefresh", "lib.lastPlaylistsRefresh"] {
|
|
UserDefaults.standard.removeObject(forKey: key)
|
|
}
|
|
UserDefaults.standard.set(Self.cacheVersion, forKey: "lib.cacheVersion")
|
|
}
|
|
|
|
func setService(_ service: MAService) {
|
|
self.service = service
|
|
}
|
|
|
|
// MARK: - Disk Persistence
|
|
|
|
/// Loads all cached library data from disk (called synchronously on init).
|
|
func loadFromDisk() {
|
|
if let cached: [MAArtist] = load("artists.json") {
|
|
artists = cached
|
|
artistsOffset = cached.count
|
|
logger.info("Loaded \(cached.count) artists from disk cache")
|
|
}
|
|
if let cached: [MAArtist] = load("albumartists.json") {
|
|
albumArtists = cached
|
|
albumArtistsOffset = cached.count
|
|
logger.info("Loaded \(cached.count) album artists from disk cache")
|
|
}
|
|
if let cached: [MAAlbum] = load("albums.json") {
|
|
albums = cached
|
|
albumsOffset = cached.count
|
|
logger.info("Loaded \(cached.count) albums from disk cache")
|
|
}
|
|
if let cached: [MAPlaylist] = load("playlists.json") {
|
|
playlists = cached
|
|
logger.info("Loaded \(cached.count) playlists from disk cache")
|
|
}
|
|
if let cached: [MAPodcast] = load("podcasts.json") {
|
|
podcasts = cached
|
|
logger.info("Loaded \(cached.count) podcasts from disk cache")
|
|
}
|
|
|
|
// Seed favorite URIs from cached data
|
|
for artist in artists where artist.favorite { favoriteURIs.insert(artist.uri) }
|
|
for artist in albumArtists where artist.favorite { favoriteURIs.insert(artist.uri) }
|
|
for album in albums where album.favorite { favoriteURIs.insert(album.uri) }
|
|
for podcast in podcasts where podcast.favorite { favoriteURIs.insert(podcast.uri) }
|
|
|
|
let ud = UserDefaults.standard
|
|
lastArtistsRefresh = ud.object(forKey: "lib.lastArtistsRefresh") as? Date
|
|
lastAlbumArtistsRefresh = ud.object(forKey: "lib.lastAlbumArtistsRefresh") as? Date
|
|
lastAlbumsRefresh = ud.object(forKey: "lib.lastAlbumsRefresh") as? Date
|
|
lastPlaylistsRefresh = ud.object(forKey: "lib.lastPlaylistsRefresh") as? Date
|
|
lastPodcastsRefresh = ud.object(forKey: "lib.lastPodcastsRefresh") as? Date
|
|
}
|
|
|
|
private func save<T: Encodable>(_ value: T, _ filename: String) {
|
|
guard let data = try? JSONEncoder().encode(value) else { return }
|
|
let url = cacheDirectory.appendingPathComponent(filename)
|
|
Task.detached(priority: .background) {
|
|
try? data.write(to: url, options: .atomic)
|
|
}
|
|
}
|
|
|
|
private func load<T: Decodable>(_ filename: String) -> T? {
|
|
let url = cacheDirectory.appendingPathComponent(filename)
|
|
guard let data = try? Data(contentsOf: url) else { return nil }
|
|
return try? JSONDecoder().decode(T.self, from: data)
|
|
}
|
|
|
|
/// Clears all cached library data from memory and disk.
|
|
func clearAll() {
|
|
artists = []
|
|
albumArtists = []
|
|
albums = []
|
|
playlists = []
|
|
podcasts = []
|
|
genres = []
|
|
favoriteURIs = []
|
|
artistsOffset = 0
|
|
albumArtistsOffset = 0
|
|
albumsOffset = 0
|
|
hasMoreArtists = true
|
|
hasMoreAlbumArtists = true
|
|
hasMoreAlbums = true
|
|
lastArtistsRefresh = nil
|
|
lastAlbumArtistsRefresh = nil
|
|
lastAlbumsRefresh = nil
|
|
lastPlaylistsRefresh = nil
|
|
lastPodcastsRefresh = nil
|
|
|
|
let ud = UserDefaults.standard
|
|
for key in ["lib.lastArtistsRefresh", "lib.lastAlbumArtistsRefresh",
|
|
"lib.lastAlbumsRefresh", "lib.lastPlaylistsRefresh", "lib.lastPodcastsRefresh"] {
|
|
ud.removeObject(forKey: key)
|
|
}
|
|
|
|
try? FileManager.default.removeItem(at: cacheDirectory)
|
|
try? FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
|
|
}
|
|
|
|
private func markRefreshed(_ key: String) -> Date {
|
|
let now = Date()
|
|
UserDefaults.standard.set(now, forKey: key)
|
|
return now
|
|
}
|
|
|
|
// MARK: - Artists
|
|
|
|
func loadArtists(refresh: Bool = false) async throws {
|
|
guard !isLoadingArtists else { return }
|
|
guard let service else { throw MAWebSocketClient.ClientError.notConnected }
|
|
|
|
if refresh {
|
|
// Full refresh: load ALL pages from scratch so we never lose data
|
|
isLoadingArtists = true
|
|
defer { isLoadingArtists = false }
|
|
|
|
logger.info("Refreshing all artists")
|
|
var allArtists: [MAArtist] = []
|
|
var offset = 0
|
|
var hasMore = true
|
|
while hasMore {
|
|
let page = try await service.getArtists(limit: pageSize, offset: offset)
|
|
allArtists.append(contentsOf: page)
|
|
offset += page.count
|
|
hasMore = page.count >= pageSize
|
|
}
|
|
artists = allArtists
|
|
artistsOffset = allArtists.count
|
|
hasMoreArtists = false
|
|
for a in allArtists where a.favorite { favoriteURIs.insert(a.uri) }
|
|
save(artists, "artists.json")
|
|
lastArtistsRefresh = markRefreshed("lib.lastArtistsRefresh")
|
|
logger.info("Refreshed all artists, total: \(allArtists.count)")
|
|
} else {
|
|
// Pagination: load next page
|
|
guard hasMoreArtists else { return }
|
|
|
|
isLoadingArtists = true
|
|
defer { isLoadingArtists = false }
|
|
|
|
logger.info("Loading artists page (offset: \(self.artistsOffset))")
|
|
let newArtists = try await service.getArtists(limit: pageSize, offset: self.artistsOffset)
|
|
|
|
artists.append(contentsOf: newArtists)
|
|
artistsOffset += newArtists.count
|
|
for a in newArtists where a.favorite { favoriteURIs.insert(a.uri) }
|
|
hasMoreArtists = newArtists.count >= pageSize
|
|
|
|
if !hasMoreArtists || artistsOffset <= pageSize {
|
|
save(artists, "artists.json")
|
|
lastArtistsRefresh = markRefreshed("lib.lastArtistsRefresh")
|
|
}
|
|
logger.info("Loaded \(newArtists.count) artists, total: \(self.artists.count)")
|
|
}
|
|
}
|
|
|
|
/// Persist updated artist list after pagination completes.
|
|
func loadMoreArtistsIfNeeded(currentItem: MAArtist?) async throws {
|
|
guard let currentItem else { return }
|
|
let thresholdIndex = artists.index(artists.endIndex, offsetBy: -10)
|
|
if let idx = artists.firstIndex(where: { $0.id == currentItem.id }), idx >= thresholdIndex {
|
|
try await loadArtists(refresh: false)
|
|
save(artists, "artists.json")
|
|
}
|
|
}
|
|
|
|
// MARK: - Album Artists
|
|
|
|
func loadAlbumArtists(refresh: Bool = false) async throws {
|
|
guard !isLoadingAlbumArtists else { return }
|
|
guard let service else { throw MAWebSocketClient.ClientError.notConnected }
|
|
|
|
if refresh {
|
|
isLoadingAlbumArtists = true
|
|
defer { isLoadingAlbumArtists = false }
|
|
|
|
logger.info("Refreshing all album artists")
|
|
var allArtists: [MAArtist] = []
|
|
var offset = 0
|
|
var hasMore = true
|
|
while hasMore {
|
|
let page = try await service.getAlbumArtists(limit: pageSize, offset: offset)
|
|
allArtists.append(contentsOf: page)
|
|
offset += page.count
|
|
hasMore = page.count >= pageSize
|
|
}
|
|
albumArtists = allArtists
|
|
albumArtistsOffset = allArtists.count
|
|
hasMoreAlbumArtists = false
|
|
for a in allArtists where a.favorite { favoriteURIs.insert(a.uri) }
|
|
save(albumArtists, "albumartists.json")
|
|
lastAlbumArtistsRefresh = markRefreshed("lib.lastAlbumArtistsRefresh")
|
|
logger.info("Refreshed all album artists, total: \(allArtists.count)")
|
|
} else {
|
|
guard hasMoreAlbumArtists else { return }
|
|
|
|
isLoadingAlbumArtists = true
|
|
defer { isLoadingAlbumArtists = false }
|
|
|
|
logger.info("Loading album artists page (offset: \(self.albumArtistsOffset))")
|
|
let newArtists = try await service.getAlbumArtists(limit: pageSize, offset: self.albumArtistsOffset)
|
|
|
|
albumArtists.append(contentsOf: newArtists)
|
|
albumArtistsOffset += newArtists.count
|
|
for a in newArtists where a.favorite { favoriteURIs.insert(a.uri) }
|
|
hasMoreAlbumArtists = newArtists.count >= pageSize
|
|
|
|
if !hasMoreAlbumArtists || albumArtistsOffset <= pageSize {
|
|
save(albumArtists, "albumartists.json")
|
|
lastAlbumArtistsRefresh = markRefreshed("lib.lastAlbumArtistsRefresh")
|
|
}
|
|
logger.info("Loaded \(newArtists.count) album artists, total: \(self.albumArtists.count)")
|
|
}
|
|
}
|
|
|
|
func loadMoreAlbumArtistsIfNeeded(currentItem: MAArtist?) async throws {
|
|
guard let currentItem else { return }
|
|
let thresholdIndex = albumArtists.index(albumArtists.endIndex, offsetBy: -10)
|
|
if let idx = albumArtists.firstIndex(where: { $0.id == currentItem.id }), idx >= thresholdIndex {
|
|
try await loadAlbumArtists(refresh: false)
|
|
save(albumArtists, "albumartists.json")
|
|
}
|
|
}
|
|
|
|
// MARK: - Albums
|
|
|
|
func loadAlbums(refresh: Bool = false) async throws {
|
|
guard !isLoadingAlbums else { return }
|
|
guard let service else { throw MAWebSocketClient.ClientError.notConnected }
|
|
|
|
if refresh {
|
|
isLoadingAlbums = true
|
|
defer { isLoadingAlbums = false }
|
|
|
|
logger.info("Refreshing all albums")
|
|
var allAlbums: [MAAlbum] = []
|
|
var offset = 0
|
|
var hasMore = true
|
|
while hasMore {
|
|
let page = try await service.getAlbums(limit: pageSize, offset: offset)
|
|
allAlbums.append(contentsOf: page)
|
|
offset += page.count
|
|
hasMore = page.count >= pageSize
|
|
}
|
|
albums = allAlbums
|
|
albumsOffset = allAlbums.count
|
|
hasMoreAlbums = false
|
|
for a in allAlbums where a.favorite { favoriteURIs.insert(a.uri) }
|
|
save(albums, "albums.json")
|
|
lastAlbumsRefresh = markRefreshed("lib.lastAlbumsRefresh")
|
|
logger.info("Refreshed all albums, total: \(allAlbums.count)")
|
|
} else {
|
|
guard hasMoreAlbums else { return }
|
|
|
|
isLoadingAlbums = true
|
|
defer { isLoadingAlbums = false }
|
|
|
|
logger.info("Loading albums page (offset: \(self.albumsOffset))")
|
|
let newAlbums = try await service.getAlbums(limit: pageSize, offset: self.albumsOffset)
|
|
|
|
albums.append(contentsOf: newAlbums)
|
|
albumsOffset += newAlbums.count
|
|
for a in newAlbums where a.favorite { favoriteURIs.insert(a.uri) }
|
|
hasMoreAlbums = newAlbums.count >= pageSize
|
|
|
|
if !hasMoreAlbums || albumsOffset <= pageSize {
|
|
save(albums, "albums.json")
|
|
lastAlbumsRefresh = markRefreshed("lib.lastAlbumsRefresh")
|
|
}
|
|
logger.info("Loaded \(newAlbums.count) albums, total: \(self.albums.count)")
|
|
}
|
|
}
|
|
|
|
func loadMoreAlbumsIfNeeded(currentItem: MAAlbum?) async throws {
|
|
guard let currentItem else { return }
|
|
let thresholdIndex = albums.index(albums.endIndex, offsetBy: -10)
|
|
if let idx = albums.firstIndex(where: { $0.id == currentItem.id }), idx >= thresholdIndex {
|
|
try await loadAlbums(refresh: false)
|
|
save(albums, "albums.json")
|
|
}
|
|
}
|
|
|
|
// MARK: - Playlists
|
|
|
|
func loadPlaylists(refresh: Bool = false) async throws {
|
|
guard !isLoadingPlaylists else { return }
|
|
guard let service else { throw MAWebSocketClient.ClientError.notConnected }
|
|
|
|
isLoadingPlaylists = true
|
|
defer { isLoadingPlaylists = false }
|
|
|
|
logger.info("Loading playlists")
|
|
|
|
let loaded = try await service.getPlaylists()
|
|
playlists = loaded
|
|
save(playlists, "playlists.json")
|
|
lastPlaylistsRefresh = markRefreshed("lib.lastPlaylistsRefresh")
|
|
|
|
logger.info("Loaded \(loaded.count) playlists")
|
|
}
|
|
|
|
// MARK: - Podcasts
|
|
|
|
func loadPodcasts(refresh: Bool = false) async throws {
|
|
guard !isLoadingPodcasts else { return }
|
|
guard let service else { throw MAWebSocketClient.ClientError.notConnected }
|
|
|
|
isLoadingPodcasts = true
|
|
defer { isLoadingPodcasts = false }
|
|
|
|
logger.info("Loading podcasts")
|
|
|
|
let loaded = try await service.getPodcasts()
|
|
podcasts = loaded
|
|
for podcast in loaded where podcast.favorite { favoriteURIs.insert(podcast.uri) }
|
|
save(podcasts, "podcasts.json")
|
|
lastPodcastsRefresh = markRefreshed("lib.lastPodcastsRefresh")
|
|
|
|
logger.info("Loaded \(loaded.count) podcasts")
|
|
}
|
|
|
|
// MARK: - Genres
|
|
|
|
func loadGenres(refresh: Bool = false) async throws {
|
|
guard !isLoadingGenres else { return }
|
|
guard genres.isEmpty || refresh else { return }
|
|
guard let service else { throw MAWebSocketClient.ClientError.notConnected }
|
|
|
|
isLoadingGenres = true
|
|
defer { isLoadingGenres = false }
|
|
|
|
logger.info("Loading genres")
|
|
let loaded = try await service.getGenres()
|
|
genres = loaded.sorted { $0.name < $1.name }
|
|
logger.info("Loaded \(loaded.count) genres, filtering empty ones…")
|
|
let filtered = await filterNonEmptyGenres(service: service)
|
|
displayGenres = filtered
|
|
logger.info("Displaying \(filtered.count) non-empty genres")
|
|
}
|
|
|
|
/// Returns deduplicated genres that have at least one artist or album.
|
|
private func filterNonEmptyGenres(service: MAService) async -> [MAGenre] {
|
|
// Deduplicate by name and collect all IDs for each unique name
|
|
var seenNames = Set<String>()
|
|
var uniqueGenres: [MAGenre] = []
|
|
for genre in genres where seenNames.insert(genre.name.lowercased()).inserted {
|
|
uniqueGenres.append(genre)
|
|
}
|
|
|
|
// Pre-compute genre IDs per unique name before entering the task group
|
|
let genreWithIds: [(MAGenre, [Int])] = uniqueGenres.map { genre in
|
|
let ids = genres
|
|
.filter { $0.name.caseInsensitiveCompare(genre.name) == .orderedSame }
|
|
.compactMap { Int($0.uri.components(separatedBy: "/").last ?? "") }
|
|
return (genre, ids)
|
|
}
|
|
|
|
return await withTaskGroup(of: MAGenre?.self) { group in
|
|
for (genre, ids) in genreWithIds where !ids.isEmpty {
|
|
group.addTask {
|
|
do {
|
|
let artists = try await service.getArtistsByGenre(genreIds: ids, limit: 1)
|
|
if !artists.isEmpty { return genre }
|
|
let albums = try await service.getAlbumsByGenre(genreIds: ids, limit: 1)
|
|
return albums.isEmpty ? nil : genre
|
|
} catch {
|
|
return genre // Don't hide on network error
|
|
}
|
|
}
|
|
}
|
|
var result: [MAGenre] = []
|
|
for await genre in group {
|
|
if let genre { result.append(genre) }
|
|
}
|
|
return result.sorted { $0.name < $1.name }
|
|
}
|
|
}
|
|
|
|
/// Fetch all artists and albums that belong to the given genre name.
|
|
/// Extracts database IDs from matching genre URIs and uses the native
|
|
/// `genre` parameter of MA Server's library_items endpoints.
|
|
func browseGenresByName(_ name: String) async throws -> [MAMediaItem] {
|
|
guard let service else { throw MAWebSocketClient.ClientError.notConnected }
|
|
|
|
// Genre URIs look like "library://genre/26". Extract the integer IDs.
|
|
let genreIds = genres
|
|
.filter { $0.name.caseInsensitiveCompare(name) == .orderedSame }
|
|
.compactMap { Int($0.uri.components(separatedBy: "/").last ?? "") }
|
|
|
|
guard !genreIds.isEmpty else { return [] }
|
|
|
|
async let artists = service.getArtistsByGenre(genreIds: genreIds)
|
|
async let albums = service.getAlbumsByGenre(genreIds: genreIds)
|
|
let (artistResults, albumResults) = try await (artists, albums)
|
|
|
|
var seen = Set<String>()
|
|
let allItems = (artistResults + albumResults)
|
|
.filter { seen.insert($0.uri).inserted }
|
|
.sorted { $0.name < $1.name }
|
|
return allItems
|
|
}
|
|
|
|
func getPodcastEpisodes(podcastUri: String) async throws -> [MAMediaItem] {
|
|
guard let service else { throw MAWebSocketClient.ClientError.notConnected }
|
|
logger.info("Loading episodes for podcast \(podcastUri)")
|
|
return try await service.getPodcastEpisodes(podcastUri: podcastUri)
|
|
}
|
|
|
|
// MARK: - Artist Albums & Tracks (not cached — fetched on demand)
|
|
|
|
func getArtistAlbums(artistUri: String) async throws -> [MAAlbum] {
|
|
guard let service else { throw MAWebSocketClient.ClientError.notConnected }
|
|
logger.info("Loading albums for artist \(artistUri)")
|
|
return try await service.getArtistAlbums(artistUri: artistUri)
|
|
}
|
|
|
|
func getAlbumTracks(albumUri: String) async throws -> [MAMediaItem] {
|
|
guard let service else { throw MAWebSocketClient.ClientError.notConnected }
|
|
logger.info("Loading tracks for album \(albumUri)")
|
|
return try await service.getAlbumTracks(albumUri: albumUri)
|
|
}
|
|
|
|
func getPlaylistTracks(playlistUri: String) async throws -> [MAMediaItem] {
|
|
guard let service else { throw MAWebSocketClient.ClientError.notConnected }
|
|
logger.info("Loading tracks for playlist \(playlistUri)")
|
|
return try await service.getPlaylistTracks(playlistUri: playlistUri)
|
|
}
|
|
|
|
// MARK: - Favorites
|
|
|
|
/// Returns whether the given URI is currently favorited.
|
|
func isFavorite(uri: String) -> Bool {
|
|
favoriteURIs.contains(uri)
|
|
}
|
|
|
|
/// Toggle favorite for any item. Performs optimistic update, then calls server.
|
|
/// Reverts on failure.
|
|
func toggleFavorite(uri: String, currentlyFavorite: Bool) async {
|
|
// Optimistic update
|
|
if currentlyFavorite {
|
|
favoriteURIs.remove(uri)
|
|
} else {
|
|
favoriteURIs.insert(uri)
|
|
}
|
|
|
|
// Call server
|
|
guard let service else {
|
|
// Revert if no service
|
|
if currentlyFavorite { favoriteURIs.insert(uri) } else { favoriteURIs.remove(uri) }
|
|
return
|
|
}
|
|
|
|
do {
|
|
if currentlyFavorite {
|
|
try await service.removeFavorite(uri: uri)
|
|
} else {
|
|
try await service.addFavorite(uri: uri)
|
|
}
|
|
} catch {
|
|
// Revert on failure
|
|
if currentlyFavorite {
|
|
favoriteURIs.insert(uri)
|
|
} else {
|
|
favoriteURIs.remove(uri)
|
|
}
|
|
logger.error("Failed to toggle favorite for \(uri): \(error)")
|
|
}
|
|
}
|
|
|
|
// MARK: - Search
|
|
|
|
func search(query: String, mediaTypes: [MediaType]? = nil) async throws -> [MAMediaItem] {
|
|
guard !query.isEmpty else { return [] }
|
|
guard let service else { throw MAWebSocketClient.ClientError.notConnected }
|
|
logger.info("Searching for '\(query)'")
|
|
return try await service.search(query: query, mediaTypes: mediaTypes)
|
|
}
|
|
}
|