466 lines
12 KiB
Swift
466 lines
12 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 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?
|
|
let playQueue: PlayQueue?
|
|
let shares: SharesContainer?
|
|
}
|
|
|
|
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 albumArtist: 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 albumArtist: 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 albumArtist: 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: - Play Queue (cross-device resume)
|
|
struct PlayQueueContainer: Codable {
|
|
let playQueue: PlayQueue?
|
|
}
|
|
struct PlayQueue: Codable {
|
|
let entry: [Song]?
|
|
let current: String? // song ID of current track
|
|
let position: Int64? // position in ms
|
|
let changed: String? // ISO timestamp of last save
|
|
let changedBy: String? // client that saved
|
|
let username: String?
|
|
}
|
|
|
|
// MARK: - Shares (public links)
|
|
struct SharesContainer: Codable {
|
|
let share: [ShareItem]?
|
|
}
|
|
struct ShareItem: Codable, Identifiable {
|
|
let id: String
|
|
let url: String?
|
|
let description: String?
|
|
let username: String?
|
|
let created: String?
|
|
let expires: String?
|
|
let lastVisited: String?
|
|
let visitCount: Int?
|
|
let entry: [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,
|
|
albumArtist: album_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,
|
|
albumArtist: album_artist,
|
|
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
|
|
}
|