updates to companion from ios to server
Models.swift — CompanionSong, CompanionAlbum, CompanionArtist,
CompanionLibraryResponse. Each has a toSong()/toAlbum() converter.
Song.id = navidrome_id (streaming still works), Song.coverArt =
"companion:{id}" (routes cover art to Companion), Album.id =
"companion:{name}|{artist}" (detected by AlbumDetailView).
CompanionAPIService.swift — fetchAllSongs, fetchAlbumSongs,
fetchAllAlbums, fetchAllArtists, searchLibrary are all nonisolated
where needed. coverArtURL/artistPhotoURL are nonisolated so views can
call them synchronously. Push handler now fires
companionCoverArtUpdated and companionArtistPhotoUpdated events and
clears ImageCache immediately on receipt.
LibraryCache.swift — cacheCompanionAlbums/loadCompanionAlbums,
cacheCompanionAlbumSongs/loadCompanionAlbumSongs for local persistence
of companion data between launches.
SyncEngine.swift — syncCompanion() runs after every Navidrome sync
(bootstrap and delta) when Companion is enabled. It fetches albums
from /library/albums, converts them to Album objects, and overwrites
"all_albums" cache — so MyMusicView’s album grid immediately shows
Companion-sorted data. companionLibraryChanged notification triggers
reactive UI updates.
AsyncCoverArt.swift — Detects "companion:{id}" prefix and routes to
CompanionAPIService().coverArtURL(companionId:) instead of Navidrome.
All existing non-companion cover art paths are unchanged.
AlbumDetailView.swift — Detects albumId.hasPrefix("companion:"),
parses "companion:{name}|{artist}", calls
/library/songs?album=...&album_artist=... on Companion, and builds a
synthetic AlbumWithSongs locally. Caches results so the next tap is
instant. Falls back to the existing Navidrome path for all
non-companion album IDs.
This commit is contained in:
parent
9c93be200e
commit
99e4f3bd3a
6 changed files with 398 additions and 21 deletions
|
|
@ -317,3 +317,115 @@ struct RadioStation: Codable, Identifiable {
|
|||
struct SimilarSongsContainer: Codable {
|
||||
let song: [Song]?
|
||||
}
|
||||
|
||||
// MARK: - Companion Library Models
|
||||
// These map the Companion API /library/* responses.
|
||||
// They convert to standard Song/Album objects using navidrome_id for stream URLs.
|
||||
|
||||
struct CompanionSong: Codable, Identifiable {
|
||||
let id: String // Companion MD5 song ID
|
||||
let navidrome_id: String? // Subsonic song ID — used for stream URL
|
||||
let title: String
|
||||
let artist: String
|
||||
let album: String
|
||||
let album_artist: String
|
||||
let genre: String
|
||||
let year: Int?
|
||||
let track_number: Int?
|
||||
let disc_number: Int?
|
||||
let duration: Double?
|
||||
let relative_path: String
|
||||
let cover_art_url: String? // "/library/cover-art/{id}" path
|
||||
let sort_album: String?
|
||||
let sort_artist: String?
|
||||
let date_added: String?
|
||||
|
||||
/// Convert to the Song type the rest of the app uses.
|
||||
/// Song.id = navidrome_id so stream URLs still work.
|
||||
/// Song.coverArt = "companion:{id}" so AsyncCoverArt routes to Companion.
|
||||
func toSong() -> Song {
|
||||
Song(
|
||||
id: navidrome_id ?? id,
|
||||
parent: nil,
|
||||
isDir: false,
|
||||
title: title,
|
||||
album: album,
|
||||
artist: artist,
|
||||
track: track_number,
|
||||
year: year,
|
||||
genre: genre.isEmpty ? nil : genre,
|
||||
coverArt: "companion:\(id)",
|
||||
size: nil,
|
||||
contentType: nil,
|
||||
suffix: (relative_path as NSString).pathExtension.lowercased(),
|
||||
transcodedContentType: nil,
|
||||
transcodedSuffix: nil,
|
||||
duration: duration.map { Int($0) },
|
||||
bitRate: nil,
|
||||
path: relative_path,
|
||||
playCount: nil,
|
||||
discNumber: disc_number,
|
||||
created: date_added,
|
||||
albumId: nil,
|
||||
artistId: nil,
|
||||
type: "music",
|
||||
starred: nil,
|
||||
bpm: nil,
|
||||
musicBrainzId: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct CompanionAlbum: Codable, Identifiable {
|
||||
let album: String
|
||||
let album_artist: String
|
||||
let sort_album: String?
|
||||
let sort_album_artist: String?
|
||||
let year: Int?
|
||||
let track_count: Int
|
||||
let cover_art_url: String? // "/library/cover-art/{rep_id}" path
|
||||
|
||||
/// Stable unique identifier: "companion:{album}|{album_artist}"
|
||||
var id: String { "companion:\(album)|\(album_artist)" }
|
||||
|
||||
func toAlbum() -> Album {
|
||||
Album(
|
||||
id: id,
|
||||
name: album,
|
||||
artist: album_artist,
|
||||
artistId: nil,
|
||||
coverArt: cover_art_url.map { extractCompanionId($0) }.map { "companion:\($0)" },
|
||||
songCount: track_count,
|
||||
duration: nil,
|
||||
playCount: nil,
|
||||
created: nil,
|
||||
starred: nil,
|
||||
year: year,
|
||||
genre: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct CompanionArtist: Codable, Identifiable {
|
||||
let artist: String
|
||||
let sort_artist: String?
|
||||
let track_count: Int
|
||||
let album_count: Int
|
||||
let photo_url: String?
|
||||
|
||||
var id: String { artist }
|
||||
}
|
||||
|
||||
struct CompanionLibraryResponse: Codable {
|
||||
let total: Int
|
||||
let page: Int?
|
||||
let per_page: Int?
|
||||
let songs: [CompanionSong]?
|
||||
let albums: [CompanionAlbum]?
|
||||
let artists: [CompanionArtist]?
|
||||
}
|
||||
|
||||
/// Extract the ID portion from a companion path like "/library/cover-art/{id}"
|
||||
private func extractCompanionId(_ path: String) -> String {
|
||||
path.components(separatedBy: "/").last ?? path
|
||||
}
|
||||
|
|
|
|||
|
|
@ -128,6 +128,31 @@ class LibraryCache: ObservableObject {
|
|||
func loadRadioStations() -> [RadioStation]? {
|
||||
load([RadioStation].self, key: "radio_stations")
|
||||
}
|
||||
|
||||
// MARK: - Companion Library Cache
|
||||
|
||||
/// Cache companion albums (authoritative sorted list from Companion API).
|
||||
func cacheCompanionAlbums(_ albums: [CompanionAlbum]) {
|
||||
save(albums, key: "companion_albums")
|
||||
}
|
||||
func loadCompanionAlbums() -> [CompanionAlbum]? {
|
||||
load([CompanionAlbum].self, key: "companion_albums")
|
||||
}
|
||||
|
||||
/// Cache companion songs (all songs, used for search + album detail).
|
||||
func cacheCompanionAlbumSongs(_ songs: [CompanionSong], albumId: String) {
|
||||
let safeKey = "companion_album_\(albumId.replacingOccurrences(of: ":", with: "_").replacingOccurrences(of: "|", with: "_").replacingOccurrences(of: " ", with: "_"))"
|
||||
save(songs, key: safeKey)
|
||||
}
|
||||
func loadCompanionAlbumSongs(albumId: String) -> [CompanionSong]? {
|
||||
let safeKey = "companion_album_\(albumId.replacingOccurrences(of: ":", with: "_").replacingOccurrences(of: "|", with: "_").replacingOccurrences(of: " ", with: "_"))"
|
||||
return load([CompanionSong].self, key: safeKey)
|
||||
}
|
||||
|
||||
/// Whether the library data came from Companion API (affects sort order, cover art).
|
||||
var hasCompanionLibrary: Bool {
|
||||
loadCompanionAlbums() != nil
|
||||
}
|
||||
|
||||
// MARK: - Download Status Check
|
||||
|
||||
|
|
|
|||
|
|
@ -36,16 +36,23 @@ class SyncEngine: ObservableObject {
|
|||
if let ts = UserDefaults.standard.object(forKey: "sync_last_date") as? Date {
|
||||
lastSyncDate = ts
|
||||
}
|
||||
// Companion push events → immediate delta sync so new uploads/edits appear instantly
|
||||
// Companion push events → immediate re-sync so edits/uploads appear instantly
|
||||
NotificationCenter.default.publisher(for: .companionMetadataUpdated)
|
||||
.merge(with: NotificationCenter.default.publisher(for: .companionTrackUploaded))
|
||||
.merge(with: NotificationCenter.default.publisher(for: .companionLibraryChanged))
|
||||
.receive(on: DispatchQueue.main)
|
||||
.debounce(for: .seconds(2), scheduler: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.lastSyncTimestamp = 0 // force full re-fetch of changed items
|
||||
self?.lastSyncTimestamp = 0
|
||||
self?.syncIfNeeded()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Cover art updated — clear image cache so new art shows immediately
|
||||
NotificationCenter.default.publisher(for: .companionCoverArtUpdated)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { _ in ImageCache.shared.clearAll() }
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
|
@ -156,11 +163,14 @@ class SyncEngine: ObservableObject {
|
|||
|
||||
lastSyncTimestamp = Int64(Date().timeIntervalSince1970)
|
||||
DebugLogger.shared.log("Bootstrap complete: \(artistCount) artists, \(allAlbums.count) albums, \(genres.count) genres", category: "Sync")
|
||||
|
||||
|
||||
// 7. Pre-cache album details in background (so tapping any album is instant)
|
||||
Task.detached(priority: .utility) { [weak self] in
|
||||
await self?.preCacheAlbumDetails(allAlbums)
|
||||
}
|
||||
|
||||
// 8. Companion sync — overwrites album list with properly sorted data
|
||||
syncCompanion()
|
||||
}
|
||||
|
||||
// MARK: - Delta Sync
|
||||
|
|
@ -212,9 +222,12 @@ class SyncEngine: ObservableObject {
|
|||
|
||||
let genres = try await client.getGenres()
|
||||
cache.save(genres, key: "genres")
|
||||
|
||||
|
||||
lastSyncTimestamp = Int64(Date().timeIntervalSince1970)
|
||||
DebugLogger.shared.log("Delta sync complete", category: "Sync")
|
||||
|
||||
// Companion sync — keeps album sort order in sync after any library change
|
||||
syncCompanion()
|
||||
}
|
||||
|
||||
// MARK: - Periodic
|
||||
|
|
@ -259,9 +272,9 @@ class SyncEngine: ObservableObject {
|
|||
|
||||
DebugLogger.shared.log("Album detail pre-cache: \(cached) new, \(skipped) already cached", category: "Sync")
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Server Change
|
||||
|
||||
|
||||
/// Call when the active server changes — invalidates sync state so next sync is a full bootstrap
|
||||
func serverChanged() {
|
||||
lastSyncTimestamp = 0
|
||||
|
|
@ -269,9 +282,49 @@ class SyncEngine: ObservableObject {
|
|||
cache.clearAll()
|
||||
DebugLogger.shared.log("Server changed — cache cleared, will re-bootstrap", category: "Sync")
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Companion Sync (Phase 1)
|
||||
|
||||
/// Kick off a Companion library sync in the background after Navidrome sync completes.
|
||||
func syncCompanion() {
|
||||
guard CompanionSettings.shared.isEnabled else { return }
|
||||
Task(priority: .utility) { [weak self] in
|
||||
do {
|
||||
try await self?.companionSync()
|
||||
} catch {
|
||||
DebugLogger.shared.log("Companion sync failed: \(error.localizedDescription)",
|
||||
category: "Companion")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func companionSync() async throws {
|
||||
let service = CompanionAPIService()
|
||||
// Skip silently if Companion is unreachable
|
||||
guard (try? await service.healthCheck()) == true else {
|
||||
DebugLogger.shared.log("Companion unreachable — skipping library sync", category: "Companion")
|
||||
return
|
||||
}
|
||||
await setProgress("Syncing Companion library...")
|
||||
DebugLogger.shared.log("Companion sync started", category: "Companion")
|
||||
|
||||
// Fetch albums with correct sort order from Companion
|
||||
let companionAlbums = try await service.fetchAllAlbums()
|
||||
cache.cacheCompanionAlbums(companionAlbums)
|
||||
|
||||
// Overwrite standard album cache with Companion-sorted data so
|
||||
// MyMusicView and search results immediately reflect correct ordering
|
||||
let standardAlbums = companionAlbums.map { $0.toAlbum() }
|
||||
cache.save(standardAlbums, key: "all_albums")
|
||||
|
||||
DebugLogger.shared.log("Companion sync: \(companionAlbums.count) albums", category: "Companion")
|
||||
await MainActor.run {
|
||||
NotificationCenter.default.post(name: .companionLibraryChanged, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
|
||||
private func setProgress(_ msg: String) async {
|
||||
await MainActor.run { self.syncProgress = msg }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -337,18 +337,30 @@ struct CachedAsyncImage: View {
|
|||
struct AsyncCoverArt: View {
|
||||
let coverArtId: String?
|
||||
let size: Int
|
||||
|
||||
|
||||
@ObservedObject private var albumCoverStore = AlbumCoverStore.shared
|
||||
|
||||
|
||||
var body: some View {
|
||||
if let id = coverArtId {
|
||||
// Check for user-set custom cover first
|
||||
// Custom user-set cover takes priority everywhere
|
||||
if let customImage = albumCoverStore.loadCover(for: id) {
|
||||
let _ = albumCoverStore.updateTrigger // reactive dependency
|
||||
let _ = albumCoverStore.updateTrigger
|
||||
Image(uiImage: customImage)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.clipped()
|
||||
} else if id.hasPrefix("companion:") {
|
||||
// Route to Companion API cover art endpoint
|
||||
let companionId = String(id.dropFirst("companion:".count))
|
||||
if let url = CompanionAPIService().coverArtURL(companionId: companionId) {
|
||||
ZStack {
|
||||
placeholderView
|
||||
CachedAsyncImage(url: url)
|
||||
}
|
||||
.clipped()
|
||||
} else {
|
||||
placeholderView
|
||||
}
|
||||
} else if let url = ServerManager.shared.client.coverArtURL(id: id, size: size) {
|
||||
ZStack {
|
||||
placeholderView
|
||||
|
|
@ -362,7 +374,7 @@ struct AsyncCoverArt: View {
|
|||
placeholderView
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private var placeholderView: some View {
|
||||
ZStack {
|
||||
Rectangle()
|
||||
|
|
@ -373,7 +385,6 @@ struct AsyncCoverArt: View {
|
|||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
|
||||
Image(systemName: "music.note")
|
||||
.font(.system(size: max(12, CGFloat(size) * 0.15)))
|
||||
.foregroundColor(.gray)
|
||||
|
|
|
|||
|
|
@ -522,12 +522,19 @@ class CompanionPushClient: ObservableObject {
|
|||
|
||||
switch msg.event {
|
||||
case "metadata_updated":
|
||||
// Notify the app to refresh library
|
||||
NotificationCenter.default.post(name: .companionMetadataUpdated, object: nil, userInfo: msg.data)
|
||||
case "track_uploaded":
|
||||
case "track_uploaded", "tracks_uploaded":
|
||||
NotificationCenter.default.post(name: .companionTrackUploaded, object: nil, userInfo: msg.data)
|
||||
case "batch_metadata_updated":
|
||||
NotificationCenter.default.post(name: .companionMetadataUpdated, object: nil, userInfo: msg.data)
|
||||
case "cover_art_updated":
|
||||
// Clear image cache so updated cover art loads immediately
|
||||
ImageCache.shared.clearAll()
|
||||
NotificationCenter.default.post(name: .companionCoverArtUpdated, object: nil, userInfo: msg.data)
|
||||
case "artist_photo_updated":
|
||||
ImageCache.shared.clearAll()
|
||||
NotificationCenter.default.post(name: .companionArtistPhotoUpdated, object: nil, userInfo: msg.data)
|
||||
case "profile":
|
||||
// Cache the profile locally
|
||||
if let path = msg.data?["path"] as? String,
|
||||
let jsonData = try? JSONSerialization.data(withJSONObject: msg.data ?? [:]),
|
||||
let profile = try? JSONDecoder().decode(SmartDJProfile.self, from: jsonData) {
|
||||
|
|
@ -578,8 +585,114 @@ struct PushMessage: Codable {
|
|||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let companionMetadataUpdated = Notification.Name("companionMetadataUpdated")
|
||||
static let companionTrackUploaded = Notification.Name("companionTrackUploaded")
|
||||
static let companionMetadataUpdated = Notification.Name("companionMetadataUpdated")
|
||||
static let companionTrackUploaded = Notification.Name("companionTrackUploaded")
|
||||
static let companionCoverArtUpdated = Notification.Name("companionCoverArtUpdated")
|
||||
static let companionArtistPhotoUpdated = Notification.Name("companionArtistPhotoUpdated")
|
||||
static let companionLibraryChanged = Notification.Name("companionLibraryChanged")
|
||||
}
|
||||
|
||||
// MARK: - Library Fetch (Phase 1)
|
||||
|
||||
extension CompanionAPIService {
|
||||
|
||||
/// Fetch all songs, paginating until complete.
|
||||
func fetchAllSongs(sort: String = "sort_album,disc_number,track_number") async throws -> [CompanionSong] {
|
||||
let base = try baseURL()
|
||||
var all: [CompanionSong] = []
|
||||
var page = 0
|
||||
let perPage = 500
|
||||
while true {
|
||||
guard var comps = URLComponents(url: base.appendingPathComponent("library/songs"),
|
||||
resolvingAgainstBaseURL: false) else { break }
|
||||
comps.queryItems = [
|
||||
URLQueryItem(name: "page", value: "\(page)"),
|
||||
URLQueryItem(name: "per_page", value: "\(perPage)"),
|
||||
URLQueryItem(name: "sort", value: sort),
|
||||
]
|
||||
guard let url = comps.url else { break }
|
||||
let (data, response) = try await session.data(from: url)
|
||||
try validateResponse(response)
|
||||
let result = try JSONDecoder().decode(CompanionLibraryResponse.self, from: data)
|
||||
let batch = result.songs ?? []
|
||||
all.append(contentsOf: batch)
|
||||
if batch.count < perPage { break }
|
||||
page += 1
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
/// Fetch songs for a specific album (used by AlbumDetailView).
|
||||
func fetchAlbumSongs(album: String, albumArtist: String) async throws -> [CompanionSong] {
|
||||
let base = try baseURL()
|
||||
guard var comps = URLComponents(url: base.appendingPathComponent("library/songs"),
|
||||
resolvingAgainstBaseURL: false) else {
|
||||
throw CompanionError.invalidURL
|
||||
}
|
||||
comps.queryItems = [
|
||||
URLQueryItem(name: "album", value: album),
|
||||
URLQueryItem(name: "album_artist", value: albumArtist),
|
||||
URLQueryItem(name: "sort", value: "disc_number,track_number"),
|
||||
URLQueryItem(name: "per_page", value: "500"),
|
||||
]
|
||||
guard let url = comps.url else { throw CompanionError.invalidURL }
|
||||
let (data, response) = try await session.data(from: url)
|
||||
try validateResponse(response)
|
||||
let result = try JSONDecoder().decode(CompanionLibraryResponse.self, from: data)
|
||||
return result.songs ?? []
|
||||
}
|
||||
|
||||
/// Fetch all albums.
|
||||
func fetchAllAlbums() async throws -> [CompanionAlbum] {
|
||||
let base = try baseURL()
|
||||
let url = base.appendingPathComponent("library/albums")
|
||||
let (data, response) = try await session.data(from: url)
|
||||
try validateResponse(response)
|
||||
let result = try JSONDecoder().decode(CompanionLibraryResponse.self, from: data)
|
||||
return result.albums ?? []
|
||||
}
|
||||
|
||||
/// Fetch all artists.
|
||||
func fetchAllArtists() async throws -> [CompanionArtist] {
|
||||
let base = try baseURL()
|
||||
let url = base.appendingPathComponent("library/artists")
|
||||
let (data, response) = try await session.data(from: url)
|
||||
try validateResponse(response)
|
||||
let result = try JSONDecoder().decode(CompanionLibraryResponse.self, from: data)
|
||||
return result.artists ?? []
|
||||
}
|
||||
|
||||
/// Full-text search across title/artist/album/genre.
|
||||
func searchLibrary(query: String, limit: Int = 50) async throws -> [CompanionSong] {
|
||||
let base = try baseURL()
|
||||
guard var comps = URLComponents(url: base.appendingPathComponent("library/search"),
|
||||
resolvingAgainstBaseURL: false) else {
|
||||
throw CompanionError.invalidURL
|
||||
}
|
||||
comps.queryItems = [
|
||||
URLQueryItem(name: "q", value: query),
|
||||
URLQueryItem(name: "limit", value: "\(limit)"),
|
||||
]
|
||||
guard let url = comps.url else { throw CompanionError.invalidURL }
|
||||
let (data, response) = try await session.data(from: url)
|
||||
try validateResponse(response)
|
||||
let result = try JSONDecoder().decode(CompanionLibraryResponse.self, from: data)
|
||||
return result.songs ?? []
|
||||
}
|
||||
|
||||
/// Build a cover art URL for a companion song ID.
|
||||
nonisolated func coverArtURL(companionId: String) -> URL? {
|
||||
CompanionSettings.shared.baseURL?
|
||||
.appendingPathComponent("library/cover-art/\(companionId)")
|
||||
}
|
||||
|
||||
/// Build an artist photo URL for an artist name.
|
||||
nonisolated func artistPhotoURL(artistName: String) -> URL? {
|
||||
guard let encoded = artistName.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)
|
||||
else { return nil }
|
||||
return CompanionSettings.shared.baseURL?
|
||||
.appendingPathComponent("library/artist-photo/\(encoded)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
|
|
|||
|
|
@ -451,13 +451,19 @@ struct AlbumDetailView: View {
|
|||
private func loadAlbum() async {
|
||||
let cache = LibraryCache.shared
|
||||
loadFailed = false
|
||||
|
||||
|
||||
// Companion album IDs are prefixed "companion:{name}|{artist}"
|
||||
if albumId.hasPrefix("companion:") {
|
||||
await loadCompanionAlbum()
|
||||
return
|
||||
}
|
||||
|
||||
// 1. Show cached version instantly — no spinner if we have data
|
||||
if album == nil, let cached = cache.loadAlbumDetail(id: albumId) {
|
||||
album = cached
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
|
||||
// 2. Fetch from server in background
|
||||
do {
|
||||
if let result = try await serverManager.client.getAlbum(id: albumId) {
|
||||
|
|
@ -479,6 +485,63 @@ struct AlbumDetailView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load album detail from Companion API for companion-sourced albums.
|
||||
/// "companion:{albumName}|{albumArtist}" → fetch songs via /library/songs?album=...
|
||||
private func loadCompanionAlbum() async {
|
||||
// Parse "companion:{name}|{artist}" from albumId
|
||||
let payload = String(albumId.dropFirst("companion:".count))
|
||||
let parts = payload.components(separatedBy: "|")
|
||||
let albumName = parts.first ?? payload
|
||||
let albumArtist = parts.count > 1 ? parts[1] : ""
|
||||
|
||||
// Check local cache first
|
||||
if let cached = LibraryCache.shared.loadCompanionAlbumSongs(albumId: albumId) {
|
||||
let builtAlbum = buildAlbumWithSongs(name: albumName, artist: albumArtist, songs: cached)
|
||||
await MainActor.run {
|
||||
self.album = builtAlbum
|
||||
self.isLoading = false
|
||||
}
|
||||
// Still refresh in background
|
||||
}
|
||||
|
||||
do {
|
||||
let companionSongs = try await CompanionAPIService()
|
||||
.fetchAlbumSongs(album: albumName, albumArtist: albumArtist)
|
||||
LibraryCache.shared.cacheCompanionAlbumSongs(companionSongs, albumId: albumId)
|
||||
let builtAlbum = buildAlbumWithSongs(name: albumName, artist: albumArtist, songs: companionSongs)
|
||||
await MainActor.run {
|
||||
self.album = builtAlbum
|
||||
self.isLoading = false
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
if self.album == nil { self.loadFailed = true }
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func buildAlbumWithSongs(name: String, artist: String, songs: [CompanionSong]) -> AlbumWithSongs {
|
||||
let stdSongs = songs.map { $0.toSong() }
|
||||
let coverArt = songs.first.map { "companion:\($0.id)" }
|
||||
let totalDuration = stdSongs.compactMap { $0.duration }.reduce(0, +)
|
||||
return AlbumWithSongs(
|
||||
id: albumId,
|
||||
name: name,
|
||||
artist: artist,
|
||||
artistId: nil,
|
||||
coverArt: coverArt,
|
||||
songCount: songs.count,
|
||||
duration: totalDuration > 0 ? totalDuration : nil,
|
||||
playCount: nil,
|
||||
created: nil,
|
||||
starred: nil,
|
||||
year: songs.first?.year,
|
||||
genre: songs.first?.genre,
|
||||
song: stdSongs
|
||||
)
|
||||
}
|
||||
|
||||
private func retryAlbumLoad() async {
|
||||
// Wait for server connection to establish
|
||||
|
|
|
|||
Loading…
Reference in a new issue