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,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