Phase 1: Anmeldung, Bibliothek, Detailseiten, Hybrid-Player
Die Datenschicht: - plex.tv-Anmeldung über das PIN-Verfahren, Token im Schlüsselbund - Serversuche und Verbindungsauswahl nach Rang (lokal vor direkt vor Vermittlung), mit Abgleich der Server-Kennung gegen veraltete plex.direct-Adressen - PlexServer als einzige Stelle, an der die App Endpunkte kennt: Mediatheken, Hubs, seitenweises Blättern mit Filtern, Metadaten, Staffeln/Folgen, Suche Die Oberfläche: - Startseite mit Aufmacher und den Reihen vom Server - Mediathek als Raster oder Liste, mit Filter- und Sortierblatt - Detailseiten für Filme, Folgen, Serien und Staffeln inkl. Besetzung, Fassungsauswahl und aufklappbaren technischen Angaben - Suche mit gemerkten Anfragen - Vollbildplayer mit eigenem Chrome Der Player: - AVPlayerEngine und VLCPlayerEngine hinter einem gemeinsamen Protokoll - VLCKit liegt in einem eigenen Package (FlixyVLC), damit `swift test` in FlixyKit nicht das 920-MB-Binärpaket mitzieht Am echten plex.tv geprüft: der Anmeldecode kommt an. Dabei fiel auf, dass `strong=true` einen dreißigstelligen Code liefert, der sich bei plex.tv/link nicht eintippen lässt — jetzt der kurze. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import Foundation
|
||||
|
||||
/// Filter und Sortierung einer Mediatheksansicht.
|
||||
///
|
||||
/// Wird zu Abfrageparametern für Plex — die Filterung passiert also auf dem
|
||||
/// Server. Bei zehntausend Titeln wäre alles andere aussichtslos: die Liste
|
||||
/// erst vollständig zu laden, um sie dann lokal zu durchsuchen, dauert
|
||||
/// Minuten und belegt entsprechend Speicher.
|
||||
public struct LibraryQuery: Sendable, Equatable {
|
||||
public enum Sort: String, Sendable, Equatable, CaseIterable {
|
||||
case titleAscending = "titleSort:asc"
|
||||
case titleDescending = "titleSort:desc"
|
||||
case recentlyAdded = "addedAt:desc"
|
||||
case releaseDate = "originallyAvailableAt:desc"
|
||||
case rating = "rating:desc"
|
||||
case lastViewed = "lastViewedAt:desc"
|
||||
}
|
||||
|
||||
public var sort: Sort
|
||||
public var unwatchedOnly: Bool
|
||||
public var genre: String?
|
||||
public var year: Int?
|
||||
public var contentRating: String?
|
||||
public var resolution: String?
|
||||
|
||||
public init(
|
||||
sort: Sort = .titleAscending,
|
||||
unwatchedOnly: Bool = false,
|
||||
genre: String? = nil,
|
||||
year: Int? = nil,
|
||||
contentRating: String? = nil,
|
||||
resolution: String? = nil
|
||||
) {
|
||||
self.sort = sort
|
||||
self.unwatchedOnly = unwatchedOnly
|
||||
self.genre = genre
|
||||
self.year = year
|
||||
self.contentRating = contentRating
|
||||
self.resolution = resolution
|
||||
}
|
||||
|
||||
/// Ob überhaupt gefiltert wird — steuert, ob die Oberfläche den
|
||||
/// Filterknopf hervorhebt.
|
||||
public var isFiltering: Bool {
|
||||
unwatchedOnly || genre != nil || year != nil || contentRating != nil || resolution != nil
|
||||
}
|
||||
|
||||
func queryItems(libraryKind: PlexLibrary.Kind?) -> [URLQueryItem] {
|
||||
var items: [URLQueryItem] = [URLQueryItem(name: "sort", value: sort.rawValue)]
|
||||
|
||||
// Plex nummeriert die Arten: 1 Film, 2 Serie, 4 Folge. Ohne diese
|
||||
// Angabe liefert eine Serienmediathek alle Folgen einzeln statt der
|
||||
// Serien.
|
||||
switch libraryKind {
|
||||
case .movie: items.append(URLQueryItem(name: "type", value: "1"))
|
||||
case .show: items.append(URLQueryItem(name: "type", value: "2"))
|
||||
default: break
|
||||
}
|
||||
|
||||
if unwatchedOnly { items.append(URLQueryItem(name: "unwatched", value: "1")) }
|
||||
if let genre { items.append(URLQueryItem(name: "genre", value: genre)) }
|
||||
if let year { items.append(URLQueryItem(name: "year", value: String(year))) }
|
||||
if let contentRating { items.append(URLQueryItem(name: "contentRating", value: contentRating)) }
|
||||
if let resolution { items.append(URLQueryItem(name: "resolution", value: resolution)) }
|
||||
|
||||
return items
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Ausschnitt aus einer längeren Liste.
|
||||
public struct PlexPage<Element: Sendable>: Sendable {
|
||||
public let items: [Element]
|
||||
public let offset: Int
|
||||
/// Wie viele Einträge es insgesamt gibt.
|
||||
public let totalSize: Int
|
||||
|
||||
public init(items: [Element], offset: Int, totalSize: Int) {
|
||||
self.items = items
|
||||
self.offset = offset
|
||||
self.totalSize = totalSize
|
||||
}
|
||||
|
||||
public var hasMore: Bool { offset + items.count < totalSize }
|
||||
public var nextOffset: Int { offset + items.count }
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import Foundation
|
||||
|
||||
/// Ein verbundener Plex-Server.
|
||||
///
|
||||
/// Die eine Stelle, an der die App mit ihrem Server spricht. Die Views kennen
|
||||
/// weder Pfade noch Abfrageparameter — sie rufen Methoden auf.
|
||||
public actor PlexServer {
|
||||
public let resource: PlexResource
|
||||
public let connection: PlexConnection
|
||||
fileprivate let client: PlexHTTPClient
|
||||
private let token: String
|
||||
|
||||
public init?(
|
||||
resource: PlexResource,
|
||||
connection: PlexConnection,
|
||||
identity: PlexClientIdentity,
|
||||
transport: any PlexTransport = URLSession.shared
|
||||
) {
|
||||
guard let baseURL = connection.url, let token = resource.accessToken else { return nil }
|
||||
self.resource = resource
|
||||
self.connection = connection
|
||||
self.token = token
|
||||
self.client = PlexHTTPClient(
|
||||
baseURL: baseURL,
|
||||
identity: identity,
|
||||
token: token,
|
||||
transport: transport
|
||||
)
|
||||
}
|
||||
|
||||
public nonisolated var name: String { resource.name }
|
||||
|
||||
// MARK: - Mediatheken
|
||||
|
||||
public func libraries() async throws(PlexError) -> [PlexLibrary] {
|
||||
let container = try await client.send(
|
||||
PlexRequest(path: "/library/sections"),
|
||||
as: PlexMediaContainer<PlexLibrary>.self
|
||||
)
|
||||
return container.content
|
||||
}
|
||||
|
||||
// MARK: - Startseite
|
||||
|
||||
/// Die fertig zusammengestellten Reihen für die Startseite.
|
||||
public func homeHubs() async throws(PlexError) -> [PlexHub] {
|
||||
let container = try await client.send(
|
||||
PlexRequest(path: "/hubs", query: [
|
||||
// Leere Reihen wären auf der Startseite nur Überschriften ohne
|
||||
// Inhalt.
|
||||
URLQueryItem(name: "includeEmpty", value: "0"),
|
||||
URLQueryItem(name: "count", value: "20"),
|
||||
]),
|
||||
as: PlexMediaContainer<PlexHub>.self
|
||||
)
|
||||
return container.content.filter { !$0.items.isEmpty }
|
||||
}
|
||||
|
||||
/// Was als Nächstes dran ist: angefangene Filme und die nächste ungesehene
|
||||
/// Folge je Serie.
|
||||
public func continueWatching() async throws(PlexError) -> [PlexItem] {
|
||||
let container = try await client.send(
|
||||
PlexRequest(path: "/hubs/continueWatching/items"),
|
||||
as: PlexMediaContainer<PlexItem>.self
|
||||
)
|
||||
return container.content
|
||||
}
|
||||
|
||||
// MARK: - Blättern
|
||||
|
||||
public func items(
|
||||
inLibrary library: PlexLibrary,
|
||||
query: LibraryQuery = LibraryQuery(),
|
||||
offset: Int = 0,
|
||||
limit: Int = 60
|
||||
) async throws(PlexError) -> PlexPage<PlexItem> {
|
||||
let container = try await client.send(
|
||||
PlexRequest(
|
||||
path: "/library/sections/\(library.key)/all",
|
||||
query: query.queryItems(libraryKind: library.kind),
|
||||
// Die Seitengröße steht in Kopfzeilen, nicht in der Adresse —
|
||||
// eine Eigenheit von Plex.
|
||||
additionalHeaders: [
|
||||
"X-Plex-Container-Start": String(offset),
|
||||
"X-Plex-Container-Size": String(limit),
|
||||
]
|
||||
),
|
||||
as: PlexMediaContainer<PlexItem>.self
|
||||
)
|
||||
|
||||
return PlexPage(
|
||||
items: container.content,
|
||||
offset: container.offset ?? offset,
|
||||
// `totalSize` fehlt, wenn alles auf eine Seite passt. Dann ist die
|
||||
// Gesamtzahl gleich der Anzahl der gelieferten Einträge.
|
||||
totalSize: container.totalSize ?? (offset + container.content.count)
|
||||
)
|
||||
}
|
||||
|
||||
/// Ein einzelner Titel mit allem, was die Detailseite braucht.
|
||||
public func metadata(ratingKey: String) async throws(PlexError) -> PlexItem {
|
||||
let container = try await client.send(
|
||||
PlexRequest(path: "/library/metadata/\(ratingKey)", query: [
|
||||
// Marker für Intro und Abspann. Sie werden schon jetzt
|
||||
// mitgeholt, obwohl sie erst in Phase 2 ausgewertet werden —
|
||||
// sie kosten nichts und ersparen später eine zweite Anfrage.
|
||||
URLQueryItem(name: "includeMarkers", value: "1"),
|
||||
URLQueryItem(name: "includeChapters", value: "1"),
|
||||
URLQueryItem(name: "includeRelated", value: "1"),
|
||||
]),
|
||||
as: PlexMediaContainer<PlexItem>.self
|
||||
)
|
||||
guard let item = container.content.first else { throw .notFound }
|
||||
return item
|
||||
}
|
||||
|
||||
/// Die Staffeln einer Serie bzw. die Folgen einer Staffel.
|
||||
public func children(of ratingKey: String) async throws(PlexError) -> [PlexItem] {
|
||||
let container = try await client.send(
|
||||
PlexRequest(path: "/library/metadata/\(ratingKey)/children"),
|
||||
as: PlexMediaContainer<PlexItem>.self
|
||||
)
|
||||
return container.content
|
||||
}
|
||||
|
||||
// MARK: - Suche
|
||||
|
||||
/// Sucht über alle Mediatheken. Plex gruppiert das Ergebnis selbst nach
|
||||
/// Filmen, Serien, Folgen und Personen.
|
||||
public func search(_ query: String, limit: Int = 30) async throws(PlexError) -> [PlexHub] {
|
||||
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return [] }
|
||||
|
||||
let container = try await client.send(
|
||||
PlexRequest(path: "/hubs/search", query: [
|
||||
URLQueryItem(name: "query", value: trimmed),
|
||||
URLQueryItem(name: "limit", value: String(limit)),
|
||||
]),
|
||||
as: PlexMediaContainer<PlexHub>.self
|
||||
)
|
||||
return container.content.filter { !$0.items.isEmpty }
|
||||
}
|
||||
|
||||
// MARK: - Adressen
|
||||
|
||||
/// Die Adresse eines Bildes in der gewünschten Größe.
|
||||
public nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL? {
|
||||
guard let path, !path.isEmpty else { return nil }
|
||||
return client.artworkURL(path: path, width: width, height: height, token: token)
|
||||
}
|
||||
|
||||
/// Die Adresse für DirectPlay: die Datei, unverändert.
|
||||
public nonisolated func directPlayURL(part: PlexPart) -> URL? {
|
||||
streamURL(partKey: part.key, download: false)
|
||||
}
|
||||
|
||||
/// Dieselbe Datei, aber als Download angefordert. Der Unterschied liegt
|
||||
/// nur im Schalter — Plex setzt daraufhin Kopfzeilen, die das Weiterladen
|
||||
/// nach einem Abbruch erlauben.
|
||||
public nonisolated func downloadURL(part: PlexPart) -> URL? {
|
||||
streamURL(partKey: part.key, download: true)
|
||||
}
|
||||
|
||||
private nonisolated func streamURL(partKey: String, download: Bool) -> URL? {
|
||||
guard let baseURL = connection.url,
|
||||
var components = URLComponents(
|
||||
url: baseURL.appending(path: partKey),
|
||||
resolvingAgainstBaseURL: false
|
||||
)
|
||||
else { return nil }
|
||||
|
||||
var query = [URLQueryItem(name: "X-Plex-Token", value: token)]
|
||||
if download { query.append(URLQueryItem(name: "download", value: "1")) }
|
||||
components.queryItems = query
|
||||
return components.url
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Filterwerte
|
||||
|
||||
/// Ein Wert, nach dem gefiltert werden kann — ein Genre, ein Jahr, eine
|
||||
/// Altersfreigabe.
|
||||
public struct PlexFilterOption: Sendable, Equatable, Decodable, Identifiable {
|
||||
public let key: String
|
||||
public let title: String
|
||||
|
||||
public var id: String { key }
|
||||
|
||||
public init(key: String, title: String) {
|
||||
self.key = key
|
||||
self.title = title
|
||||
}
|
||||
}
|
||||
|
||||
extension PlexServer {
|
||||
/// Die Werte, die es in dieser Mediathek für ein Filterfeld gibt.
|
||||
///
|
||||
/// Wird vom Server berechnet, nicht aus der Titelliste abgeleitet: die
|
||||
/// Liste ist seitenweise geladen und kennt die Genres der übrigen
|
||||
/// Tausenden Titel gar nicht.
|
||||
public func filterOptions(
|
||||
inLibrary library: PlexLibrary,
|
||||
field: String
|
||||
) async throws(PlexError) -> [PlexFilterOption] {
|
||||
let container = try await client.send(
|
||||
PlexRequest(path: "/library/sections/\(library.key)/\(field)"),
|
||||
as: PlexMediaContainer<PlexFilterOption>.self
|
||||
)
|
||||
return container.content
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user