import Foundation /// One music server the app reads from. The first one ever added has an empty [id], so its /// keys stay bare and everything saved before multi-server support (downloads, mixes, stem /// playlists) still matches. Every other server's keys carry "~" in front. struct MediaServer: Codable, Hashable, Identifiable { enum Kind: String, Codable { case plex, jellyfin } var id = "" var kind = Kind.plex var name = "" var url = "" var token = "" var user = "" /// Jellyfin user id; Plex leaves it empty. var userId = "" var machineId = "" var sections: [String] = [] var sectionTitles = "" var signedIn: Bool { !token.isEmpty } var ready: Bool { signedIn && !sections.isEmpty } var base: String { url.trimmingCharacters(in: CharacterSet(charactersIn: "/")) } var label: String { name.isEmpty ? (kind == .plex ? "Plex" : "Jellyfin") : name } var kindLabel: String { kind == .plex ? "Plex" : "Jellyfin" } static func newId() -> String { "s" + String(UUID().uuidString.prefix(6)).lowercased() } } /// What a single server can do. [PlexClient] pools these and tags their keys. protocol MediaSource: AnyObject, Sendable { var server: MediaServer { get } func artists(sort: String) async throws -> [Artist] func albums(sort: String) async throws -> [Album] func recentlyAdded(count: Int) async throws -> [Album] func recentlyPlayed(count: Int) async throws -> [Album] func recentlyAddedTracks(count: Int) async throws -> [Track] func randomAlbums(_ count: Int) async throws -> [Album] func tracksPage(start: Int, size: Int, sort: String) async throws -> ([Track], Int) func randomTracks(_ count: Int) async throws -> [Track] func album(_ key: String) async throws -> Album? func albumTracks(_ key: String) async throws -> [Track] func artistAlbums(_ key: String) async throws -> [Album] func artistTracks(_ key: String) async throws -> [Track] func searchTracks(_ title: String) async throws -> [Track] func loudness(_ trackKey: String, samples: Int) async throws -> [Float]? func search(_ query: String) async throws -> SearchResults func playlists(sort: String) async throws -> [Playlist] func playlistTracks(_ key: String) async throws -> [Track] func createPlaylist(_ title: String, trackKeys: [String]) async throws -> Playlist? func addToPlaylist(_ key: String, trackKeys: [String]) async throws func removeFromPlaylist(_ key: String, playlistItemId: Int64) async throws func movePlaylistItem(_ key: String, playlistItemId: Int64, afterItemId: Int64?) async throws func renamePlaylist(_ key: String, title: String) async throws func deletePlaylist(_ key: String) async throws func scrobble(_ trackKey: String) async func timeline(_ track: Track, state: String, positionMs: Int64) async func artUrl(_ thumb: String?, px: Int) -> URL? func partUrl(_ partKey: String) -> URL? /// The URL to stream a track's file from, after any setup the server needs for playback. func streamUrl(_ ratingKey: String, partKey: String) async -> URL? /// Music libraries on the server, for the picker in Settings. func musicSections() async throws -> [MusicSection] } // ---- key tagging ----------------------------------------------------------------- extension String { func tagged(_ id: String) -> String { id.isEmpty ? self : id + "~" + self } } extension Optional where Wrapped == String { func tagged(_ id: String) -> String? { map { $0.tagged(id) } } } extension Track { func tagged(_ id: String) -> Track { guard !id.isEmpty else { return self } var t = self t.ratingKey = ratingKey.tagged(id); t.albumKey = albumKey.tagged(id); t.artistKey = artistKey.tagged(id) t.thumb = thumb.tagged(id); t.partKey = partKey.tagged(id) return t } } extension Album { func tagged(_ id: String) -> Album { guard !id.isEmpty else { return self } var a = self a.ratingKey = ratingKey.tagged(id); a.artistKey = artistKey.tagged(id); a.thumb = thumb.tagged(id) return a } } extension Artist { func tagged(_ id: String) -> Artist { guard !id.isEmpty else { return self } var a = self a.ratingKey = ratingKey.tagged(id); a.thumb = thumb.tagged(id) return a } } extension Playlist { func tagged(_ id: String) -> Playlist { guard !id.isEmpty else { return self } var p = self p.ratingKey = ratingKey.tagged(id); p.thumb = thumb.tagged(id) return p } } extension SearchResults { func tagged(_ id: String) -> SearchResults { SearchResults(artists: artists.map { $0.tagged(id) }, albums: albums.map { $0.tagged(id) }, tracks: tracks.map { $0.tagged(id) }, playlists: playlists.map { $0.tagged(id) }) } }