diff --git a/Flixy/Views/Detail/TechnicalDetails.swift b/Flixy/Views/Detail/TechnicalDetails.swift index 2bd7f71..c15b292 100644 --- a/Flixy/Views/Detail/TechnicalDetails.swift +++ b/Flixy/Views/Detail/TechnicalDetails.swift @@ -15,7 +15,7 @@ struct TechnicalDetails: View { @Binding var isExpanded: Bool private var decision: PlaybackDecision { - PlaybackDecider().decide(media: media, part: part, policy: .directPlayOnly) + PlaybackDecider().decide(profile: media.profile(part: part), policy: .directPlayOnly) } var body: some View { diff --git a/Flixy/Views/Player/PlayerModel.swift b/Flixy/Views/Player/PlayerModel.swift index c3c91d1..a95470a 100644 --- a/Flixy/Views/Player/PlayerModel.swift +++ b/Flixy/Views/Player/PlayerModel.swift @@ -1,5 +1,6 @@ import AVKit import Foundation +import MediaKit import Observation import FlixyDownloads import FlixyPlayback @@ -184,7 +185,7 @@ final class PlayerModel { currentMedia = media currentPart = part - let decision = PlaybackDecider().decide(media: media, part: part, policy: policy) + let decision = PlaybackDecider().decide(profile: media.profile(part: part), policy: policy) self.decision = decision guard let source = await source(for: decision, item: item, media: media, part: part, server: server) else { @@ -232,7 +233,10 @@ final class PlayerModel { phase = .preparing let newPolicy = newQuality.policy(basedOn: policy) - let newDecision = PlaybackDecider().decide(media: media, part: part, policy: newPolicy) + let newDecision = PlaybackDecider().decide( + profile: media.profile(part: part), + policy: newPolicy + ) decision = newDecision guard let source = await source( @@ -299,19 +303,19 @@ final class PlayerModel { let offline = downloads.offlineMedia(forRatingKey: request.ratingKey) let decision = PlaybackDecider().decide( - media: PlexMedia( - id: 0, + // Früher standen hier **erfundene** Plex-Modelle: eine Media mit + // Kennung 0, ein Part mit dem Dateinamen als Schlüssel und + // zusammengebaute Spuren — alles nur, damit die Signatur passte, + // für eine Datei, die längst auf dem Gerät lag. Genau daran war zu + // sehen, dass die Entscheidung nie etwas mit Plex zu tun hatte. + profile: MediaProfile( container: offline?.container, videoCodec: offline?.videoCodec, audioCodec: offline?.audioCodec, - videoResolution: offline?.videoResolution, - bitrate: offline?.bitrate - ), - part: PlexPart( - id: 0, - key: url.lastPathComponent, - container: offline?.container, - streams: offline.map(localStreams) ?? [] + bitrate: offline?.bitrate, + // Was heruntergeladen wurde, ist untersucht. Ohne diese Zusage + // ginge jede Offline-Datei vorsichtshalber an VLC. + hasKnownStreams: offline?.videoCodec != nil ), // Lokal gibt es nichts umzurechnen und keine Bandbreitengrenze. policy: .directPlayOnly @@ -325,19 +329,6 @@ final class PlayerModel { ) } - /// Baut künstliche Spuren aus dem Offline-Abzug. - /// - /// Der `PlaybackDecider` erwartet Spuren, um überhaupt zu entscheiden — - /// eine leere Liste hieße „noch nicht analysiert" und schickte jede - /// heruntergeladene Datei vorsichtshalber an VLC. - private func localStreams(from offline: OfflineMedia) -> [PlexStream] { - var streams = [PlexStream(id: 1, streamType: 1, codec: offline.videoCodec)] - if let audioCodec = offline.audioCodec { - streams.append(PlexStream(id: 2, streamType: 2, codec: audioCodec, selected: true)) - } - return streams - } - private func load(_ source: PlaybackSource, decision: PlaybackDecision) async { diagnostics.record(decision: decision, source: source.url) diff --git a/Packages/FlixyKit/Package.swift b/Packages/FlixyKit/Package.swift index 8166de7..824e876 100644 --- a/Packages/FlixyKit/Package.swift +++ b/Packages/FlixyKit/Package.swift @@ -11,6 +11,7 @@ let package = Package( // Die App selbst ist iOS-only; das legt project.yml fest. platforms: [.iOS(.v26), .macOS(.v26)], products: [ + .library(name: "MediaKit", targets: ["MediaKit"]), .library(name: "PlexKit", targets: ["PlexKit"]), .library(name: "FlixyDesign", targets: ["FlixyDesign"]), .library(name: "FlixyPlayback", targets: ["FlixyPlayback"]), @@ -19,7 +20,14 @@ let package = Package( targets: [ // Sprechen mit dem Server: Anmeldung, Endpunkte, Modelle. Kein SwiftUI, // damit die gesamte Protokoll-Schicht ohne Oberfläche testbar bleibt. - .target(name: "PlexKit"), + // Die neutrale Grundschicht: Typen und Rechnungen, die für jeden + // Server gelten. Hängt von nichts ab — genau das macht sie zur + // Grundlage, auf der Plex, Jellyfin und später das Web-Projekt + // dasselbe meinen. + .target(name: "MediaKit"), + .testTarget(name: "MediaKitTests", dependencies: ["MediaKit"]), + + .target(name: "PlexKit", dependencies: ["MediaKit"]), // Ein Werkzeug für die Fragen, die reines HTTP sind — ohne Oberfläche, // ohne Gerät, ohne Upload. Eine Runde dauert damit Sekunden statt @@ -44,12 +52,11 @@ let package = Package( // DirectPlay/Transcode. VLCKit steckt bewusst NICHT hier drin, sondern // in FlixyPlaybackVLC — sonst zöge jeder Testlauf ein 920 MB großes // xcframework hinter sich her. - .target(name: "FlixyPlayback", dependencies: ["PlexKit"]), + .target(name: "FlixyPlayback", dependencies: ["MediaKit"]), .testTarget(name: "FlixyPlaybackTests", dependencies: ["FlixyPlayback"]), // Warteschlange, Hintergrundübertragung, Offline-Bestand. - .target(name: "FlixyDownloads", dependencies: ["PlexKit"], - resources: [.process("Localizable.xcstrings")]), + .target(name: "FlixyDownloads", dependencies: ["MediaKit"], resources: [.process("Localizable.xcstrings")]), .testTarget(name: "FlixyDownloadsTests", dependencies: ["FlixyDownloads"]), ] ) diff --git a/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadCoordinator.swift b/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadCoordinator.swift index 5655574..78279d3 100644 --- a/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadCoordinator.swift +++ b/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadCoordinator.swift @@ -1,4 +1,5 @@ import Foundation +import MediaKit import Observation import SwiftData diff --git a/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadRules.swift b/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadRules.swift index 22d3e54..e63eff7 100644 --- a/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadRules.swift +++ b/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadRules.swift @@ -1,5 +1,5 @@ import Foundation -import PlexKit +import MediaKit /// Die Regeln, nach denen Downloads von selbst entstehen und verschwinden. /// diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlaybackDecider.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlaybackDecider.swift index e4f6c92..6306e98 100644 --- a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlaybackDecider.swift +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlaybackDecider.swift @@ -1,5 +1,5 @@ import Foundation -import PlexKit +import MediaKit /// Entscheidet, wie ein Titel abgespielt wird. /// @@ -15,8 +15,7 @@ public struct PlaybackDecider: Sendable { } public func decide( - media: PlexMedia, - part: PlexPart, + profile: MediaProfile, policy: PlaybackPolicy ) -> PlaybackDecision { var rationale: [PlaybackDecision.Rationale] = [] @@ -36,7 +35,7 @@ public struct PlaybackDecider: Sendable { // 1. Reicht die Leitung? Das kommt zuerst, weil es unabhängig vom // Format ist: eine 80-Mbit-Datei über Mobilfunk ist auch dann keine // gute Idee, wenn AVPlayer sie problemlos öffnen könnte. - if let limit = policy.maximumBitrate, let bitrate = media.bitrate, bitrate > limit { + if let limit = policy.maximumBitrate, let bitrate = profile.bitrate, bitrate > limit { rationale.append(.bitrateExceedsLimit(mediaBitrate: bitrate, limit: limit)) if policy.allowsTranscoding { return PlaybackDecision(method: .transcode(maximumBitrate: limit), rationale: rationale) @@ -48,8 +47,8 @@ public struct PlaybackDecider: Sendable { } // 2. Kennt der Server die Datei überhaupt? - let container = normalized(part.container ?? media.container) - guard !part.streams.isEmpty, let videoCodec = normalized(media.videoCodec ?? part.videoStream?.codec) else { + let container = normalized(profile.container) + guard profile.hasKnownStreams, let videoCodec = normalized(profile.videoCodec) else { rationale.append(.streamsUnknown) return fallback(policy: policy, rationale: rationale) } @@ -62,10 +61,9 @@ public struct PlaybackDecider: Sendable { if !capabilities.videoCodecs.contains(videoCodec) { blockers.append(.videoCodecUnsupported(videoCodec)) } - // Geprüft wird die Spur, die tatsächlich klingen soll. Eine Datei mit - // deutschem AAC und englischem DTS ist für AVPlayer in Ordnung, - // solange Deutsch gewählt ist. - if let audioCodec = normalized(part.selectedAudioStream?.codec ?? media.audioCodec), + // Geprüft wird die Spur, die tatsächlich klingen soll — welche das + // ist, hat der Aufrufer beim Bauen des Profils entschieden. + if let audioCodec = normalized(profile.audioCodec), !capabilities.audioCodecs.contains(audioCodec) { blockers.append(.audioCodecUnsupported(audioCodec)) } diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift index e1089cf..3bcff96 100644 --- a/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift @@ -1,6 +1,6 @@ import AVFoundation import Foundation -import PlexKit +import MediaKit /// Wiedergabe über den Systemplayer. /// diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Engine/MediaTrack.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/MediaTrack.swift index b955d4e..ae0e7f1 100644 --- a/Packages/FlixyKit/Sources/FlixyPlayback/Engine/MediaTrack.swift +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/MediaTrack.swift @@ -1,12 +1,11 @@ import Foundation -import PlexKit /// Eine wählbare Ton- oder Untertitelspur. /// -/// Bewusst getrennt von `PlexStream`: VLCKit und AVPlayer nummerieren ihre -/// Spuren selbst, und die Nummern stimmen nicht mit denen von Plex überein. -/// `engineIdentifier` hält deshalb fest, wie der jeweilige Engine die Spur -/// nennt, während `plexStreamID` für den Server gedacht ist. +/// Bewusst getrennt von den Spuren, die der Server meldet: VLCKit und +/// AVPlayer nummerieren selbst, und ihre Nummern stimmen mit denen des Servers +/// nicht überein. `engineIdentifier` hält fest, wie die laufende Engine die +/// Spur nennt, `serverStreamID`, wie der Server sie nennt. public struct MediaTrack: Sendable, Equatable, Identifiable { public enum Kind: Sendable, Equatable { case audio, subtitle } @@ -14,7 +13,7 @@ public struct MediaTrack: Sendable, Equatable, Identifiable { public let kind: Kind public let displayName: String public let languageCode: String? - public let plexStreamID: Int? + public let serverStreamID: Int? /// Die Nummer, unter der der laufende Engine die Spur führt. public let engineIdentifier: Int? public let isForced: Bool @@ -24,7 +23,7 @@ public struct MediaTrack: Sendable, Equatable, Identifiable { kind: Kind, displayName: String, languageCode: String? = nil, - plexStreamID: Int? = nil, + serverStreamID: Int? = nil, engineIdentifier: Int? = nil, isForced: Bool = false ) { @@ -32,40 +31,9 @@ public struct MediaTrack: Sendable, Equatable, Identifiable { self.kind = kind self.displayName = displayName self.languageCode = languageCode - self.plexStreamID = plexStreamID + self.serverStreamID = serverStreamID self.engineIdentifier = engineIdentifier self.isForced = isForced } } -extension MediaTrack { - /// Baut eine Spur aus dem, was Plex gemeldet hat. - /// - /// `displayTitle` ist das, was Plex selbst in seinen Oberflächen zeigt - /// (etwa „Deutsch (AC3 5.1)"). Fehlt es, wird aus Sprache und Codec etwas - /// Brauchbares gebaut, statt „Spur 3" anzuzeigen. - public init?(stream: PlexStream) { - guard let kind = stream.kind else { return nil } - let trackKind: Kind - switch kind { - case .audio: trackKind = .audio - case .subtitle: trackKind = .subtitle - case .video: return nil - } - - let name = stream.displayTitle - ?? stream.title - ?? [stream.language, stream.codec?.uppercased()] - .compactMap(\.self) - .joined(separator: " · ") - - self.init( - id: "plex-\(stream.id)", - kind: trackKind, - displayName: name.isEmpty ? "—" : name, - languageCode: stream.languageCode, - plexStreamID: stream.id, - isForced: stream.isForced - ) - } -} diff --git a/Packages/FlixyKit/Sources/MediaKit/Duration+Units.swift b/Packages/FlixyKit/Sources/MediaKit/Duration+Units.swift new file mode 100644 index 0000000..2f1eec4 --- /dev/null +++ b/Packages/FlixyKit/Sources/MediaKit/Duration+Units.swift @@ -0,0 +1,25 @@ +import Foundation + +extension Duration { + /// Die Dauer als Gleitkommazahl in Sekunden. + /// + /// Foundation und AVFoundation rechnen in `TimeInterval`; `Duration` ist + /// der genauere Typ, den der übrige Code verwendet. + /// + /// Diese Brücke stand bis eben in `PlexKit` — „weil es die unterste + /// Schicht ist". Das stimmte, solange es nur einen Server gab. Sie hier + /// zu haben ist der Grund, warum Downloads und Wiedergabe jetzt ohne + /// jeden Serverbezug übersetzen. + public var seconds: Double { + Double(components.seconds) + Double(components.attoseconds) / 1e18 + } + + /// Die Dauer in ganzen Millisekunden. + /// + /// Die Einheit, in der sowohl Plex als auch Jellyfin Fortschritt melden — + /// Jellyfin allerdings in Ticks zu 100 Nanosekunden, was der jeweilige + /// Server-Adapter umrechnet. + public var milliseconds: Int { + Int(components.seconds * 1000 + components.attoseconds / 1_000_000_000_000_000) + } +} diff --git a/Packages/FlixyKit/Sources/MediaKit/MediaProfile.swift b/Packages/FlixyKit/Sources/MediaKit/MediaProfile.swift new file mode 100644 index 0000000..955ca4a --- /dev/null +++ b/Packages/FlixyKit/Sources/MediaKit/MediaProfile.swift @@ -0,0 +1,44 @@ +import Foundation + +/// Was der `PlaybackDecider` über eine Datei wissen muss — und nicht mehr. +/// +/// Vorher nahm er die Plex-Modelle entgegen und las daraus sechs Felder. +/// Das band die Entscheidung an einen bestimmten Server, obwohl an ihr nichts +/// serverspezifisch ist: ob iOS eine Datei öffnen kann, hängt an Container und +/// Codecs, nicht daran, wer sie ausliefert. +/// +/// Wie ernst die Kopplung war, zeigt der Player selbst: für die +/// Offline-Wiedergabe baute er sich **erfundene** Plex-Werte zusammen, nur um +/// diese Signatur zu bedienen — für eine Datei, die längst auf dem Gerät lag +/// und mit Plex nichts mehr zu tun hatte. +public struct MediaProfile: Sendable, Equatable { + /// Der Behälter, etwa `mkv` oder `mp4`. + public let container: String? + public let videoCodec: String? + /// Der Codec der Tonspur, die tatsächlich klingen soll. + /// + /// Nicht irgendeiner: eine Datei mit deutschem AAC und englischem DTS ist + /// für AVPlayer in Ordnung, solange Deutsch gewählt ist. + public let audioCodec: String? + /// Gesamtbitrate in Kilobit je Sekunde. + public let bitrate: Int? + /// Ob der Server die Datei überhaupt schon untersucht hat. + /// + /// Ohne bekannte Spuren lässt sich nichts entscheiden — dann ist der + /// Ersatzplayer die sichere Wahl. + public let hasKnownStreams: Bool + + public init( + container: String? = nil, + videoCodec: String? = nil, + audioCodec: String? = nil, + bitrate: Int? = nil, + hasKnownStreams: Bool = true + ) { + self.container = container + self.videoCodec = videoCodec + self.audioCodec = audioCodec + self.bitrate = bitrate + self.hasKnownStreams = hasKnownStreams + } +} diff --git a/Packages/FlixyKit/Sources/PlexKit/Auth/PlexAuthenticator.swift b/Packages/FlixyKit/Sources/PlexKit/Auth/PlexAuthenticator.swift index 84d8b05..9293d4c 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Auth/PlexAuthenticator.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Auth/PlexAuthenticator.swift @@ -1,4 +1,5 @@ import Foundation +import MediaKit /// Führt die Anmeldung bei plex.tv durch. public actor PlexAuthenticator { diff --git a/Packages/FlixyKit/Sources/PlexKit/Client/PlexTransport.swift b/Packages/FlixyKit/Sources/PlexKit/Client/PlexTransport.swift index 0a184e3..d86ba83 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Client/PlexTransport.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Client/PlexTransport.swift @@ -1,4 +1,5 @@ import Foundation +import MediaKit /// Der schmale Spalt, durch den PlexKit ans Netz kommt. /// @@ -89,14 +90,3 @@ public struct PlexRequest: Sendable, Equatable { return request } } - -extension Duration { - /// Die Dauer als Gleitkommazahl in Sekunden. - /// - /// Foundation und AVFoundation rechnen in `TimeInterval`; `Duration` ist - /// der genauere Typ, den der übrige Code verwendet. Diese Brücke steht in - /// PlexKit, weil es die unterste Schicht ist — die darüber erben sie. - public var seconds: Double { - Double(components.seconds) + Double(components.attoseconds) / 1e18 - } -} diff --git a/Packages/FlixyKit/Sources/PlexKit/Discovery/PlexConnectionSelector.swift b/Packages/FlixyKit/Sources/PlexKit/Discovery/PlexConnectionSelector.swift index 83c21f6..acc73f0 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Discovery/PlexConnectionSelector.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Discovery/PlexConnectionSelector.swift @@ -1,4 +1,5 @@ import Foundation +import MediaKit /// Sucht den besten erreichbaren Weg zu einem Server. /// diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift index d46e713..9ce8ca0 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift @@ -1,4 +1,5 @@ import Foundation +import MediaKit /// Was beim Live-TV herauskommen kann. public enum LiveTVAvailability: Sendable, Equatable { diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift index 2f0e79d..5b8e052 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift @@ -1,4 +1,5 @@ import Foundation +import MediaKit extension PlexServer { // MARK: - Transcoding @@ -235,13 +236,6 @@ public enum PlaybackReportState: String, Sendable { case buffering } -extension Duration { - /// Die Dauer in ganzen Millisekunden — die Einheit, in der Plex rechnet. - public var milliseconds: Int { - Int(components.seconds * 1000 + components.attoseconds / 1_000_000_000_000_000) - } -} - extension PlexServer { /// Die nächste Folge nach der angegebenen. /// diff --git a/Packages/FlixyKit/Sources/PlexKit/Models/PlexLiveTV.swift b/Packages/FlixyKit/Sources/PlexKit/Models/PlexLiveTV.swift index 00250dc..0610ade 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Models/PlexLiveTV.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Models/PlexLiveTV.swift @@ -1,4 +1,5 @@ import Foundation +import MediaKit /// Ein Fernsehempfänger am Server. /// diff --git a/Packages/FlixyKit/Sources/PlexKit/Models/PlexMedia+Profile.swift b/Packages/FlixyKit/Sources/PlexKit/Models/PlexMedia+Profile.swift new file mode 100644 index 0000000..287f39b --- /dev/null +++ b/Packages/FlixyKit/Sources/PlexKit/Models/PlexMedia+Profile.swift @@ -0,0 +1,23 @@ +import Foundation +import MediaKit + +extension PlexMedia { + /// Was die Wiedergabe-Entscheidung über diese Fassung wissen muss. + /// + /// Die eine Stelle, an der Plex-Modelle in die neutrale Form übersetzt + /// werden. Vorher stand diese Auswahl im Entscheider selbst — und band + /// ihn damit an einen bestimmten Server, obwohl an der Frage „kann iOS + /// das öffnen?" nichts serverspezifisch ist. + public func profile(part: PlexPart) -> MediaProfile { + MediaProfile( + container: part.container ?? container, + videoCodec: videoCodec ?? part.videoStream?.codec, + // Die **gewählte** Tonspur, nicht irgendeine: eine Datei mit + // deutschem AAC und englischem DTS ist für AVPlayer in Ordnung, + // solange Deutsch klingt. + audioCodec: part.selectedAudioStream?.codec ?? audioCodec, + bitrate: bitrate, + hasKnownStreams: !part.streams.isEmpty + ) + } +} diff --git a/Packages/FlixyKit/Tests/FlixyPlaybackTests/PlaybackDeciderTests.swift b/Packages/FlixyKit/Tests/FlixyPlaybackTests/PlaybackDeciderTests.swift index 4bb7437..df185b5 100644 --- a/Packages/FlixyKit/Tests/FlixyPlaybackTests/PlaybackDeciderTests.swift +++ b/Packages/FlixyKit/Tests/FlixyPlaybackTests/PlaybackDeciderTests.swift @@ -1,5 +1,5 @@ import Testing -import PlexKit +import MediaKit @testable import FlixyPlayback /// Die Tabelle, die festhält, welche Datei auf welchem Weg landet. @@ -9,27 +9,24 @@ import PlexKit @Suite("Wiedergabe-Entscheidung") struct PlaybackDeciderTests { - /// Baut eine Datei mit den angegebenen Eigenschaften. - private func media( + /// Beschreibt eine Datei mit den angegebenen Eigenschaften. + /// + /// Seit die Entscheidung `MediaProfile` entgegennimmt, steht hier genau + /// das, worauf es ankommt. Vorher mussten vollständige Plex-Modelle + /// gebaut werden — mit Kennungen, Dateipfaden und Spurlisten, von denen + /// die Entscheidung nichts las. + private func profile( container: String, video: String, audio: String, bitrate: Int? = nil - ) -> (PlexMedia, PlexPart) { - let streams = [ - PlexStream(id: 1, streamType: 1, codec: video), - PlexStream(id: 2, streamType: 2, codec: audio, selected: true), - ] - let part = PlexPart(id: 10, key: "/library/parts/10/1/file", container: container, streams: streams) - let media = PlexMedia( - id: 100, + ) -> MediaProfile { + MediaProfile( container: container, videoCodec: video, audioCodec: audio, - bitrate: bitrate, - parts: [part] + bitrate: bitrate ) - return (media, part) } // MARK: - Was AVPlayer nativ kann @@ -47,8 +44,10 @@ struct PlaybackDeciderTests { ] ) func nativeFormatsUseAVPlayer(container: String, video: String, audio: String) { - let (media, part) = media(container: container, video: video, audio: audio) - let decision = PlaybackDecider().decide(media: media, part: part, policy: .directPlayOnly) + let decision = PlaybackDecider().decide( + profile: profile(container: container, video: video, audio: audio), + policy: .directPlayOnly + ) #expect(decision.method == .directPlay(engine: .avPlayer)) #expect(decision.rationale.contains(.nativelySupported)) @@ -58,8 +57,10 @@ struct PlaybackDeciderTests { @Test("Matroska geht an VLC, auch bei nativen Codecs") func matroskaFallsBackToVLC() { - let (media, part) = media(container: "mkv", video: "h264", audio: "aac") - let decision = PlaybackDecider().decide(media: media, part: part, policy: .directPlayOnly) + let decision = PlaybackDecider().decide( + profile: profile(container: "mkv", video: "h264", audio: "aac"), + policy: .directPlayOnly + ) #expect(decision.method == .directPlay(engine: .vlc)) #expect(decision.rationale.contains(.containerUnsupported("mkv"))) @@ -70,8 +71,10 @@ struct PlaybackDeciderTests { arguments: ["dts", "dca", "truehd", "vorbis", "opus"] ) func unsupportedAudioFallsBackToVLC(codec: String) { - let (media, part) = media(container: "mp4", video: "h264", audio: codec) - let decision = PlaybackDecider().decide(media: media, part: part, policy: .directPlayOnly) + let decision = PlaybackDecider().decide( + profile: profile(container: "mp4", video: "h264", audio: codec), + policy: .directPlayOnly + ) #expect(decision.method == .directPlay(engine: .vlc)) #expect(decision.rationale.contains(.audioCodecUnsupported(codec))) @@ -82,8 +85,10 @@ struct PlaybackDeciderTests { arguments: ["av1", "vp9", "mpeg2video", "vc1", "wmv3"] ) func unsupportedVideoFallsBackToVLC(codec: String) { - let (media, part) = media(container: "mp4", video: codec, audio: "aac") - let decision = PlaybackDecider().decide(media: media, part: part, policy: .directPlayOnly) + let decision = PlaybackDecider().decide( + profile: profile(container: "mp4", video: codec, audio: "aac"), + policy: .directPlayOnly + ) #expect(decision.method == .directPlay(engine: .vlc)) #expect(decision.rationale.contains(.videoCodecUnsupported(codec))) @@ -94,24 +99,24 @@ struct PlaybackDeciderTests { // Deutsches AAC ist ausgewählt, englisches DTS liegt daneben. Würde // der Decider nur die erste Tonspur ansehen, ginge diese Datei // unnötig an VLC — und damit ohne Bild-in-Bild. - let streams = [ - PlexStream(id: 1, streamType: 1, codec: "h264"), - PlexStream(id: 2, streamType: 2, codec: "dts", language: "English"), - PlexStream(id: 3, streamType: 2, codec: "aac", language: "Deutsch", selected: true), - ] - let part = PlexPart(id: 10, key: "/p", container: "mp4", streams: streams) - let media = PlexMedia(id: 100, container: "mp4", videoCodec: "h264", audioCodec: "dts", parts: [part]) - - let decision = PlaybackDecider().decide(media: media, part: part, policy: .directPlayOnly) + // Die Datei hat englisches DTS und deutsches AAC; gewählt ist + // Deutsch. Entschieden wird über die Spur, die klingen soll — wer sie + // aussucht, hat der Aufrufer beim Bauen des Profils entschieden. + let decision = PlaybackDecider().decide( + profile: profile(container: "mp4", video: "h264", audio: "aac"), + policy: .directPlayOnly + ) #expect(decision.method == .directPlay(engine: .avPlayer)) } @Test("Unanalysierte Dateien gehen an den toleranteren Engine") func unknownStreamsFallBackToVLC() { - let part = PlexPart(id: 10, key: "/p", container: "mp4", streams: []) - let media = PlexMedia(id: 100, container: "mp4", parts: [part]) - - let decision = PlaybackDecider().decide(media: media, part: part, policy: .directPlayOnly) + // Der Server hat die Datei noch nicht untersucht: keine Spuren, kein + // bekannter Codec. Dann ist Raten die falsche Antwort. + let decision = PlaybackDecider().decide( + profile: MediaProfile(container: "mp4", hasKnownStreams: false), + policy: .directPlayOnly + ) #expect(decision.method == .directPlay(engine: .vlc)) #expect(decision.rationale.contains(.streamsUnknown)) } @@ -120,10 +125,12 @@ struct PlaybackDeciderTests { @Test("Über der Grenze wird umgerechnet, sobald das erlaubt ist") func exceedingBitrateTranscodesWhenAllowed() { - let (media, part) = media(container: "mp4", video: "h264", audio: "aac", bitrate: 80_000) let policy = PlaybackPolicy(allowsTranscoding: true, maximumBitrate: 8_000) - let decision = PlaybackDecider().decide(media: media, part: part, policy: policy) + let decision = PlaybackDecider().decide( + profile: profile(container: "mp4", video: "h264", audio: "aac", bitrate: 80_000), + policy: policy + ) #expect(decision.method == .transcode(maximumBitrate: 8_000)) #expect(decision.rationale.contains(.bitrateExceedsLimit(mediaBitrate: 80_000, limit: 8_000))) @@ -133,10 +140,12 @@ struct PlaybackDeciderTests { func exceedingBitrateStillPlaysInPhaseOne() { // Das ist die Lage in Phase 1. Abbrechen wäre falsch: im WLAN läuft // auch ein 80-Mbit-Remux, die Grenze ist nur eine Vorsichtsmaßnahme. - let (media, part) = media(container: "mp4", video: "h264", audio: "aac", bitrate: 80_000) let policy = PlaybackPolicy(allowsTranscoding: false, maximumBitrate: 8_000) - let decision = PlaybackDecider().decide(media: media, part: part, policy: policy) + let decision = PlaybackDecider().decide( + profile: profile(container: "mp4", video: "h264", audio: "aac", bitrate: 80_000), + policy: policy + ) #expect(decision.isDirectPlay) #expect(decision.rationale.contains(.transcodingUnavailable)) @@ -144,19 +153,23 @@ struct PlaybackDeciderTests { @Test("Unter der Grenze bleibt alles beim Direktabspielen") func withinBitrateLimitPlaysDirectly() { - let (media, part) = media(container: "mp4", video: "h264", audio: "aac", bitrate: 4_000) let policy = PlaybackPolicy(allowsTranscoding: true, maximumBitrate: 8_000) - let decision = PlaybackDecider().decide(media: media, part: part, policy: policy) + let decision = PlaybackDecider().decide( + profile: profile(container: "mp4", video: "h264", audio: "aac", bitrate: 4_000), + policy: policy + ) #expect(decision.method == .directPlay(engine: .avPlayer)) } @Test("Ohne VLC übernimmt der Transcoder") func withoutFallbackEngineTranscodingTakesOver() { - let (media, part) = media(container: "mkv", video: "h264", audio: "dts") let policy = PlaybackPolicy(allowsTranscoding: true, allowsFallbackEngine: false) - let decision = PlaybackDecider().decide(media: media, part: part, policy: policy) + let decision = PlaybackDecider().decide( + profile: profile(container: "mkv", video: "h264", audio: "dts"), + policy: policy + ) #expect(decision.method == .transcode(maximumBitrate: nil)) } @@ -164,10 +177,12 @@ struct PlaybackDeciderTests { func transcodingUsesAVPlayer() { // HLS ist genau die Kost, für die AVPlayer gebaut ist — inklusive // automatischem Bitratenwechsel. Deshalb darf VLC hier nicht greifen. - let (media, part) = media(container: "mkv", video: "h264", audio: "dts") let policy = PlaybackPolicy(allowsTranscoding: true, allowsFallbackEngine: false) - let decision = PlaybackDecider().decide(media: media, part: part, policy: policy) + let decision = PlaybackDecider().decide( + profile: profile(container: "mkv", video: "h264", audio: "dts"), + policy: policy + ) #expect(decision.engine == .avPlayer) } @@ -176,22 +191,8 @@ struct PlaybackDeciderTests { // Ohne das verpuffte die Wahl im Player: wer für eine 8-Mbit-Datei // „12 Mbit" wählt, bekam DirectPlay, weil die Datei ja unter der // Grenze liegt. Gemeint ist aber „rechne es mir auf diese Stufe um". - let media = PlexMedia( - id: 1, - container: "mp4", - videoCodec: "h264", - audioCodec: "aac", - bitrate: 8_000, - parts: [] - ) - let part = PlexPart(id: 1, key: "/library/parts/1", container: "mp4", streams: [ - PlexStream(id: 1, streamType: 1, codec: "h264"), - PlexStream(id: 2, streamType: 2, codec: "aac", selected: true), - ]) - let decision = PlaybackDecider().decide( - media: media, - part: part, + profile: profile(container: "mp4", video: "h264", audio: "aac", bitrate: 8_000), policy: PlaybackPolicy( allowsTranscoding: true, allowsFallbackEngine: false, @@ -206,22 +207,8 @@ struct PlaybackDeciderTests { @Test("Ohne ausdrückliche Wahl bleibt es bei DirectPlay") func leavesAutomaticAlone() { - let media = PlexMedia( - id: 1, - container: "mp4", - videoCodec: "h264", - audioCodec: "aac", - bitrate: 8_000, - parts: [] - ) - let part = PlexPart(id: 1, key: "/library/parts/1", container: "mp4", streams: [ - PlexStream(id: 1, streamType: 1, codec: "h264"), - PlexStream(id: 2, streamType: 2, codec: "aac", selected: true), - ]) - let decision = PlaybackDecider().decide( - media: media, - part: part, + profile: profile(container: "mp4", video: "h264", audio: "aac", bitrate: 8_000), policy: PlaybackPolicy(allowsTranscoding: true, maximumBitrate: 12_000) ) diff --git a/Packages/FlixyKit/Tests/MediaKitTests/DurationUnitsTests.swift b/Packages/FlixyKit/Tests/MediaKitTests/DurationUnitsTests.swift new file mode 100644 index 0000000..0e41f83 --- /dev/null +++ b/Packages/FlixyKit/Tests/MediaKitTests/DurationUnitsTests.swift @@ -0,0 +1,28 @@ +import Foundation +import Testing +@testable import MediaKit + +/// Die Umrechnung von `Duration` in die Einheiten der Server. +@Suite("Dauer in Zahlen") +struct DurationUnitsTests { + + @Test("Sekunden als Kommazahl") + func convertsToSeconds() { + #expect(Duration.seconds(90).seconds == 90.0) + #expect(Duration.milliseconds(1_500).seconds == 1.5) + } + + @Test("Millisekunden ganzzahlig") + func convertsToMilliseconds() { + #expect(Duration.seconds(2).milliseconds == 2_000) + #expect(Duration.milliseconds(1_234).milliseconds == 1_234) + } + + @Test("Bruchteile gehen nicht verloren") + func keepsFractions() { + // Der Fortschritt wird in Millisekunden gemeldet; würde hier + // abgeschnitten, verlöre man bei jedem Bericht bis zu einer Sekunde. + #expect(Duration.milliseconds(1).milliseconds == 1) + #expect(Duration.milliseconds(1).seconds == 0.001) + } +} diff --git a/project.yml b/project.yml index b27d4e7..f462a4b 100644 --- a/project.yml +++ b/project.yml @@ -40,6 +40,11 @@ targets: type: file dependencies: - target: DownloadWidget + # Die neutrale Grundschicht. Ausdrücklich genannt, obwohl sie über + # PlexKit ohnehin im Baum hängt: die App soll sie direkt benutzen und + # nicht davon abhängen, dass ein bestimmter Server sie mitschleppt. + - package: FlixyKit + product: MediaKit - package: FlixyKit product: PlexKit - package: FlixyKit