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:
Guido
2026-09-11 17:54:17 +02:00
co-authored by Claude Opus 5
parent b7fc18823a
commit 5c0c1d651d
14 changed files with 924 additions and 7 deletions
@@ -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
)
}
}