451 lines
20 KiB
Swift
451 lines
20 KiB
Swift
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<JItem>](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.0\""
|
|
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<JFolder>].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<String>()
|
|
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))
|
|
}
|
|
|
|
func streamUrl(_ ratingKey: String, partKey: String) async -> URL? { partUrl(partKey) }
|
|
|
|
/// 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: ""))
|
|
}
|
|
}
|