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:
Dallas Groot 2026-04-10 11:14:41 -07:00
parent 9c93be200e
commit 99e4f3bd3a
6 changed files with 398 additions and 21 deletions

View file

@ -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
}

View file

@ -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

View file

@ -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 }
}

View file

@ -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)

View file

@ -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

View file

@ -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