Files
MobileMusicAssistant/Mobile Music Assistant/ServicesMALibraryManager.swift
T
2026-04-05 19:44:30 +02:00

267 lines
9.6 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 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<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
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)
}
}