// // 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] = [] // 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 /// 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 = [] // 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? // 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") } // 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) } 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 } 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 } 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: - 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) } }