import Foundation import UIKit /// A Jellyfin item, read leniently like PlexItem. struct JItem: Decodable { var id = "" var name = "" var type = "" var sortName: String? var album: String? var albumId: String? var albumArtist: String? var albumArtistId: String? var artistId: String? var artist: String? var index: Int? var disc: Int? var ticks: Int64? var year: Int? var premiere: String? var path: String? var size: Int64? var container: String? var primaryTag: String? var albumPrimaryTag: String? var childCount: Int? var genre: String? var mediaType: String? var playlistItemId: String? var collectionType: String? init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: AnyKey.self) id = c.str("Id") ?? "" name = c.str("Name") ?? "" type = c.str("Type") ?? "" sortName = c.str("SortName") album = c.str("Album") albumId = c.str("AlbumId") albumArtist = c.str("AlbumArtist") let albumArtists: [JRef] = c.list("AlbumArtists") let artists: [JRef] = c.list("ArtistItems") albumArtistId = albumArtists.first?.id artistId = artists.first?.id ?? albumArtistId artist = artists.first?.name index = c.int("IndexNumber") disc = c.int("ParentIndexNumber") ticks = c.int64("RunTimeTicks") year = c.int("ProductionYear") premiere = c.str("PremiereDate") path = c.str("Path") let sources: [JSource] = c.list("MediaSources") size = sources.first?.size container = sources.first?.container ?? c.str("Container") if path == nil { path = sources.first?.path } primaryTag = (try? c.decodeIfPresent([String: String].self, forKey: AnyKey("ImageTags")))?["Primary"] albumPrimaryTag = c.str("AlbumPrimaryImageTag") childCount = c.int("ChildCount") ?? c.int("SongCount") genre = (try? c.decodeIfPresent([String].self, forKey: AnyKey("Genres")))?.first mediaType = c.str("MediaType") playlistItemId = c.str("PlaylistItemId") collectionType = c.str("CollectionType") } var durationMs: Int64 { (ticks ?? 0) / 10_000 } var thumb: String? { primaryTag != nil ? id : albumPrimaryTag != nil ? albumId : nil } func toTrack(_ position: Int? = nil) -> Track { Track(ratingKey: id, title: name, artist: artist ?? albumArtist ?? "Unknown Artist", album: album ?? "", albumKey: albumId, artistKey: albumArtistId ?? artistId, index: index, disc: disc, durationMs: durationMs, thumb: thumb, partKey: id, partId: stableId(id), partFile: path, partSize: size, codec: container, year: year, playlistItemId: position.map(Int64.init)) } func toAlbum() -> Album { Album(ratingKey: id, title: name, artist: albumArtist ?? "Unknown Artist", artistKey: albumArtistId, year: year, thumb: thumb, trackCount: childCount, genre: genre, released: premiere.map { String($0.prefix(10)) }) } func toArtist() -> Artist { Artist(ratingKey: id, title: name, sortTitle: sortName ?? name, thumb: thumb) } func toPlaylist() -> Playlist { Playlist(ratingKey: id, title: name, thumb: thumb, trackCount: childCount ?? 0, durationMs: durationMs) } } /// FNV-1a, so the cache key for a Jellyfin file is the same on every launch. private func stableId(_ s: String) -> Int64 { var h: UInt64 = 0xcbf29ce484222325 for b in s.utf8 { h = (h ^ UInt64(b)) &* 0x100000001b3 } return Int64(bitPattern: h & 0x7FFF_FFFF_FFFF_FFFF) } private struct JRef: Decodable { var id = "" var name = "" init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: AnyKey.self) id = c.str("Id") ?? "" name = c.str("Name") ?? "" } } private struct JSource: Decodable { var size: Int64? var container: String? var path: String? init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: AnyKey.self) size = c.int64("Size") container = c.str("Container") path = c.str("Path") } } private struct JList: Decodable { var items: [JItem] = [] var total = 0 init(from decoder: Decoder) throws { if let arr = try? [Lenient](from: decoder) { items = arr.compactMap(\.value) total = items.count return } let c = try decoder.container(keyedBy: AnyKey.self) items = c.list("Items") total = c.int("TotalRecordCount") ?? items.count } } private struct JFolder: Decodable { var id = "" var locations: [String] = [] init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: AnyKey.self) id = c.str("ItemId") ?? "" locations = (try? c.decodeIfPresent([String].self, forKey: AnyKey("Locations"))) ?? [] } } struct JellyfinLogin { var token: String var userId: String var user: String var serverId: String } /// One Jellyfin server. Tracks keep the file Path so stem matching works the same as on Plex. final class JellyfinClient: MediaSource, @unchecked Sendable { private static let page = 1000 private static let fields = "Path,MediaSources,SortName,Genres,PremiereDate,ChildCount,ProductionYear" private let http: Http private let settings: Settings let server: MediaServer init(http: Http, settings: Settings, server: MediaServer) { self.http = http self.settings = settings self.server = server } private var base: String { server.base } private var uid: String { server.userId } private func request(_ url: URL, method: String = "GET", token: String? = nil) -> URLRequest { var r = URLRequest(url: url) r.httpMethod = method r.setValue("application/json", forHTTPHeaderField: "Accept") let t = token ?? server.token var auth = "MediaBrowser Client=\"\(PlexClient.product)\", Device=\"iPhone\", DeviceId=\"\(settings.clientId)\", Version=\"1.1\"" if !t.isEmpty { auth += ", Token=\"\(t)\"" } r.setValue(auth, forHTTPHeaderField: "Authorization") return r } private func url(_ path: String, _ params: [(String, Any?)] = []) throws -> URL { guard let u = URL(string: base + path) else { throw PlainError("Bad Jellyfin address") } return u.with(params) } private func get(_ path: String, _ params: [(String, Any?)] = []) async throws -> JList { guard server.signedIn else { throw NotSignedIn() } return try JSONDecoder().decode(JList.self, from: try await http.data(request(try url(path, params)))) } @discardableResult private func send(_ method: String, _ path: String, _ params: [(String, Any?)] = [], body: [String: Any]? = nil) async throws -> Data { guard server.signedIn else { throw NotSignedIn() } var req = request(try url(path, params), method: method) if let body { req.httpBody = try JSONSerialization.data(withJSONObject: body) req.setValue("application/json", forHTTPHeaderField: "Content-Type") } else if method != "DELETE" { req.httpBody = Data() } return try await http.data(req) } /// Plex sort keys ("addedAt:desc", "artist.titleSort,year") to Jellyfin's SortBy and SortOrder. private func sortParams(_ sort: String, tracks: Bool = false) -> [(String, Any?)] { let desc = sort.contains(":desc") let first = sort.split(separator: ",").first.map { String($0).replacingOccurrences(of: ":desc", with: "") } ?? "" let by: String = switch first { case "addedAt", "updatedAt": "DateCreated" case "lastViewedAt": "DatePlayed" case "viewCount": "PlayCount" case "userRating": "CommunityRating" case "year": "ProductionYear,SortName" case "originallyAvailableAt": "PremiereDate,SortName" case "duration": "Runtime" case "random": "Random" case "artist.titleSort": tracks ? "AlbumArtist,Album,ParentIndexNumber,IndexNumber" : "AlbumArtist,ProductionYear" case "album.titleSort": "Album,ParentIndexNumber,IndexNumber" default: "SortName" } return [("SortBy", by), ("SortOrder", desc ? "Descending" : "Ascending")] } private func items(_ type: String, _ params: [(String, Any?)], parent: String? = nil, start: Int? = nil, limit: Int? = nil) async throws -> JList { try await get("/Items", [("userId", uid), ("IncludeItemTypes", type), ("Recursive", "true"), ("Fields", Self.fields), ("ParentId", parent), ("StartIndex", start), ("Limit", limit)] + params) } /// Every chosen library's items, paged. private func pooled(_ type: String, _ params: [(String, Any?)]) async throws -> [JItem] { var out: [JItem] = [] for sec in server.sections { var start = 0 while true { let page = try await items(type, params, parent: sec, start: start, limit: Self.page) out += page.items start += page.items.count if page.items.isEmpty || start >= page.total { break } } } return out } private func first(_ type: String, _ params: [(String, Any?)], count: Int) async throws -> [JItem] { var out: [JItem] = [] for sec in server.sections { out += try await items(type, params, parent: sec, limit: count).items } return out } // ---- sign-in -------------------------------------------------------------------- func login(user: String, password: String) async throws -> JellyfinLogin { var req = request(try url("/Users/AuthenticateByName"), method: "POST", token: "") req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = try JSONSerialization.data(withJSONObject: ["Username": user, "Pw": password]) let data = try await http.data(req) guard let o = try JSONSerialization.jsonObject(with: data) as? [String: Any], let t = o["AccessToken"] as? String, let u = o["User"] as? [String: Any], let id = u["Id"] as? String else { throw PlainError("Jellyfin didn't accept that sign-in") } return JellyfinLogin(token: t, userId: id, user: u["Name"] as? String ?? user, serverId: o["ServerId"] as? String ?? "") } /// An API key from the dashboard has no user, so take the first user (admins only see that list). func loginWithKey(_ key: String) async throws -> JellyfinLogin { let data = try await http.data(request(try url("/Users"), token: key)) guard let list = try JSONSerialization.jsonObject(with: data) as? [[String: Any]], let u = list.first, let id = u["Id"] as? String else { throw PlainError("That API key didn't work on this server") } return JellyfinLogin(token: key, userId: id, user: u["Name"] as? String ?? "Jellyfin user", serverId: u["ServerId"] as? String ?? "") } func musicSections() async throws -> [MusicSection] { let all: [JItem] do { all = try await get("/UserViews", [("userId", uid)]).items } catch { all = try await get("/Users/\(uid)/Views").items } let views = all.filter { $0.collectionType == "music" } // Folder paths need an admin; without them the picker just shows names. var paths: [String: [String]] = [:] if let data = try? await http.data(request(try url("/Library/VirtualFolders"))), let folders = try? JSONDecoder().decode([Lenient].self, from: data) { for f in folders.compactMap(\.value) { paths[f.id] = f.locations } } return views.map { MusicSection(key: $0.id, title: $0.name, locations: paths[$0.id] ?? []) } } // ---- library ---------------------------------------------------------------------- func artists(sort: String) async throws -> [Artist] { var out: [Artist] = [] for sec in server.sections { out += try await get("/Artists/AlbumArtists", [("userId", uid), ("ParentId", sec), ("Fields", "SortName")] + sortParams(sort)) .items.map { $0.toArtist() } } return out } func albums(sort: String) async throws -> [Album] { try await pooled("MusicAlbum", sortParams(sort)).map { $0.toAlbum() } } func recentlyAdded(count: Int) async throws -> [Album] { try await first("MusicAlbum", sortParams("addedAt:desc"), count: count).map { $0.toAlbum() } } /// Albums of the songs played last, newest first. func recentlyPlayed(count: Int) async throws -> [Album] { let played = try await first("Audio", sortParams("lastViewedAt:desc") + [("Filters", "IsPlayed")], count: count * 4) var seen = Set() let ids = played.compactMap(\.albumId).filter { seen.insert($0).inserted }.prefix(count) guard !ids.isEmpty else { return [] } let list = try await get("/Items", [("userId", uid), ("Ids", ids.joined(separator: ",")), ("Fields", Self.fields)]).items return ids.compactMap { id in list.first { $0.id == id }?.toAlbum() } } func recentlyAddedTracks(count: Int) async throws -> [Track] { try await first("Audio", sortParams("addedAt:desc"), count: count).map { $0.toTrack() } } func randomAlbums(_ count: Int) async throws -> [Album] { Array(try await first("MusicAlbum", sortParams("random"), count: count).map { $0.toAlbum() }.shuffled().prefix(count)) } func tracksPage(start: Int, size: Int, sort: String) async throws -> ([Track], Int) { var out: [Track] = [] var offset = 0, total = 0 for sec in server.sections { let count = try await items("Audio", [], parent: sec, limit: 0).total let from = max(start + out.count - offset, 0) if out.count < size, from < count { out += try await items("Audio", sortParams(sort, tracks: true), parent: sec, start: from, limit: size - out.count).items.map { $0.toTrack() } } offset += count; total += count } return (out, total) } func randomTracks(_ count: Int) async throws -> [Track] { let each = count / max(server.sections.count, 1) + 1 return Array(try await first("Audio", sortParams("random"), count: each).map { $0.toTrack() }.shuffled().prefix(count)) } func album(_ key: String) async throws -> Album? { try await get("/Items", [("userId", uid), ("Ids", key), ("Fields", Self.fields)]).items.first?.toAlbum() } func albumTracks(_ key: String) async throws -> [Track] { try await items("Audio", [("SortBy", "ParentIndexNumber,IndexNumber,SortName")], parent: key).items.map { $0.toTrack() } } func artistAlbums(_ key: String) async throws -> [Album] { try await items("MusicAlbum", [("AlbumArtistIds", key), ("SortBy", "ProductionYear,SortName")]).items.map { $0.toAlbum() } } func artistTracks(_ key: String) async throws -> [Track] { try await items("Audio", [("ArtistIds", key), ("SortBy", "Album,ParentIndexNumber,IndexNumber")]).items.map { $0.toTrack() } } func searchTracks(_ title: String) async throws -> [Track] { try await items("Audio", [("searchTerm", title), ("Limit", 50)]).items.map { $0.toTrack() } } func loudness(_ trackKey: String, samples: Int) async throws -> [Float]? { nil } func search(_ query: String) async throws -> SearchResults { async let ar = get("/Artists", [("userId", uid), ("searchTerm", query), ("Limit", 12)]) async let al = items("MusicAlbum", [("searchTerm", query)], limit: 12) async let tr = items("Audio", [("searchTerm", query)], limit: 12) async let pl = items("Playlist", [("searchTerm", query)], limit: 12) return SearchResults(artists: try await ar.items.map { $0.toArtist() }, albums: try await al.items.map { $0.toAlbum() }, tracks: try await tr.items.map { $0.toTrack() }, playlists: try await pl.items.filter { $0.mediaType != "Video" }.map { $0.toPlaylist() }) } // ---- playlists ---------------------------------------------------------------------- func playlists(sort: String) async throws -> [Playlist] { try await items("Playlist", sortParams(sort)).items.filter { $0.mediaType != "Video" }.map { $0.toPlaylist() } } private func entries(_ key: String) async throws -> [JItem] { try await get("/Playlists/\(key)/Items", [("userId", uid), ("Fields", Self.fields)]).items } /// Jellyfin's entry ids are strings, so a track's playlistItemId is its position. func playlistTracks(_ key: String) async throws -> [Track] { try await entries(key).enumerated().map { $1.toTrack($0) } } func createPlaylist(_ title: String, trackKeys: [String]) async throws -> Playlist? { let data = try await send("POST", "/Playlists", body: ["Name": title, "Ids": trackKeys, "UserId": uid, "MediaType": "Audio"]) guard let o = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let id = o["Id"] as? String else { return nil } return Playlist(ratingKey: id, title: title, trackCount: trackKeys.count) } func addToPlaylist(_ key: String, trackKeys: [String]) async throws { try await send("POST", "/Playlists/\(key)/Items", [("ids", trackKeys.joined(separator: ",")), ("userId", uid)]) } private func entryId(_ key: String, _ position: Int64) async throws -> String { let list = try await entries(key) guard position >= 0, position < list.count, let id = list[Int(position)].playlistItemId else { throw PlainError("That song moved; refresh the playlist") } return id } func removeFromPlaylist(_ key: String, playlistItemId: Int64) async throws { let id = try await entryId(key, playlistItemId) try await send("DELETE", "/Playlists/\(key)/Items", [("entryIds", id)]) } func movePlaylistItem(_ key: String, playlistItemId: Int64, afterItemId: Int64?) async throws { let id = try await entryId(key, playlistItemId) // The new index counts the list without the moved song. let to = afterItemId.map { $0 < playlistItemId ? $0 + 1 : $0 } ?? 0 try await send("POST", "/Playlists/\(key)/Items/\(id)/Move/\(to)") } func renamePlaylist(_ key: String, title: String) async throws { try await send("POST", "/Playlists/\(key)", body: ["Name": title]) } func deletePlaylist(_ key: String) async throws { try await send("DELETE", "/Items/\(key)") } // ---- playback reporting ---------------------------------------------------------------- func scrobble(_ trackKey: String) async { if (try? await send("POST", "/UserPlayedItems/\(trackKey)", [("userId", uid)])) == nil { _ = try? await send("POST", "/Users/\(uid)/PlayedItems/\(trackKey)") } } func timeline(_ track: Track, state: String, positionMs: Int64) async { let path = state == "stopped" ? "/Sessions/Playing/Stopped" : "/Sessions/Playing/Progress" _ = try? await send("POST", path, body: ["ItemId": track.ratingKey, "PositionTicks": positionMs * 10_000, "IsPaused": state == "paused", "CanSeek": true]) } // ---- URLs --------------------------------------------------------------------------- func artUrl(_ thumb: String?, px: Int) -> URL? { guard let thumb, !thumb.isEmpty, server.signedIn, let u = URL(string: base + "/Items/\(thumb)/Images/Primary") else { return nil } return u.with([("fillWidth", px), ("fillHeight", px), ("quality", 90), ("api_key", server.token)]) } /// The original file, untouched, like Plex's part URL. func partUrl(_ partKey: String) -> URL? { URL(string: base + "/Audio/\(partKey)/stream?static=true&api_key=" + urlEncode(server.token)) } /// True if [url] answers as a Jellyfin server. func probe() async -> Bool { guard let u = URL(string: base + "/System/Info/Public") else { return false } return await http.ok(request(u, token: "")) } }