Der zweite Server. Eigenes Target neben PlexKit, nicht darin — die beiden teilen die neutrale Schicht und sonst nichts. Die Anmeldung geht zuerst über **Quick Connect**: die App zeigt einen sechsstelligen Code, den man in einem bereits angemeldeten Client bestätigt. Damit gilt hier dieselbe Zusage wie bei Plex — das Passwort erreicht Flixy nie. Benutzername und Passwort bleiben als Rückfall, falls der Server Quick Connect abgeschaltet hat; dann hält die App das Passwort für die Dauer einer Anfrage und speichert es nirgends. Zwei Stellen, an denen sich eine Übersetzung verrechnet, und die deshalb Tests haben: Jellyfin zählt Zeiten in Ticks zu hundert Nanosekunden. Eine Null zu viel, und aus 44 Minuten werden siebeneinhalb Stunden. Und es meldet die **ungesehenen** Folgen, nicht die gesehenen. Ungeprüft übernommen stünde bei einer fast fertigen Serie „9 von 24" statt „15 von 24". Dass die Adressen den Token tragen, steht hier von Anfang an — bei Plex hat genau das tagelang gekostet, weil AVPlayer keine Kopfzeilen mitschickt. Zwei Fähigkeiten sind ausdrücklich nicht da: Sterne-Bewertungen kennt Jellyfin nicht, nur Daumen hoch und runter. Und die Adresse einer verkleinerten Fassung gibt es erst nach einer Anfrage — sie zu erfinden hieße, eine Adresse anzubieten, die nicht antwortet. Die Startseite ist nachsichtig gebaut: fällt „Als Nächstes" aus, bleibt deshalb nicht die ganze Seite leer. Die App bietet Jellyfin noch nicht an — das ist der nächste Schritt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
153 lines
5.6 KiB
Swift
153 lines
5.6 KiB
Swift
import Foundation
|
|
import MediaKit
|
|
|
|
/// Ein Eintrag, wie Jellyfin ihn über die Leitung schickt.
|
|
///
|
|
/// Die Felder heißen groß und die Zeiten sind Ticks zu hundert Nanosekunden —
|
|
/// beides bleibt hier unten. Nach oben geht nur `MediaItem`.
|
|
public struct JellyfinItem: Decodable, Sendable {
|
|
public let id: String
|
|
public let name: String?
|
|
public let type: String?
|
|
public let overview: String?
|
|
public let productionYear: Int?
|
|
/// Laufzeit in Ticks zu 100 ns.
|
|
public let runTimeTicks: Int64?
|
|
public let indexNumber: Int?
|
|
public let parentIndexNumber: Int?
|
|
public let seriesName: String?
|
|
public let seriesID: String?
|
|
public let seasonID: String?
|
|
public let seasonName: String?
|
|
public let officialRating: String?
|
|
public let communityRating: Double?
|
|
public let criticRating: Double?
|
|
public let dateCreated: Date?
|
|
public let childCount: Int?
|
|
public let recursiveItemCount: Int?
|
|
public let genres: [String]?
|
|
public let people: [Person]?
|
|
public let userData: UserData?
|
|
public let imageTags: [String: String]?
|
|
public let backdropImageTags: [String]?
|
|
public let parentBackdropItemID: String?
|
|
public let seriesPrimaryImageTag: String?
|
|
public let mediaSources: [JellyfinMediaSource]?
|
|
public let collectionType: String?
|
|
|
|
public struct Person: Decodable, Sendable {
|
|
public let id: String?
|
|
public let name: String?
|
|
public let role: String?
|
|
public let type: String?
|
|
public let primaryImageTag: String?
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case id = "Id", name = "Name", role = "Role", type = "Type"
|
|
case primaryImageTag = "PrimaryImageTag"
|
|
}
|
|
}
|
|
|
|
public struct UserData: Decodable, Sendable {
|
|
public let playbackPositionTicks: Int64?
|
|
public let playCount: Int?
|
|
public let played: Bool?
|
|
public let isFavorite: Bool?
|
|
public let unplayedItemCount: Int?
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case playbackPositionTicks = "PlaybackPositionTicks"
|
|
case playCount = "PlayCount"
|
|
case played = "Played"
|
|
case isFavorite = "IsFavorite"
|
|
case unplayedItemCount = "UnplayedItemCount"
|
|
}
|
|
}
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case id = "Id", name = "Name", type = "Type", overview = "Overview"
|
|
case productionYear = "ProductionYear", runTimeTicks = "RunTimeTicks"
|
|
case indexNumber = "IndexNumber", parentIndexNumber = "ParentIndexNumber"
|
|
case seriesName = "SeriesName", seriesID = "SeriesId"
|
|
case seasonID = "SeasonId", seasonName = "SeasonName"
|
|
case officialRating = "OfficialRating", communityRating = "CommunityRating"
|
|
case criticRating = "CriticRating", dateCreated = "DateCreated"
|
|
case childCount = "ChildCount", recursiveItemCount = "RecursiveItemCount"
|
|
case genres = "Genres", people = "People", userData = "UserData"
|
|
case imageTags = "ImageTags", backdropImageTags = "BackdropImageTags"
|
|
case parentBackdropItemID = "ParentBackdropItemId"
|
|
case seriesPrimaryImageTag = "SeriesPrimaryImageTag"
|
|
case mediaSources = "MediaSources", collectionType = "CollectionType"
|
|
}
|
|
}
|
|
|
|
/// Eine Fassung, wie Jellyfin sie meldet.
|
|
public struct JellyfinMediaSource: Decodable, Sendable {
|
|
public let id: String?
|
|
public let container: String?
|
|
public let size: Int64?
|
|
public let bitrate: Int?
|
|
public let runTimeTicks: Int64?
|
|
public let mediaStreams: [JellyfinMediaStream]?
|
|
public let supportsDirectPlay: Bool?
|
|
public let supportsDirectStream: Bool?
|
|
public let supportsTranscoding: Bool?
|
|
public let transcodingURL: String?
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case id = "Id", container = "Container", size = "Size", bitrate = "Bitrate"
|
|
case runTimeTicks = "RunTimeTicks", mediaStreams = "MediaStreams"
|
|
case supportsDirectPlay = "SupportsDirectPlay"
|
|
case supportsDirectStream = "SupportsDirectStream"
|
|
case supportsTranscoding = "SupportsTranscoding"
|
|
case transcodingURL = "TranscodingUrl"
|
|
}
|
|
}
|
|
|
|
/// Eine Spur, wie Jellyfin sie meldet.
|
|
public struct JellyfinMediaStream: Decodable, Sendable {
|
|
public let index: Int?
|
|
public let type: String?
|
|
public let codec: String?
|
|
public let language: String?
|
|
public let displayTitle: String?
|
|
public let channels: Int?
|
|
public let isDefault: Bool?
|
|
public let isForced: Bool?
|
|
public let height: Int?
|
|
public let width: Int?
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case index = "Index", type = "Type", codec = "Codec", language = "Language"
|
|
case displayTitle = "DisplayTitle", channels = "Channels"
|
|
case isDefault = "IsDefault", isForced = "IsForced"
|
|
case height = "Height", width = "Width"
|
|
}
|
|
}
|
|
|
|
/// Die Hülle, in der Jellyfin Listen schickt.
|
|
public struct JellyfinItemPage: Decodable, Sendable {
|
|
public let items: [JellyfinItem]
|
|
public let totalRecordCount: Int?
|
|
public let startIndex: Int?
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case items = "Items"
|
|
case totalRecordCount = "TotalRecordCount"
|
|
case startIndex = "StartIndex"
|
|
}
|
|
}
|
|
|
|
extension Int64 {
|
|
/// Ticks zu hundert Nanosekunden in Millisekunden.
|
|
///
|
|
/// Jellyfins Einheit. Zehntausend Ticks sind eine Millisekunde — eine
|
|
/// Zahl, die man beim Lesen zweimal prüft und deshalb genau einmal
|
|
/// aufschreibt.
|
|
var millisecondsFromTicks: Int { Int(self / 10_000) }
|
|
}
|
|
|
|
extension Int {
|
|
var ticksFromMilliseconds: Int64 { Int64(self) * 10_000 }
|
|
}
|