diff --git a/Flixy/Views/Detail/PlayableDetailView.swift b/Flixy/Views/Detail/PlayableDetailView.swift index 85655e1..9ac7a60 100644 --- a/Flixy/Views/Detail/PlayableDetailView.swift +++ b/Flixy/Views/Detail/PlayableDetailView.swift @@ -67,7 +67,8 @@ struct PlayableDetailView: View { path: item.art ?? item.grandparentArt ?? item.thumb, width: 900, height: 1200 - ) + ), + logoURL: session.server?.rawImageURL(path: item.clearLogoPath) ) { Button { playback.play(item, mediaIndex: selectedVersion) diff --git a/Flixy/Views/Detail/SeriesDetailView.swift b/Flixy/Views/Detail/SeriesDetailView.swift index b810e10..dfaecaa 100644 --- a/Flixy/Views/Detail/SeriesDetailView.swift +++ b/Flixy/Views/Detail/SeriesDetailView.swift @@ -45,7 +45,8 @@ struct SeriesDetailView: View { path: item.art ?? item.thumb, width: 900, height: 1200 - ) + ), + logoURL: session.server?.rawImageURL(path: item.clearLogoPath) ) { DownloadAllButton(container: item) } diff --git a/Flixy/Views/Home/HeroSection.swift b/Flixy/Views/Home/HeroSection.swift index ed7a95a..bc665a3 100644 --- a/Flixy/Views/Home/HeroSection.swift +++ b/Flixy/Views/Home/HeroSection.swift @@ -19,7 +19,8 @@ struct HeroSection: View { path: item.art ?? item.grandparentArt ?? item.thumb, width: 900, height: 1200 - ) + ), + logoURL: session.server?.rawImageURL(path: item.clearLogoPath) ) { Button { playback.play(item) diff --git a/Flixy/Views/LiveTV/LiveTVView.swift b/Flixy/Views/LiveTV/LiveTVView.swift index 4f60b81..ab4d836 100644 --- a/Flixy/Views/LiveTV/LiveTVView.swift +++ b/Flixy/Views/LiveTV/LiveTVView.swift @@ -12,6 +12,8 @@ struct LiveTVView: View { @State private var availability: LiveTVAvailability? @State private var channels: [PlexChannel] = [] + /// Woher die Senderliste kam — steht in der leeren Ansicht. + @State private var channelSource: String? @State private var programs: [String: [PlexProgram]] = [:] @State private var isTuning: String? @@ -47,7 +49,17 @@ struct LiveTVView: View { ContentUnavailableView { Label("liveTV.noChannels.title", systemImage: "tv") } description: { - Text("liveTV.noChannels.description") + VStack(spacing: FlixySpacing.snug) { + Text("liveTV.noChannels.description") + if let channelSource { + // Ohne diese Angabe ist „keine Sender" eine + // Sackgasse: es sagt nicht, ob der Server nichts + // hergibt oder ob die App am falschen Ort sucht. + Text(channelSource) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(Color.flixyTextMuted) + } + } } actions: { Button("action.retry") { Task { await load() } } } @@ -86,7 +98,10 @@ struct LiveTVView: View { self.availability = availability guard case .available(let dvrs) = availability, let dvr = dvrs.first else { return } - channels = (try? await server.channels(inDVR: dvr)) ?? [] + + let lookup = await server.channels(inDVR: dvr) + channels = lookup.channels + channelSource = lookup.description // Der Programmführer kommt getrennt: er kann ausfallen, ohne dass // deshalb das Umschalten unmöglich wäre. Eine Senderliste ohne diff --git a/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift b/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift index 58af1f4..20a41c2 100644 --- a/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift +++ b/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift @@ -10,6 +10,10 @@ public struct HeroBanner: View { private let metadata: String? private let summary: String? private let artworkURL: URL? + /// Der freigestellte Schriftzug des Films. Steht er zur Verfügung, tritt + /// er an die Stelle des gesetzten Titels — „Harry Potter" in seiner + /// eigenen Schrift ist wiedererkennbar, eine Systemschrift nicht. + private let logoURL: URL? private let aspectRatio: CGFloat private let actions: Actions @@ -18,6 +22,7 @@ public struct HeroBanner: View { metadata: String? = nil, summary: String? = nil, artworkURL: URL?, + logoURL: URL? = nil, // 16:9, weil Plex genau das als `art` liefert. Vorher stand hier // 3:4 — mit `scaledToFill` wurde das Bild dadurch links und rechts // kräftig beschnitten, und vom Aufmacher war nur ein Ausschnitt zu @@ -29,10 +34,49 @@ public struct HeroBanner: View { self.metadata = metadata self.summary = summary self.artworkURL = artworkURL + self.logoURL = logoURL self.aspectRatio = aspectRatio self.actions = actions() } + /// Schriftzug, wenn vorhanden — sonst gesetzter Titel. + /// + /// Der Schriftzug wird ohne Bild-Transcoder geladen; er ist ein + /// freigestelltes PNG, und der Transcoder liefert JPEG. Aus dem + /// freigestellten Logo würde sonst ein schwarzer Kasten. + @ViewBuilder + private var titleView: some View { + if let logoURL { + AsyncImage(url: logoURL) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFit() + // Nach oben begrenzt: manche Schriftzüge sind fast + // quadratisch, andere sehr breit. Ohne Deckel schöbe + // ein hohes Logo die Knöpfe aus dem Bild. + .frame(maxWidth: 260, maxHeight: 76, alignment: .leading) + default: + // Solange geladen wird — und wenn es scheitert — bleibt es + // beim gesetzten Titel. Ein leerer Platz wäre schlimmer + // als eine Systemschrift. + plainTitle + } + } + .accessibilityLabel(Text(title)) + } else { + plainTitle + } + } + + private var plainTitle: some View { + Text(title) + .font(.flixyHero) + .foregroundStyle(Color.flixyTextPrimary) + .lineLimit(2) + } + public var body: some View { FlixyArtwork(url: artworkURL, aspectRatio: aspectRatio) .overlay(alignment: .bottom) { @@ -53,10 +97,7 @@ public struct HeroBanner: View { } .overlay(alignment: .bottomLeading) { VStack(alignment: .leading, spacing: FlixySpacing.snug) { - Text(title) - .font(.flixyHero) - .foregroundStyle(Color.flixyTextPrimary) - .lineLimit(2) + titleView if let metadata { Text(metadata) diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift index 23c0c81..7ba781e 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift @@ -31,13 +31,34 @@ extension PlexServer { } } - /// 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.self - ) - return container.content + /// Die Sender eines Empfängers, samt Auskunft darüber, woher sie kamen. + /// + /// Zwei Quellen, in dieser Reihenfolge. Die erste ist die verlässliche: + /// Plex liefert die Sender bereits verschachtelt in der Antwort auf + /// `/livetv/dvrs` mit. Der getrennte Endpunkt war meine erste Vermutung + /// und lieferte nichts — daran hing „Keine Sender" bei einem Server mit + /// über dreißig eingerichteten Sendern. + /// + /// Die Auskunft über die Quelle steht in der Diagnose. Bei einem Bereich + /// ohne offizielle Beschreibung ist „welcher Weg hat gegriffen" die + /// Angabe, mit der sich der nächste Fehler finden lässt. + public func channels(inDVR dvr: PlexDVR) async -> ChannelLookup { + if !dvr.channels.isEmpty { + return ChannelLookup(channels: dvr.channels, source: .embeddedInDVR) + } + + do { + let container = try await client.send( + PlexRequest(path: "/livetv/dvrs/\(dvr.key)/channels"), + as: PlexMediaContainer.self + ) + return ChannelLookup( + channels: container.content, + source: container.content.isEmpty ? .nothingFound : .separateEndpoint + ) + } catch { + return ChannelLookup(channels: [], source: .failed(String(describing: error))) + } } /// Die Kennung des Programmanbieters. @@ -116,6 +137,37 @@ extension PlexServer { } } +/// Woher die Senderliste kam. +public struct ChannelLookup: Sendable { + public enum Source: Sendable, Equatable { + /// Aus der Antwort auf `/livetv/dvrs` — der übliche Weg. + case embeddedInDVR + /// Über `/livetv/dvrs/{id}/channels`. + case separateEndpoint + /// Beide Wege lieferten nichts. + case nothingFound + case failed(String) + } + + public let channels: [PlexChannel] + public let source: Source + + public init(channels: [PlexChannel], source: Source) { + self.channels = channels + self.source = source + } + + /// Kurzbeschreibung für die Diagnose. + public var description: String { + switch source { + case .embeddedInDVR: "\(channels.count) Sender, aus der Empfängerliste" + case .separateEndpoint: "\(channels.count) Sender, über /livetv/dvrs/…/channels" + case .nothingFound: "Kein Weg lieferte Sender" + case .failed(let reason): "Abfrage gescheitert: \(reason)" + } + } +} + /// Ein Anbieter am Server — Mediathek, Programmführer, Entdecken. public struct PlexMediaProvider: Sendable, Equatable, Decodable, Identifiable { public let identifier: String diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer.swift index 6406248..73af848 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer.swift @@ -156,6 +156,25 @@ public actor PlexServer { return client.artworkURL(path: path, width: width, height: height, token: token) } + /// Ein Bild unverändert, ohne den Umweg über den Bild-Transcoder. + /// + /// Für freigestellte Schriftzüge zwingend: `/photo/:/transcode` liefert + /// JPEG, und JPEG kennt keine Transparenz. Ein Logo käme als weißer + /// Schriftzug auf schwarzem Kasten heraus statt freigestellt auf dem + /// Hintergrundbild. + public nonisolated func rawImageURL(path: String?) -> URL? { + guard let path, !path.isEmpty, + let baseURL = connection.url, + var components = URLComponents( + url: baseURL.appending(path: path), + resolvingAgainstBaseURL: false + ) + else { return nil } + + components.queryItems = [URLQueryItem(name: "X-Plex-Token", value: token)] + return components.url + } + /// Die Adresse für DirectPlay: die Datei, unverändert. public nonisolated func directPlayURL(part: PlexPart) -> URL? { streamURL(partKey: part.key, download: false) diff --git a/Packages/FlixyKit/Sources/PlexKit/Models/PlexItem.swift b/Packages/FlixyKit/Sources/PlexKit/Models/PlexItem.swift index 0c702b0..ee78b35 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Models/PlexItem.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Models/PlexItem.swift @@ -69,6 +69,8 @@ public struct PlexItem: Sendable, Equatable, Decodable, Identifiable { /// Die Besetzung, mit Porträt und Rollennamen. public let roles: [PlexTag] public let markers: [PlexMarker] + /// Plakat, Hintergrund und — wenn vorhanden — der Schriftzug des Films. + public let images: [PlexImage] private enum CodingKeys: String, CodingKey { case ratingKey, key, guid, type, title, titleSort, summary, year, duration @@ -84,6 +86,7 @@ public struct PlexItem: Sendable, Equatable, Decodable, Identifiable { case writers = "Writer" case roles = "Role" case markers = "Marker" + case images = "Image" } /// Von Hand geschrieben statt erzeugt, weil der erzeugte Decoder einen @@ -134,6 +137,7 @@ public struct PlexItem: Sendable, Equatable, Decodable, Identifiable { self.writers = try values.decodeIfPresent([PlexTag].self, forKey: .writers) ?? [] self.roles = try values.decodeIfPresent([PlexTag].self, forKey: .roles) ?? [] self.markers = try values.decodeIfPresent([PlexMarker].self, forKey: .markers) ?? [] + self.images = try values.decodeIfPresent([PlexImage].self, forKey: .images) ?? [] } /// `ratingKey` ist serverweit eindeutig und eignet sich deshalb direkt @@ -162,6 +166,15 @@ public struct PlexItem: Sendable, Equatable, Decodable, Identifiable { /// Ob die Detailseite eine Versionsauswahl anbieten muss. public var hasMultipleVersions: Bool { media.count > 1 } + + /// Der Schriftzug des Titels, falls der Server einen hat. + /// + /// Nicht jeder Film hat einen — dann bleibt es beim gesetzten Titel. Bei + /// Folgen liefert Plex in der Regel den Schriftzug der Serie mit, weil + /// eine einzelne Folge keinen eigenen hat. + public var clearLogoPath: String? { + images.first(where: \.isClearLogo)?.url + } } /// Die Hülle, in die Plex jede Antwort packt. diff --git a/Packages/FlixyKit/Sources/PlexKit/Models/PlexLiveTV.swift b/Packages/FlixyKit/Sources/PlexKit/Models/PlexLiveTV.swift index 19e8d1f..56af607 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Models/PlexLiveTV.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Models/PlexLiveTV.swift @@ -14,15 +14,96 @@ public struct PlexDVR: Sendable, Equatable, Decodable, Identifiable { public let language: String? public let lineupTitle: String? public let lineup: String? + /// Die Empfänger am Server, jeweils mit ihren Sendern. + /// + /// Plex liefert sie verschachtelt in der Antwort auf `/livetv/dvrs` mit. + /// Der getrennte Endpunkt `/livetv/dvrs/{id}/channels` war die erste + /// Vermutung und lieferte nichts — daran hing „Keine Sender" bei einem + /// Server, auf dem über dreißig Sender eingerichtet sind. + public let devices: [PlexTunerDevice] public var id: String { key } - public init(key: String, uuid: String? = nil, lineupTitle: String? = nil, lineup: String? = nil, language: String? = nil) { + /// Alle Sender aller Empfänger dieses DVR. + public var channels: [PlexChannel] { + devices.flatMap(\.channels) + } + + private enum CodingKeys: String, CodingKey { + case key, uuid, language, lineupTitle, lineup + case devices = "Device" + } + + public init(from decoder: any Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + // Als Zahl oder Zeichenkette — je nach Serverfassung. + if let text = try? values.decode(String.self, forKey: .key) { + self.key = text + } else { + self.key = String(try values.decode(Int.self, forKey: .key)) + } + self.uuid = try values.decodeIfPresent(String.self, forKey: .uuid) + self.language = try values.decodeIfPresent(String.self, forKey: .language) + self.lineupTitle = try values.decodeIfPresent(String.self, forKey: .lineupTitle) + self.lineup = try values.decodeIfPresent(String.self, forKey: .lineup) + self.devices = try values.decodeIfPresent([PlexTunerDevice].self, forKey: .devices) ?? [] + } + + public init( + key: String, + uuid: String? = nil, + lineupTitle: String? = nil, + lineup: String? = nil, + language: String? = nil, + devices: [PlexTunerDevice] = [] + ) { self.key = key self.uuid = uuid self.lineupTitle = lineupTitle self.lineup = lineup self.language = language + self.devices = devices + } +} + +/// Ein Empfänger — eine Fernsehkarte oder ein Netzwerk-Tuner. +public struct PlexTunerDevice: Sendable, Equatable, Decodable, Identifiable { + public let key: String? + public let uuid: String? + public let title: String? + public let channels: [PlexChannel] + + public var id: String { uuid ?? key ?? title ?? "tuner" } + + /// Plex nennt die Senderliste je nach Fassung `Channel` oder + /// `ChannelMapping`. Beide werden gelesen — eine falsche Annahme darüber + /// ergibt eine leere Liste, und die sieht aus wie „kein Sender vorhanden". + private enum CodingKeys: String, CodingKey { + case key, uuid, title + case channel = "Channel" + case channelMapping = "ChannelMapping" + } + + public init(from decoder: any Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + self.key = try? values.decodeIfPresent(String.self, forKey: .key) + self.uuid = try values.decodeIfPresent(String.self, forKey: .uuid) + self.title = try values.decodeIfPresent(String.self, forKey: .title) + + if values.contains(.channel) { + self.channels = try values.decode([PlexChannel].self, forKey: .channel) + } else if values.contains(.channelMapping) { + self.channels = try values.decode([PlexChannel].self, forKey: .channelMapping) + } else { + self.channels = [] + } + } + + public init(key: String? = nil, uuid: String? = nil, title: String? = nil, channels: [PlexChannel] = []) { + self.key = key + self.uuid = uuid + self.title = title + self.channels = channels } } diff --git a/Packages/FlixyKit/Sources/PlexKit/Models/PlexTag.swift b/Packages/FlixyKit/Sources/PlexKit/Models/PlexTag.swift index 8ce9cb2..e904ec5 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Models/PlexTag.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Models/PlexTag.swift @@ -61,3 +61,24 @@ public struct PlexMarker: Sendable, Equatable, Decodable, Identifiable { self.endTimeOffset = endTimeOffset } } + +/// Ein Bild zu einem Titel. +/// +/// Plex liefert neben Plakat und Hintergrund noch das `clearLogo`: den +/// Schriftzug des Films als freigestelltes PNG — „Harry Potter" in seiner +/// eigenen Schrift statt in der der App. Genau dafür ist dieser Typ da. +public struct PlexImage: Sendable, Equatable, Decodable, Identifiable { + public let alt: String? + public let type: String + public let url: String + + public var id: String { url } + + public var isClearLogo: Bool { type == "clearLogo" } + + public init(alt: String? = nil, type: String, url: String) { + self.alt = alt + self.type = type + self.url = url + } +} diff --git a/Packages/FlixyKit/Tests/PlexKitTests/ClearLogoTests.swift b/Packages/FlixyKit/Tests/PlexKitTests/ClearLogoTests.swift new file mode 100644 index 0000000..a173543 --- /dev/null +++ b/Packages/FlixyKit/Tests/PlexKitTests/ClearLogoTests.swift @@ -0,0 +1,119 @@ +import Foundation +import Testing +@testable import PlexKit + +@Suite("Schriftzüge und Bildliste") +struct ClearLogoTests { + private func item(from json: String) throws -> PlexItem { + let data = Data(#"{"MediaContainer":{"Metadata":[\#(json)]}}"#.utf8) + return try #require( + try PlexResponse.decode(PlexMediaContainer.self, from: data).content.first + ) + } + + @Test("Der Schriftzug wird aus der Bildliste gelesen") + func findsClearLogo() throws { + let decoded = try item(from: """ + { + "ratingKey": "1", "type": "movie", "title": "Harry Potter", + "Image": [ + {"alt": "Harry Potter", "type": "coverPoster", "url": "/library/metadata/1/thumb/1"}, + {"alt": "Harry Potter", "type": "background", "url": "/library/metadata/1/art/1"}, + {"alt": "Harry Potter", "type": "clearLogo", "url": "/library/metadata/1/clearLogo/1"} + ] + } + """) + + #expect(decoded.clearLogoPath == "/library/metadata/1/clearLogo/1") + } + + @Test("Ohne Schriftzug bleibt es beim Titel") + func withoutClearLogo() throws { + // Der Normalfall bei selbst zusammengestellten Mediatheken. + let decoded = try item(from: """ + { + "ratingKey": "1", "type": "movie", "title": "Ein Film", + "Image": [{"type": "coverPoster", "url": "/library/metadata/1/thumb/1"}] + } + """) + #expect(decoded.clearLogoPath == nil) + } + + @Test("Eine fehlende Bildliste ist kein Fehler") + func missingImageList() throws { + // Listen liefern keine Bildliste mit — nur die Einzelabfrage tut das. + let decoded = try item(from: #"{"ratingKey": "1", "type": "movie", "title": "Ein Film"}"#) + #expect(decoded.images.isEmpty) + #expect(decoded.clearLogoPath == nil) + } +} + +@Suite("Empfänger und Sender") +struct TunerDecodingTests { + + @Test("Sender werden aus dem Empfänger gelesen") + func readsChannelsFromDevice() throws { + // Der Fall, an dem „Keine Sender" hing: Plex liefert sie verschachtelt + // in der Antwort auf /livetv/dvrs mit, nicht über einen eigenen + // Endpunkt. + let json = """ + {"MediaContainer":{"Dvr":[{ + "key": "1", "lineupTitle": "Kabel", + "Device": [{ + "key": "2", "uuid": "tuner-1", "title": "HDHomeRun", + "Channel": [ + {"id": 10, "channelIdentifier": "1.1", "title": "SRF 1 HD", "key": "/livetv/channel/1"}, + {"id": 11, "channelIdentifier": "1.2", "title": "SRF zwei"} + ] + }] + }]}} + """ + let container = try PlexResponse.decode( + PlexMediaContainer.self, + from: Data(json.utf8) + ) + let dvr = try #require(container.content.first) + + #expect(dvr.devices.count == 1) + #expect(dvr.channels.count == 2) + #expect(dvr.channels.first?.title == "SRF 1 HD") + #expect(dvr.channels.first?.channelIdentifier == "1.1") + } + + @Test("Auch die ältere Schreibweise ChannelMapping wird gelesen") + func readsChannelMapping() throws { + let json = """ + {"MediaContainer":{"Dvr":[{ + "key": "1", + "Device": [{"uuid": "t", "ChannelMapping": [{"id": "a", "title": "3sat"}]}] + }]}} + """ + let container = try PlexResponse.decode( + PlexMediaContainer.self, + from: Data(json.utf8) + ) + #expect(container.content.first?.channels.first?.title == "3sat") + } + + @Test("Ein Empfänger ohne Sender ergibt eine leere Liste, keinen Fehler") + func deviceWithoutChannels() throws { + let json = #"{"MediaContainer":{"Dvr":[{"key":"1","Device":[{"uuid":"t"}]}]}}"# + let container = try PlexResponse.decode( + PlexMediaContainer.self, + from: Data(json.utf8) + ) + #expect(container.content.first?.channels.isEmpty == true) + } + + @Test("Ein Empfänger-Schlüssel als Zahl wird gelesen") + func numericDVRKey() throws { + // Je nach Serverfassung kommt er als Zahl oder als Zeichenkette. Ein + // fester Typ hätte die ganze Empfängerliste unlesbar gemacht. + let json = #"{"MediaContainer":{"Dvr":[{"key":42}]}}"# + let container = try PlexResponse.decode( + PlexMediaContainer.self, + from: Data(json.utf8) + ) + #expect(container.content.first?.key == "42") + } +}