From 99e4f3bd3aa55fd19dcfcef4004a2c6651fe3cf6 Mon Sep 17 00:00:00 2001 From: Dallas Groot Date: Fri, 10 Apr 2026 11:14:41 -0700 Subject: [PATCH] updates to companion from ios to server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Shared/Models/Models.swift | 112 ++++++++++++++++ Shared/Storage/LibraryCache.swift | 25 ++++ iOS/Data/SyncEngine.swift | 69 ++++++++-- iOS/Views/Common/AsyncCoverArt.swift | 23 +++- iOS/Views/Companion/CompanionAPIService.swift | 123 +++++++++++++++++- iOS/Views/Library/AlbumDetailView.swift | 67 +++++++++- 6 files changed, 398 insertions(+), 21 deletions(-) diff --git a/Shared/Models/Models.swift b/Shared/Models/Models.swift index 8614f3b..19f5028 100644 --- a/Shared/Models/Models.swift +++ b/Shared/Models/Models.swift @@ -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 +} diff --git a/Shared/Storage/LibraryCache.swift b/Shared/Storage/LibraryCache.swift index 0edc527..a805be9 100644 --- a/Shared/Storage/LibraryCache.swift +++ b/Shared/Storage/LibraryCache.swift @@ -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 diff --git a/iOS/Data/SyncEngine.swift b/iOS/Data/SyncEngine.swift index 669e2d9..fa9e517 100644 --- a/iOS/Data/SyncEngine.swift +++ b/iOS/Data/SyncEngine.swift @@ -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 } } diff --git a/iOS/Views/Common/AsyncCoverArt.swift b/iOS/Views/Common/AsyncCoverArt.swift index dc2cbb8..156a224 100644 --- a/iOS/Views/Common/AsyncCoverArt.swift +++ b/iOS/Views/Common/AsyncCoverArt.swift @@ -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) diff --git a/iOS/Views/Companion/CompanionAPIService.swift b/iOS/Views/Companion/CompanionAPIService.swift index 275216a..fbe00f1 100644 --- a/iOS/Views/Companion/CompanionAPIService.swift +++ b/iOS/Views/Companion/CompanionAPIService.swift @@ -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 diff --git a/iOS/Views/Library/AlbumDetailView.swift b/iOS/Views/Library/AlbumDetailView.swift index 15f7eeb..334ff3a 100644 --- a/iOS/Views/Library/AlbumDetailView.swift +++ b/iOS/Views/Library/AlbumDetailView.swift @@ -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