Phase 6: Live-TV, plus Live Activity für Downloads
Live-TV - An keinem echten Server geprüft: die DVR- und EPG-Endpunkte sind der am schlechtesten dokumentierte Teil der Plex-API. Die Modelle sind deshalb durchgehend nachsichtig — fehlende Felder ergeben leere Listen, nicht Fehler, und die Senderkennung wird als Zahl, Zeichenkette oder uuid akzeptiert. - Ohne eingerichteten Empfänger erklärt die App das, statt einen Fehler zu zeigen. Das ist der häufigste Fall. - Senderliste mit laufender Sendung, Fortschrittsbalken und „danach". Der Programmführer wird getrennt geholt: fällt er aus, bleibt das Umschalten möglich. - Die Anbieterkennung wird aus /media/providers gelesen statt fest verdrahtet — sie wechselt je nach Server. Live Activity - Eigene Widget-Erweiterung; iOS zeichnet den Sperrbildschirm in einem getrennten Prozess, während die App meist gar nicht läuft. - Der Presenter drosselt auf 1,5 Sekunden. ActivityKit verwirft zu häufige Aktualisierungen kommentarlos, und der Fortschritt kommt mehrmals je Sekunde — ungebremst bliebe die Anzeige stehen. - @preconcurrency import: Activity und ActivityContent tragen keine Sendable-Zusage. Zwei Stolpersteine unterwegs: canImport(ActivityKit) ist auf macOS wahr, die Typen darin aber nicht verfügbar — es muss os(iOS) sein. Und eine von Hand geschriebene Info.plist ohne CFBundleIdentifier führt zu einer Erweiterung ganz ohne Kennung; der Fehler lautet dann „not prefixed with the parent app's bundle identifier" und zeigt auf die falsche Stelle. 126 Tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
import Foundation
|
||||
|
||||
#if os(iOS)
|
||||
// `@preconcurrency`, weil `Activity` und `ActivityContent` keine
|
||||
// Sendable-Zusage tragen. Beide bleiben hier ohnehin auf dem Hauptakteur —
|
||||
// die Klasse ist darauf festgelegt, und ActivityKit erwartet seine Aufrufe
|
||||
// von dort. Ohne diese Zeile lehnt Swift 6 jeden Aufruf ab.
|
||||
@preconcurrency import ActivityKit
|
||||
|
||||
/// Zeigt den Download-Fortschritt im Sperrbildschirm.
|
||||
///
|
||||
/// Getrennt vom Koordinator, weil Live Activities eine Reihe eigener Fallen
|
||||
/// haben — sie können vom Nutzer abgeschaltet sein, ihre Zahl ist begrenzt,
|
||||
/// und eine nicht beendete Activity bleibt stundenlang stehen. Der Koordinator
|
||||
/// meldet nur „das läuft gerade"; was daraus wird, entscheidet sich hier.
|
||||
@MainActor
|
||||
public final class DownloadActivityPresenter {
|
||||
private var activity: Activity<DownloadActivityAttributes>?
|
||||
/// Wann zuletzt aktualisiert wurde.
|
||||
///
|
||||
/// ActivityKit drosselt häufige Aktualisierungen und verwirft sie
|
||||
/// irgendwann kommentarlos. Der Fortschritt kommt mehrmals je Sekunde —
|
||||
/// ungebremst weitergereicht bliebe die Anzeige irgendwann stehen.
|
||||
private var lastUpdate = Date.distantPast
|
||||
private let minimumInterval: TimeInterval = 1.5
|
||||
|
||||
public init() {}
|
||||
|
||||
/// Bringt die Anzeige auf den neuesten Stand.
|
||||
///
|
||||
/// `nil` beendet sie — das ist der Fall, wenn nichts mehr läuft.
|
||||
/// `async`, nicht `Task { … }` im Inneren: `Activity` und
|
||||
/// `ActivityContent` sind nicht `Sendable`, und eine Aufgabe würde sie über
|
||||
/// die Isolationsgrenze schicken. So bleibt alles auf dem Hauptakteur.
|
||||
public func update(with state: DownloadActivityAttributes.ContentState?) async {
|
||||
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
|
||||
|
||||
guard let state else {
|
||||
await end()
|
||||
return
|
||||
}
|
||||
|
||||
if let activity {
|
||||
guard Date().timeIntervalSince(lastUpdate) >= minimumInterval else { return }
|
||||
lastUpdate = Date()
|
||||
await activity.update(ActivityContent(state: state, staleDate: nil))
|
||||
} else {
|
||||
start(with: state)
|
||||
}
|
||||
}
|
||||
|
||||
private func start(with state: DownloadActivityAttributes.ContentState) {
|
||||
do {
|
||||
activity = try Activity.request(
|
||||
attributes: DownloadActivityAttributes(),
|
||||
content: ActivityContent(state: state, staleDate: nil)
|
||||
)
|
||||
lastUpdate = Date()
|
||||
} catch {
|
||||
// Scheitert meist, weil die Höchstzahl gleichzeitiger Activities
|
||||
// erreicht ist. Kein Grund, den Download anzuhalten — die Anzeige
|
||||
// ist Beiwerk.
|
||||
activity = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Beendet die Anzeige.
|
||||
public func end() async {
|
||||
guard let activity else { return }
|
||||
self.activity = nil
|
||||
// `.immediate`: eine Download-Anzeige, die nach dem letzten Byte noch
|
||||
// eine Viertelstunde im Sperrbildschirm klebt, ist Müll.
|
||||
await activity.end(nil, dismissalPolicy: .immediate)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -119,6 +119,10 @@ public final class DownloadCoordinator {
|
||||
private var session: URLSession!
|
||||
private let delegate = DownloadSessionDelegate()
|
||||
|
||||
#if os(iOS)
|
||||
private let activityPresenter = DownloadActivityPresenter()
|
||||
#endif
|
||||
|
||||
/// Die Konfiguration der Hintergrundsitzung.
|
||||
///
|
||||
/// Als eigene Funktion, damit Tests stattdessen eine flüchtige Sitzung
|
||||
@@ -424,6 +428,7 @@ public final class DownloadCoordinator {
|
||||
guard let item = items.first(where: { $0.ratingKey == ratingKey }) else { return }
|
||||
item.receivedBytes = received
|
||||
if expected > 0 { item.totalBytes = expected }
|
||||
updateActivity()
|
||||
// Bewusst ohne `save()`: der Fortschritt kommt mehrmals je Sekunde, und
|
||||
// jedes Schreiben wäre eine Plattenoperation. Für die Anzeige reicht
|
||||
// die Beobachtung; gespeichert wird bei Zustandswechseln.
|
||||
@@ -444,6 +449,7 @@ public final class DownloadCoordinator {
|
||||
item.completedAt = Date()
|
||||
save()
|
||||
refreshUsage()
|
||||
updateActivity()
|
||||
} catch {
|
||||
fail(item, message: error.localizedDescription)
|
||||
}
|
||||
@@ -469,6 +475,33 @@ public final class DownloadCoordinator {
|
||||
item.state = .failed
|
||||
item.failureMessage = message
|
||||
save()
|
||||
updateActivity()
|
||||
}
|
||||
|
||||
/// Hält die Anzeige im Sperrbildschirm auf dem Stand.
|
||||
///
|
||||
/// Zeigt den am weitesten fortgeschrittenen Download — bei zwei
|
||||
/// gleichzeitigen ist das der, der zuerst fertig wird, und damit der, auf
|
||||
/// den man wartet.
|
||||
private func updateActivity() {
|
||||
#if os(iOS)
|
||||
let running = items.filter { $0.state == .downloading }
|
||||
guard let leading = running.max(by: { ($0.progress ?? 0) < ($1.progress ?? 0) }) else {
|
||||
Task { [activityPresenter] in await activityPresenter.update(with: nil) }
|
||||
return
|
||||
}
|
||||
|
||||
// Der Zustand wird hier gebaut, nicht in der Aufgabe: SwiftData-Objekte
|
||||
// gehören dem Hauptakteur, der reine Wertetyp darf wandern.
|
||||
let state = DownloadActivityAttributes.ContentState(
|
||||
title: leading.title,
|
||||
progress: leading.progress,
|
||||
receivedBytes: leading.receivedBytes,
|
||||
totalBytes: leading.totalBytes,
|
||||
remainingCount: items.filter { $0.state.isTransferable }.count + running.count - 1
|
||||
)
|
||||
Task { [activityPresenter] in await activityPresenter.update(with: state) }
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Innereien
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import Foundation
|
||||
|
||||
// `os(iOS)` statt `canImport(ActivityKit)`: der Import gelingt auch auf
|
||||
// macOS, die Typen darin sind dort aber als nicht verfügbar markiert. Eine
|
||||
// Prüfung auf den Import allein übersetzt deshalb nicht.
|
||||
#if os(iOS)
|
||||
import ActivityKit
|
||||
|
||||
/// Was im Sperrbildschirm und in der Dynamic Island steht.
|
||||
///
|
||||
/// Liegt in FlixyDownloads, weil App und Widget-Erweiterung denselben Typ
|
||||
/// brauchen — zwei getrennte Kopien wären die klassische Quelle für eine Live
|
||||
/// Activity, die sich nicht mehr aktualisieren lässt: ActivityKit gleicht die
|
||||
/// Typen über ihren vollen Namen ab.
|
||||
public struct DownloadActivityAttributes: ActivityAttributes {
|
||||
public struct ContentState: Codable, Hashable, Sendable {
|
||||
/// Der Titel, der gerade lädt.
|
||||
public var title: String
|
||||
/// 0…1, oder `nil` solange die Gesamtgröße unbekannt ist.
|
||||
public var progress: Double?
|
||||
public var receivedBytes: Int64
|
||||
public var totalBytes: Int64
|
||||
/// Wie viele weitere in der Warteschlange stehen.
|
||||
public var remainingCount: Int
|
||||
|
||||
public init(
|
||||
title: String,
|
||||
progress: Double?,
|
||||
receivedBytes: Int64,
|
||||
totalBytes: Int64,
|
||||
remainingCount: Int
|
||||
) {
|
||||
self.title = title
|
||||
self.progress = progress
|
||||
self.receivedBytes = receivedBytes
|
||||
self.totalBytes = totalBytes
|
||||
self.remainingCount = remainingCount
|
||||
}
|
||||
}
|
||||
|
||||
/// Bleibt über die Laufzeit gleich. Hier leer: welcher Titel lädt, wechselt
|
||||
/// im Laufe einer Staffel und gehört deshalb in den Zustand.
|
||||
public init() {}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,126 @@
|
||||
import Foundation
|
||||
|
||||
/// Was beim Live-TV herauskommen kann.
|
||||
public enum LiveTVAvailability: Sendable, Equatable {
|
||||
/// Es gibt einen Empfänger und Sender.
|
||||
case available(dvrs: [PlexDVR])
|
||||
/// Der Server läuft, hat aber keinen Empfänger eingerichtet.
|
||||
///
|
||||
/// Der mit Abstand häufigste Fall — Live-TV verlangt eine Fernsehkarte
|
||||
/// oder einen Netzwerk-Tuner. Die Oberfläche soll das erklären, statt
|
||||
/// einen Fehler zu zeigen.
|
||||
case notConfigured
|
||||
case failed(message: String)
|
||||
}
|
||||
|
||||
extension PlexServer {
|
||||
/// Prüft, ob der Server überhaupt Live-TV kann.
|
||||
public func liveTVAvailability() async -> LiveTVAvailability {
|
||||
do {
|
||||
let container = try await client.send(
|
||||
PlexRequest(path: "/livetv/dvrs", timeout: .seconds(10)),
|
||||
as: PlexMediaContainer<PlexDVR>.self
|
||||
)
|
||||
return container.content.isEmpty ? .notConfigured : .available(dvrs: container.content)
|
||||
} catch PlexError.notFound {
|
||||
// Ältere Serverfassungen kennen den Endpunkt gar nicht. Das ist
|
||||
// dasselbe Ergebnis wie „kein Empfänger": es gibt nichts zu sehen.
|
||||
return .notConfigured
|
||||
} catch {
|
||||
return .failed(message: String(describing: error))
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Sender eines Empfängers.
|
||||
public func channels(inDVR dvr: PlexDVR) async throws(PlexError) -> [PlexChannel] {
|
||||
let container = try await client.send(
|
||||
PlexRequest(path: "/livetv/dvrs/\(dvr.key)/channels"),
|
||||
as: PlexMediaContainer<PlexChannel>.self
|
||||
)
|
||||
return container.content
|
||||
}
|
||||
|
||||
/// Die Kennung des Programmanbieters.
|
||||
///
|
||||
/// Sie wechselt je nach Server und Einrichtung (etwa
|
||||
/// `tv.plex.providers.epg.cloud:2`) und steckt in der Anbieterliste. Fest
|
||||
/// verdrahtet wäre sie auf der Hälfte der Server falsch.
|
||||
public func electronicProgramGuideIdentifier() async -> String? {
|
||||
guard let container = try? await client.send(
|
||||
PlexRequest(path: "/media/providers"),
|
||||
as: PlexMediaContainer<PlexMediaProvider>.self
|
||||
) else { return nil }
|
||||
|
||||
return container.content.first { $0.identifier.contains("epg") }?.identifier
|
||||
}
|
||||
|
||||
/// Das Programmraster für einen Zeitraum.
|
||||
///
|
||||
/// Bewusst über einen Zeitraum statt „alles": ein Tag über hundert Sender
|
||||
/// sind mehrere tausend Sendungen, und mehr als die nächsten Stunden
|
||||
/// braucht niemand beim Umschalten.
|
||||
public func guide(
|
||||
providerIdentifier: String,
|
||||
from start: Date,
|
||||
to end: Date
|
||||
) async throws(PlexError) -> [PlexProgram] {
|
||||
let container = try await client.send(
|
||||
PlexRequest(
|
||||
path: "/\(providerIdentifier)/grid",
|
||||
query: [
|
||||
// 1 steht bei Plex für „Sendung".
|
||||
URLQueryItem(name: "type", value: "1"),
|
||||
URLQueryItem(name: "beginsAt>", value: String(Int(start.timeIntervalSince1970))),
|
||||
URLQueryItem(name: "endsAt<", value: String(Int(end.timeIntervalSince1970))),
|
||||
],
|
||||
timeout: .seconds(30)
|
||||
),
|
||||
as: PlexMediaContainer<PlexGridEntry>.self
|
||||
)
|
||||
|
||||
return container.content.compactMap { entry in
|
||||
// Die Senderzuordnung steckt im Medien-Block. Fehlt sie, lässt
|
||||
// sich die Sendung keiner Spalte zuordnen — dann ist sie für ein
|
||||
// Raster wertlos.
|
||||
let channelID = entry.media?.first?.parts.first?.key
|
||||
return entry.program(channelID: channelID)
|
||||
}
|
||||
}
|
||||
|
||||
/// Schaltet einen Sender ein.
|
||||
///
|
||||
/// Live-TV kommt immer als umgerechneter Strom: der Tuner liefert einen
|
||||
/// Transportstrom, den kein iPhone direkt abspielt. Deshalb führt der Weg
|
||||
/// hier zwangsläufig über den Transcoder — anders als bei Dateien, wo
|
||||
/// DirectPlay der Normalfall ist.
|
||||
public func tune(channel: PlexChannel, maximumBitrate: Int?) async throws(PlexError) -> URL? {
|
||||
guard let key = channel.key else { return nil }
|
||||
|
||||
let session = UUID().uuidString
|
||||
let container = try await client.send(
|
||||
PlexRequest(
|
||||
path: "/livetv/sessions/\(session)/\(key)",
|
||||
query: [URLQueryItem(name: "X-Plex-Session-Identifier", value: session)],
|
||||
timeout: .seconds(30)
|
||||
),
|
||||
as: PlexMediaContainer<PlexItem>.self
|
||||
)
|
||||
|
||||
guard let item = container.content.first else { throw .notFound }
|
||||
return transcodeURL(for: TranscodeRequest(
|
||||
ratingKey: item.ratingKey,
|
||||
maximumBitrate: maximumBitrate,
|
||||
isLocal: connection.local,
|
||||
sessionIdentifier: session
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Anbieter am Server — Mediathek, Programmführer, Entdecken.
|
||||
public struct PlexMediaProvider: Sendable, Equatable, Decodable, Identifiable {
|
||||
public let identifier: String
|
||||
public let title: String?
|
||||
public let types: String?
|
||||
|
||||
public var id: String { identifier }
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import Foundation
|
||||
|
||||
/// Ein Fernsehempfänger am Server.
|
||||
///
|
||||
/// **Dieser ganze Bereich ist an keinem echten Server geprüft.** Die
|
||||
/// Live-TV-Endpunkte sind der am schlechtesten dokumentierte Teil der
|
||||
/// Plex-API — es gibt keine offizielle Beschreibung, nur das, was sich aus den
|
||||
/// Anfragen der offiziellen Apps ablesen lässt. Die Modelle sind deshalb
|
||||
/// durchgehend nachsichtig: fehlende Felder führen zu leeren Listen, nicht zu
|
||||
/// Fehlern.
|
||||
public struct PlexDVR: Sendable, Equatable, Decodable, Identifiable {
|
||||
public let key: String
|
||||
public let uuid: String?
|
||||
public let language: String?
|
||||
public let lineupTitle: String?
|
||||
public let lineup: String?
|
||||
|
||||
public var id: String { key }
|
||||
|
||||
public init(key: String, uuid: String? = nil, lineupTitle: String? = nil, lineup: String? = nil, language: String? = nil) {
|
||||
self.key = key
|
||||
self.uuid = uuid
|
||||
self.lineupTitle = lineupTitle
|
||||
self.lineup = lineup
|
||||
self.language = language
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Sender.
|
||||
public struct PlexChannel: Sendable, Equatable, Decodable, Identifiable {
|
||||
/// Die Nummer, unter der der Sender läuft — etwa `"3.1"`.
|
||||
public let channelIdentifier: String?
|
||||
public let title: String
|
||||
public let thumb: String?
|
||||
/// Der Schlüssel, über den sich der Sender einschalten lässt.
|
||||
public let key: String?
|
||||
public let id: String
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case channelIdentifier, title, thumb, key
|
||||
case identifier = "id"
|
||||
case uuid
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.title = try values.decodeIfPresent(String.self, forKey: .title) ?? "—"
|
||||
self.channelIdentifier = try values.decodeIfPresent(String.self, forKey: .channelIdentifier)
|
||||
self.thumb = try values.decodeIfPresent(String.self, forKey: .thumb)
|
||||
self.key = try values.decodeIfPresent(String.self, forKey: .key)
|
||||
|
||||
// Plex liefert die Kennung mal als Zahl, mal als Zeichenkette, mal als
|
||||
// `uuid`. Ein einzelner erwarteter Typ hätte hier bei manchen
|
||||
// Serverfassungen die ganze Senderliste unlesbar gemacht.
|
||||
if let text = try? values.decodeIfPresent(String.self, forKey: .identifier) {
|
||||
self.id = text
|
||||
} else if let number = try? values.decodeIfPresent(Int.self, forKey: .identifier) {
|
||||
self.id = String(number)
|
||||
} else if let uuid = try values.decodeIfPresent(String.self, forKey: .uuid) {
|
||||
self.id = uuid
|
||||
} else {
|
||||
self.id = channelIdentifier ?? title
|
||||
}
|
||||
}
|
||||
|
||||
public init(id: String, title: String, channelIdentifier: String? = nil, thumb: String? = nil, key: String? = nil) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.channelIdentifier = channelIdentifier
|
||||
self.thumb = thumb
|
||||
self.key = key
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Sendung im Programm.
|
||||
public struct PlexProgram: Sendable, Equatable, Identifiable {
|
||||
public let id: String
|
||||
public let title: String
|
||||
public let subtitle: String?
|
||||
public let summary: String?
|
||||
public let startsAt: Date
|
||||
public let endsAt: Date
|
||||
public let channelID: String?
|
||||
public let thumb: String?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
title: String,
|
||||
subtitle: String? = nil,
|
||||
summary: String? = nil,
|
||||
startsAt: Date,
|
||||
endsAt: Date,
|
||||
channelID: String? = nil,
|
||||
thumb: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.summary = summary
|
||||
self.startsAt = startsAt
|
||||
self.endsAt = endsAt
|
||||
self.channelID = channelID
|
||||
self.thumb = thumb
|
||||
}
|
||||
|
||||
public var duration: Duration {
|
||||
.seconds(endsAt.timeIntervalSince(startsAt))
|
||||
}
|
||||
|
||||
public func isRunning(at moment: Date = Date()) -> Bool {
|
||||
moment >= startsAt && moment < endsAt
|
||||
}
|
||||
|
||||
/// Wie weit die Sendung fortgeschritten ist, 0…1.
|
||||
public func progress(at moment: Date = Date()) -> Double {
|
||||
let total = endsAt.timeIntervalSince(startsAt)
|
||||
guard total > 0 else { return 0 }
|
||||
return min(max(moment.timeIntervalSince(startsAt) / total, 0), 1)
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Eintrag aus dem Programmraster.
|
||||
///
|
||||
/// Plex liefert das Raster als gewöhnliche Metadaten mit den Zeitangaben in
|
||||
/// Sekunden seit 1970. Die Übersetzung in `PlexProgram` passiert hier, damit
|
||||
/// die Oberfläche keine Zeitstempel rechnen muss.
|
||||
struct PlexGridEntry: Decodable {
|
||||
let ratingKey: String?
|
||||
let title: String?
|
||||
let summary: String?
|
||||
let grandparentTitle: String?
|
||||
let thumb: String?
|
||||
let beginsAt: Int?
|
||||
let endsAt: Int?
|
||||
|
||||
/// Die Sendermedien; darin steht, zu welchem Sender die Sendung gehört.
|
||||
let media: [PlexMedia]?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ratingKey, title, summary, grandparentTitle, thumb, beginsAt, endsAt
|
||||
case media = "Media"
|
||||
}
|
||||
|
||||
func program(channelID: String?) -> PlexProgram? {
|
||||
guard let beginsAt, let endsAt, let title else { return nil }
|
||||
return PlexProgram(
|
||||
id: ratingKey ?? "\(channelID ?? "")-\(beginsAt)",
|
||||
title: title,
|
||||
subtitle: grandparentTitle,
|
||||
summary: summary,
|
||||
startsAt: Date(timeIntervalSince1970: TimeInterval(beginsAt)),
|
||||
endsAt: Date(timeIntervalSince1970: TimeInterval(endsAt)),
|
||||
channelID: channelID,
|
||||
thumb: thumb
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user