NavidromeApp/Shared/Models/Models.swift
Dallas Groot 99e4f3bd3a 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.
2026-04-10 11:14:41 -07:00

431 lines
11 KiB
Swift

import Foundation
// MARK: - Server Configuration
struct ServerConfig: Codable, Identifiable, Hashable {
var id: UUID = UUID()
var name: String
var url: String
var username: String
var password: String // stored in Keychain in production
var isActive: Bool = true
var baseURL: URL? { URL(string: url) }
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
// MARK: - Subsonic API Response Wrappers
struct SubsonicResponse: Codable {
let subsonicResponse: SubsonicResponseBody
enum CodingKeys: String, CodingKey {
case subsonicResponse = "subsonic-response"
}
}
struct SubsonicResponseBody: Codable {
let status: String
let version: String?
let type: String?
let serverVersion: String?
let openSubsonic: Bool?
let error: SubsonicError?
// Various response types
let musicFolders: MusicFoldersContainer?
let indexes: IndexesContainer?
let artists: ArtistsContainer?
let artist: ArtistWithAlbums?
let album: AlbumWithSongs?
let song: Song?
let directory: DirectoryContainer?
let searchResult3: SearchResult3?
let albumList2: AlbumList2Container?
let playlists: PlaylistsContainer?
let playlist: PlaylistWithSongs?
let genres: GenresContainer?
let starred2: Starred2Container?
let nowPlaying: NowPlayingContainer?
let randomSongs: RandomSongsContainer?
let scanStatus: ScanStatus?
let lyrics: LyricsResult?
let internetRadioStations: InternetRadioContainer?
let similarSongs2: SimilarSongsContainer?
}
struct SubsonicError: Codable {
let code: Int
let message: String
}
// MARK: - Music Folders
struct MusicFoldersContainer: Codable {
let musicFolder: [MusicFolder]?
}
struct MusicFolder: Codable, Identifiable {
let id: String
let name: String?
}
// MARK: - Indexes / Artists
struct IndexesContainer: Codable {
let index: [ArtistIndex]?
let lastModified: Int64?
let ignoredArticles: String?
}
struct ArtistsContainer: Codable {
let index: [ArtistIndex]?
let ignoredArticles: String?
}
struct ArtistIndex: Codable, Identifiable {
let name: String
let artist: [Artist]?
var id: String { name }
}
struct Artist: Codable, Identifiable {
let id: String
let name: String
let coverArt: String?
let artistImageUrl: String?
let albumCount: Int?
let starred: String?
let musicBrainzId: String?
let sortName: String?
}
// MARK: - Albums
struct ArtistWithAlbums: Codable, Identifiable {
let id: String
let name: String
let coverArt: String?
let albumCount: Int?
let album: [Album]?
}
struct Album: Codable, Identifiable {
let id: String
let name: String
let artist: String?
let artistId: String?
let coverArt: String?
let songCount: Int?
let duration: Int?
let playCount: Int?
let created: String?
let starred: String?
let year: Int?
let genre: String?
}
struct AlbumWithSongs: Codable, Identifiable {
let id: String
let name: String
let artist: String?
let artistId: String?
let coverArt: String?
let songCount: Int?
let duration: Int?
let playCount: Int?
let created: String?
let starred: String?
let year: Int?
let genre: String?
let song: [Song]?
}
struct AlbumList2Container: Codable {
let album: [Album]?
}
// MARK: - Songs
struct Song: Codable, Identifiable {
let id: String
let parent: String?
let isDir: Bool?
let title: String
let album: String?
let artist: String?
let track: Int?
let year: Int?
let genre: String?
let coverArt: String?
let size: Int64?
let contentType: String?
let suffix: String?
let transcodedContentType: String?
let transcodedSuffix: String?
let duration: Int?
let bitRate: Int?
let path: String?
let playCount: Int?
let discNumber: Int?
let created: String?
let albumId: String?
let artistId: String?
let type: String?
let starred: String?
let bpm: Int?
let musicBrainzId: String?
var durationFormatted: String {
guard let dur = duration else { return "--:--" }
let min = dur / 60
let sec = dur % 60
return String(format: "%d:%02d", min, sec)
}
}
// MARK: - Directory
struct DirectoryContainer: Codable {
let id: String
let name: String?
let parent: String?
let child: [Song]?
}
// MARK: - Search
struct SearchResult3: Codable {
let artist: [Artist]?
let album: [Album]?
let song: [Song]?
}
// MARK: - Playlists
struct PlaylistsContainer: Codable {
let playlist: [Playlist]?
}
struct Playlist: Codable, Identifiable {
let id: String
let name: String
let comment: String?
let songCount: Int?
let duration: Int?
let coverArt: String?
let owner: String?
let `public`: Bool?
let created: String?
let changed: String?
}
struct PlaylistWithSongs: Codable, Identifiable {
let id: String
let name: String
let comment: String?
let songCount: Int?
let duration: Int?
let coverArt: String?
let owner: String?
let `public`: Bool?
let created: String?
let changed: String?
var entry: [Song]? // var PlaylistDetailView mutates this for optimistic reorder
}
// MARK: - Genres
struct GenresContainer: Codable {
let genre: [Genre]?
}
struct Genre: Codable, Identifiable {
let value: String
let songCount: Int?
let albumCount: Int?
var id: String { value }
enum CodingKeys: String, CodingKey {
case value, songCount, albumCount
}
}
// MARK: - Starred
struct Starred2Container: Codable {
let artist: [Artist]?
let album: [Album]?
let song: [Song]?
}
// MARK: - Now Playing
struct NowPlayingContainer: Codable {
let entry: [NowPlayingEntry]?
}
struct NowPlayingEntry: Codable, Identifiable {
let id: String
let title: String
let artist: String?
let album: String?
let username: String?
let minutesAgo: Int?
let playerId: Int?
let playerName: String?
}
// MARK: - Random Songs
struct RandomSongsContainer: Codable {
let song: [Song]?
}
// MARK: - Scan Status
struct ScanStatus: Codable {
let scanning: Bool
let count: Int?
}
// MARK: - Lyrics
struct LyricsResult: Codable {
let artist: String?
let title: String?
let value: String?
}
// MARK: - Offline Download
struct DownloadedSong: Codable, Identifiable {
let id: String
let serverId: UUID
let song: Song
let localPath: String
let downloadDate: Date
let fileSize: Int64
}
// MARK: - Internet Radio
struct InternetRadioContainer: Codable {
let internetRadioStation: [RadioStation]?
}
struct RadioStation: Codable, Identifiable {
let id: String
let name: String
let streamUrl: String
let homePageUrl: String?
enum CodingKeys: String, CodingKey {
case id, name, streamUrl, homePageUrl
}
}
// MARK: - Similar Songs
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
}