Updated API calls and Library View
This commit is contained in:
@@ -10,207 +10,234 @@ import OSLog
|
||||
|
||||
private let logger = Logger(subsystem: "com.musicassistant.mobile", category: "Library")
|
||||
|
||||
/// Manages library data and caching
|
||||
/// Manages library data with in-memory state and JSON disk cache.
|
||||
@Observable
|
||||
final class MALibraryManager {
|
||||
// MARK: - Properties
|
||||
|
||||
|
||||
private weak var service: MAService?
|
||||
|
||||
// Cache
|
||||
|
||||
// Published library data
|
||||
private(set) var artists: [MAArtist] = []
|
||||
private(set) var albums: [MAAlbum] = []
|
||||
private(set) var playlists: [MAPlaylist] = []
|
||||
|
||||
|
||||
// Pagination
|
||||
private var artistsOffset = 0
|
||||
private var albumsOffset = 0
|
||||
private var hasMoreArtists = true
|
||||
private var hasMoreAlbums = true
|
||||
|
||||
private let pageSize = 50
|
||||
|
||||
|
||||
// Loading states
|
||||
private(set) var isLoadingArtists = false
|
||||
private(set) var isLoadingAlbums = false
|
||||
private(set) var isLoadingPlaylists = false
|
||||
|
||||
|
||||
// Last refresh timestamps (persisted in UserDefaults)
|
||||
private(set) var lastArtistsRefresh: Date?
|
||||
private(set) var lastAlbumsRefresh: Date?
|
||||
private(set) var lastPlaylistsRefresh: Date?
|
||||
|
||||
// MARK: - Disk Cache
|
||||
|
||||
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
|
||||
loadFromDisk()
|
||||
}
|
||||
|
||||
|
||||
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: [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")
|
||||
}
|
||||
|
||||
let ud = UserDefaults.standard
|
||||
lastArtistsRefresh = ud.object(forKey: "lib.lastArtistsRefresh") as? Date
|
||||
lastAlbumsRefresh = ud.object(forKey: "lib.lastAlbumsRefresh") as? Date
|
||||
lastPlaylistsRefresh = ud.object(forKey: "lib.lastPlaylistsRefresh") 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)
|
||||
}
|
||||
|
||||
private func markRefreshed(_ key: String) -> Date {
|
||||
let now = Date()
|
||||
UserDefaults.standard.set(now, forKey: key)
|
||||
return now
|
||||
}
|
||||
|
||||
// MARK: - Artists
|
||||
|
||||
/// Load initial artists
|
||||
|
||||
func loadArtists(refresh: Bool = false) async throws {
|
||||
guard !isLoadingArtists else { return }
|
||||
guard let service else {
|
||||
throw MAWebSocketClient.ClientError.notConnected
|
||||
}
|
||||
|
||||
guard let service else { throw MAWebSocketClient.ClientError.notConnected }
|
||||
|
||||
if refresh {
|
||||
artistsOffset = 0
|
||||
hasMoreArtists = true
|
||||
await MainActor.run {
|
||||
self.artists = []
|
||||
}
|
||||
artists = []
|
||||
}
|
||||
|
||||
|
||||
guard hasMoreArtists else { return }
|
||||
|
||||
|
||||
isLoadingArtists = true
|
||||
defer { isLoadingArtists = false }
|
||||
|
||||
|
||||
logger.info("Loading artists (offset: \(self.artistsOffset))")
|
||||
|
||||
do {
|
||||
let newArtists = try await service.getArtists(
|
||||
limit: pageSize,
|
||||
offset: artistsOffset
|
||||
)
|
||||
|
||||
await MainActor.run {
|
||||
if refresh {
|
||||
self.artists = newArtists
|
||||
} else {
|
||||
self.artists.append(contentsOf: newArtists)
|
||||
}
|
||||
|
||||
self.artistsOffset += newArtists.count
|
||||
self.hasMoreArtists = newArtists.count >= self.pageSize
|
||||
|
||||
logger.info("Loaded \(newArtists.count) artists, total: \(self.artists.count)")
|
||||
}
|
||||
} catch {
|
||||
logger.error("Failed to load artists: \(error.localizedDescription)")
|
||||
throw error
|
||||
|
||||
let newArtists = try await service.getArtists(limit: pageSize, offset: artistsOffset)
|
||||
|
||||
if refresh {
|
||||
artists = newArtists
|
||||
} else {
|
||||
artists.append(contentsOf: newArtists)
|
||||
}
|
||||
|
||||
artistsOffset += newArtists.count
|
||||
hasMoreArtists = newArtists.count >= pageSize
|
||||
|
||||
// Persist to disk after a full load or first page of refresh
|
||||
if refresh || artistsOffset <= pageSize {
|
||||
save(artists, "artists.json")
|
||||
lastArtistsRefresh = markRefreshed("lib.lastArtistsRefresh")
|
||||
}
|
||||
|
||||
logger.info("Loaded \(newArtists.count) artists, total: \(self.artists.count)")
|
||||
}
|
||||
|
||||
/// Load more artists (pagination)
|
||||
|
||||
/// 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 itemIndex = artists.firstIndex(where: { $0.id == currentItem.id }),
|
||||
itemIndex >= thresholdIndex {
|
||||
if let idx = artists.firstIndex(where: { $0.id == currentItem.id }), idx >= thresholdIndex {
|
||||
try await loadArtists(refresh: false)
|
||||
save(artists, "artists.json")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Albums
|
||||
|
||||
/// Load initial albums
|
||||
|
||||
func loadAlbums(refresh: Bool = false) async throws {
|
||||
guard !isLoadingAlbums else { return }
|
||||
guard let service else {
|
||||
throw MAWebSocketClient.ClientError.notConnected
|
||||
}
|
||||
|
||||
guard let service else { throw MAWebSocketClient.ClientError.notConnected }
|
||||
|
||||
if refresh {
|
||||
albumsOffset = 0
|
||||
hasMoreAlbums = true
|
||||
await MainActor.run {
|
||||
self.albums = []
|
||||
}
|
||||
albums = []
|
||||
}
|
||||
|
||||
|
||||
guard hasMoreAlbums else { return }
|
||||
|
||||
|
||||
isLoadingAlbums = true
|
||||
defer { isLoadingAlbums = false }
|
||||
|
||||
|
||||
logger.info("Loading albums (offset: \(self.albumsOffset))")
|
||||
|
||||
do {
|
||||
let newAlbums = try await service.getAlbums(
|
||||
limit: pageSize,
|
||||
offset: albumsOffset
|
||||
)
|
||||
|
||||
await MainActor.run {
|
||||
if refresh {
|
||||
self.albums = newAlbums
|
||||
} else {
|
||||
self.albums.append(contentsOf: newAlbums)
|
||||
}
|
||||
|
||||
self.albumsOffset += newAlbums.count
|
||||
self.hasMoreAlbums = newAlbums.count >= self.pageSize
|
||||
|
||||
logger.info("Loaded \(newAlbums.count) albums, total: \(self.albums.count)")
|
||||
}
|
||||
} catch {
|
||||
logger.error("Failed to load albums: \(error.localizedDescription)")
|
||||
throw error
|
||||
|
||||
let newAlbums = try await service.getAlbums(limit: pageSize, offset: albumsOffset)
|
||||
|
||||
if refresh {
|
||||
albums = newAlbums
|
||||
} else {
|
||||
albums.append(contentsOf: newAlbums)
|
||||
}
|
||||
|
||||
albumsOffset += newAlbums.count
|
||||
hasMoreAlbums = newAlbums.count >= pageSize
|
||||
|
||||
if refresh || albumsOffset <= pageSize {
|
||||
save(albums, "albums.json")
|
||||
lastAlbumsRefresh = markRefreshed("lib.lastAlbumsRefresh")
|
||||
}
|
||||
|
||||
logger.info("Loaded \(newAlbums.count) albums, total: \(self.albums.count)")
|
||||
}
|
||||
|
||||
/// Load more albums (pagination)
|
||||
|
||||
func loadMoreAlbumsIfNeeded(currentItem: MAAlbum?) async throws {
|
||||
guard let currentItem else { return }
|
||||
|
||||
let thresholdIndex = albums.index(albums.endIndex, offsetBy: -10)
|
||||
if let itemIndex = albums.firstIndex(where: { $0.id == currentItem.id }),
|
||||
itemIndex >= thresholdIndex {
|
||||
if let idx = albums.firstIndex(where: { $0.id == currentItem.id }), idx >= thresholdIndex {
|
||||
try await loadAlbums(refresh: false)
|
||||
save(albums, "albums.json")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Playlists
|
||||
|
||||
/// Load playlists
|
||||
|
||||
func loadPlaylists(refresh: Bool = false) async throws {
|
||||
guard !isLoadingPlaylists else { return }
|
||||
guard let service else {
|
||||
throw MAWebSocketClient.ClientError.notConnected
|
||||
}
|
||||
|
||||
guard let service else { throw MAWebSocketClient.ClientError.notConnected }
|
||||
|
||||
isLoadingPlaylists = true
|
||||
defer { isLoadingPlaylists = false }
|
||||
|
||||
|
||||
logger.info("Loading playlists")
|
||||
|
||||
do {
|
||||
let loadedPlaylists = try await service.getPlaylists()
|
||||
|
||||
await MainActor.run {
|
||||
self.playlists = loadedPlaylists
|
||||
logger.info("Loaded \(loadedPlaylists.count) playlists")
|
||||
}
|
||||
} catch {
|
||||
logger.error("Failed to load playlists: \(error.localizedDescription)")
|
||||
throw error
|
||||
}
|
||||
|
||||
let loaded = try await service.getPlaylists()
|
||||
playlists = loaded
|
||||
save(playlists, "playlists.json")
|
||||
lastPlaylistsRefresh = markRefreshed("lib.lastPlaylistsRefresh")
|
||||
|
||||
logger.info("Loaded \(loaded.count) playlists")
|
||||
}
|
||||
|
||||
// MARK: - Album Tracks
|
||||
|
||||
/// Get tracks for an album
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
guard let service else { throw MAWebSocketClient.ClientError.notConnected }
|
||||
logger.info("Loading tracks for album \(albumUri)")
|
||||
return try await service.getAlbumTracks(albumUri: albumUri)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Search
|
||||
|
||||
/// Search library
|
||||
|
||||
func search(query: String, mediaTypes: [MediaType]? = nil) async throws -> [MAMediaItem] {
|
||||
guard !query.isEmpty else { return [] }
|
||||
guard let service else {
|
||||
throw MAWebSocketClient.ClientError.notConnected
|
||||
}
|
||||
|
||||
guard let service else { throw MAWebSocketClient.ClientError.notConnected }
|
||||
logger.info("Searching for '\(query)'")
|
||||
return try await service.search(query: query, mediaTypes: mediaTypes)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user