// // 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 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 /// Increment this whenever the model format changes to invalidate stale caches. private static let cacheVersion = 2 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.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: [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(_ 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(_ 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 func loadArtists(refresh: Bool = false) async throws { guard !isLoadingArtists else { return } guard let service else { throw MAWebSocketClient.ClientError.notConnected } // For refresh, reset pagination counters but keep existing data visible until new data arrives let fetchOffset = refresh ? 0 : artistsOffset if refresh { hasMoreArtists = true } guard hasMoreArtists else { return } isLoadingArtists = true defer { isLoadingArtists = false } logger.info("Loading artists (offset: \(fetchOffset), refresh: \(refresh))") let newArtists = try await service.getArtists(limit: pageSize, offset: fetchOffset) // DEBUG: log first artist's image state so we can trace artwork loading if let a = newArtists.first { logger.debug("DEBUG Artist[0] name=\(a.name) metadata=\(String(describing: a.metadata)) imageUrl=\(a.imageUrl ?? "nil") imageProvider=\(a.imageProvider ?? "nil")") } // Replace or append atomically — no intermediate empty state if refresh { artists = newArtists artistsOffset = newArtists.count } else { artists.append(contentsOf: newArtists) artistsOffset += newArtists.count } hasMoreArtists = newArtists.count >= pageSize if refresh || 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: - Albums func loadAlbums(refresh: Bool = false) async throws { guard !isLoadingAlbums else { return } guard let service else { throw MAWebSocketClient.ClientError.notConnected } let fetchOffset = refresh ? 0 : albumsOffset if refresh { hasMoreAlbums = true } guard hasMoreAlbums else { return } isLoadingAlbums = true defer { isLoadingAlbums = false } logger.info("Loading albums (offset: \(fetchOffset), refresh: \(refresh))") let newAlbums = try await service.getAlbums(limit: pageSize, offset: fetchOffset) if refresh { albums = newAlbums albumsOffset = newAlbums.count } 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)") } 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: - 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) } // 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) } }