Was heruntergeladen wird, muss der Systemplayer öffnen können
**Vorbereitung dafür, dass VLC verschwindet.** Online ist der Fall längst
gelöst: steht nur die Schachtel im Weg, packt der Server um — dieselben
Ströme, eine MP4-Hülle. Offline gibt es diesen Ausweg nicht. Was einmal als
Matroska auf dem Gerät liegt, kann hinterher niemand mehr umpacken, und ohne
zweite Engine bliebe die Datei schwarz.
Also entscheidet sich das beim Herunterladen. Fragt jemand das Original an und
AVFoundation könnte die Datei nicht öffnen, wird `remuxDownloadURL` benutzt:
`/Videos/{id}/stream.mp4` mit `allowVideoStreamCopy` und
`allowAudioStreamCopy`. Der Server reicht durch, was passt, und rechnet nur
um, was nicht passt — das Bild bleibt das Original, nur DTS oder TrueHD
werden zu AC3 oder AAC.
Drei Dinge hängen daran und sind mitgezogen:
- Die **Größe** ist dann keine Zusage mehr: dieselben Ströme in einer anderen
Hülle wiegen anders. `expectedIsExact` fällt auf falsch, und es gilt wieder
die weiche Prüfung.
- Der **Container** im Offline-Abzug ist „mp4", nicht die alte Schachtel.
Sonst entschiede der Player anhand einer Datei, die es so nicht mehr gibt.
- Der **Tonkodierer** bleibt leer. Ob aus DTS dann AAC oder AC3 wird, sagt der
Server vorher nicht; eine Behauptung wäre schlechter als die Lücke, und die
Spuren liest der Player ohnehin aus der Datei.
**Die Frage „kann das der Systemplayer?" steht jetzt an einer Stelle.**
`DeviceCapabilities.opens(container:videoCodec:audioCodec:)` — dieselbe Liste,
nach der auch beim Abspielen entschieden wird. Was der Server nicht nennt,
gilt als passend: vorsichtshalber umzupacken hiesse, aus einer tadellosen MP4
eine neu gerechnete Datei zu machen. Fünf Tests.
NOCH NICHT AN EINEM ECHTEN SERVER GEPRÜFT: ob Jellyfin das Kopieren hier
tatsächlich wählt, verrät erst die Dateigröße des ersten Downloads.
140 Tests in FlixyPlayback, iOS und tvOS bauen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b1973c6890
commit
ef2596e2bb
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import FlixyDownloads
|
||||
import FlixyPlayback
|
||||
import MediaKit
|
||||
|
||||
/// Übersetzt Titel vom Server in Download-Aufträge.
|
||||
@@ -105,8 +106,18 @@ struct DownloadService {
|
||||
guard let part = media.files.first else {
|
||||
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noPart"))
|
||||
}
|
||||
// **Was offline liegt, muss der Systemplayer öffnen können.** Online
|
||||
// springt für eine Matroska das Umpacken ein; auf dem Gerät gibt es
|
||||
// hinterher niemanden mehr, der das nachholen könnte — und seit es
|
||||
// keine zweite Engine mehr gibt, wäre die Datei schlicht stumm und
|
||||
// schwarz. Also wird schon beim Herunterladen umgepackt.
|
||||
let umpacken = quality == .original && !systemPlayerOpens(media: media, part: part)
|
||||
|
||||
let url: URL?
|
||||
switch quality {
|
||||
case .original where umpacken:
|
||||
url = backend.remuxDownloadURL(itemID: item.id, versionIndex: mediaIndex)
|
||||
?? backend.downloadURL(for: part)
|
||||
case .original:
|
||||
url = backend.downloadURL(for: part)
|
||||
case .fullHD, .hd, .low:
|
||||
@@ -143,8 +154,11 @@ struct DownloadService {
|
||||
// den Metadaten des Servers. Die umgerechneten Stufen sind aus
|
||||
// Bitrate und Dauer hochgerechnet — und was hochgerechnet ist,
|
||||
// darf abweichen, ohne dass die Datei kaputt wäre.
|
||||
expectedIsExact: quality == .original && part.size != nil,
|
||||
offlineMedia: offlineMedia(for: item, media: media, part: part)
|
||||
// Umgepackt stimmt die Größe des Servers nicht mehr: dieselben
|
||||
// Ströme in einer anderen Hülle wiegen anders. Dann ist sie wieder
|
||||
// eine Schätzung — und die weiche Prüfung die richtige.
|
||||
expectedIsExact: quality == .original && !umpacken && part.size != nil,
|
||||
offlineMedia: offlineMedia(for: item, media: media, part: part, remuxed: umpacken)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -239,11 +253,32 @@ struct DownloadService {
|
||||
}
|
||||
|
||||
/// Der Abzug der Serverangaben, den der Player offline braucht.
|
||||
private func offlineMedia(for item: MediaItem, media: MediaVersion, part: MediaFile) -> OfflineMedia {
|
||||
OfflineMedia(
|
||||
/// Ob AVFoundation die Datei so, wie sie auf dem Server liegt, öffnet.
|
||||
private func systemPlayerOpens(media: MediaVersion, part: MediaFile) -> Bool {
|
||||
DeviceCapabilities.avFoundation.opens(
|
||||
container: part.container ?? media.container,
|
||||
videoCodec: media.videoCodec,
|
||||
audioCodec: part.selectedAudioStream?.codec ?? media.audioCodec,
|
||||
audioCodec: part.selectedAudioStream?.codec ?? media.audioCodec
|
||||
)
|
||||
}
|
||||
|
||||
private func offlineMedia(
|
||||
for item: MediaItem,
|
||||
media: MediaVersion,
|
||||
part: MediaFile,
|
||||
remuxed: Bool
|
||||
) -> OfflineMedia {
|
||||
OfflineMedia(
|
||||
// Umgepackt liegt eine MP4 auf dem Gerät, keine Matroska. Stünde
|
||||
// hier weiter die alte Schachtel, entschiede der Player anhand
|
||||
// einer Datei, die es so nicht mehr gibt.
|
||||
container: remuxed ? "mp4" : (part.container ?? media.container),
|
||||
videoCodec: media.videoCodec,
|
||||
// **Beim Umpacken wissen wir es nicht.** Der Server rechnet nur
|
||||
// um, was nicht durchpasst — ob aus DTS dann AAC oder AC3 wird,
|
||||
// sagt er vorher nicht. Eine Behauptung wäre schlechter als die
|
||||
// Lücke: der Player liest die Spuren ohnehin aus der Datei.
|
||||
audioCodec: remuxed ? nil : (part.selectedAudioStream?.codec ?? media.audioCodec),
|
||||
videoResolution: media.videoResolution,
|
||||
bitrate: media.bitrate,
|
||||
audioLanguages: part.audioStreams.compactMap(\.language),
|
||||
|
||||
@@ -39,4 +39,25 @@ public struct DeviceCapabilities: Sendable, Equatable {
|
||||
/// Platz hat. Diese drei sind die, die in echten Beständen vorkommen und
|
||||
/// deren Ströme regelmässig MP4-tauglich sind.
|
||||
public static let remuxableContainers: Set<String> = ["mkv", "matroska", "ts", "m2ts", "avi"]
|
||||
|
||||
/// Ob diese Datei so, wie sie ist, geöffnet werden kann.
|
||||
///
|
||||
/// **Hier und nicht beim Aufrufer**, weil es die Frage inzwischen zweimal
|
||||
/// gibt: beim Abspielen entscheidet sie `PlaybackDecider`, beim
|
||||
/// Herunterladen muss sie vorher beantwortet sein — was offline liegt,
|
||||
/// kann hinterher niemand mehr umpacken.
|
||||
///
|
||||
/// **Was fehlt, zählt als passend.** Nennt der Server einen Codec nicht,
|
||||
/// ist das keine Aussage über seine Untauglichkeit. Vorsichtshalber
|
||||
/// umzupacken hiesse, aus einer tadellosen MP4 eine neu gerechnete Datei
|
||||
/// zu machen.
|
||||
public func opens(container: String?, videoCodec: String?, audioCodec: String?) -> Bool {
|
||||
func passt(_ wert: String?, in liste: Set<String>) -> Bool {
|
||||
guard let wert, !wert.isEmpty else { return true }
|
||||
return liste.contains(wert.lowercased())
|
||||
}
|
||||
return passt(container, in: containers)
|
||||
&& passt(videoCodec, in: videoCodecs)
|
||||
&& passt(audioCodec, in: audioCodecs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,6 +378,37 @@ public func isInLocalNetwork() async -> Bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// Dieselbe Datei in einer MP4-Hülle.
|
||||
///
|
||||
/// **Für Downloads, die sonst offline unspielbar wären.** Eine Matroska
|
||||
/// mit H.264 und AC3 ist inhaltlich tadellos — nur die Schachtel öffnet
|
||||
/// AVFoundation nicht. Online hilft derselbe Weg schon
|
||||
/// (`PlaybackDecision.remux`); auf dem Gerät gibt es hinterher niemanden
|
||||
/// mehr, der umpacken könnte. Also beim Herunterladen.
|
||||
///
|
||||
/// `allowVideoStreamCopy`/`allowAudioStreamCopy` sind der ganze Punkt: der
|
||||
/// Server soll die Ströme **durchreichen**, wenn sie in die Liste passen,
|
||||
/// und nur umrechnen, was nicht passt. Das Bild bleibt damit das Original;
|
||||
/// nur DTS oder TrueHD werden zu AC3.
|
||||
///
|
||||
/// NOCH NICHT AN EINEM ECHTEN SERVER GEPRÜFT: ob Jellyfin das Kopieren
|
||||
/// hier tatsächlich wählt, steht erst nach dem ersten Download fest — die
|
||||
/// Dateigröße verrät es.
|
||||
public nonisolated func remuxDownloadURL(itemID: String, versionIndex: Int) -> URL? {
|
||||
client.url(
|
||||
path: "/Videos/\(itemID)/stream.mp4",
|
||||
query: [
|
||||
URLQueryItem(name: "videoCodec", value: "h264,hevc"),
|
||||
URLQueryItem(name: "audioCodec", value: "aac,ac3,eac3"),
|
||||
URLQueryItem(name: "allowVideoStreamCopy", value: "true"),
|
||||
URLQueryItem(name: "allowAudioStreamCopy", value: "true"),
|
||||
URLQueryItem(name: "TranscodingContainer", value: "mp4"),
|
||||
URLQueryItem(name: "TranscodingMaxAudioChannels", value: "6"),
|
||||
],
|
||||
includingToken: account.token
|
||||
)
|
||||
}
|
||||
|
||||
/// Eine verkleinerte Fassung zum Herunterladen.
|
||||
///
|
||||
/// **Fortlaufendes MP4, nicht HLS.** Zum Abspielen liefert Jellyfin eine
|
||||
|
||||
@@ -89,6 +89,17 @@ public protocol MediaBackend: Sendable {
|
||||
resolution: String?
|
||||
) -> URL?
|
||||
|
||||
/// Die Adresse für dieselbe Datei in einer **anderen Schachtel**.
|
||||
///
|
||||
/// Gemeint ist Umpacken, nicht Umrechnen: dieselben Ströme, eine Hülle,
|
||||
/// die der Systemplayer öffnen kann. Nötig für Dateien, die offline sonst
|
||||
/// unspielbar wären — auf dem Gerät gibt es keinen Server mehr, der
|
||||
/// nachträglich helfen könnte.
|
||||
///
|
||||
/// `nil`, wenn der Server das nicht anbietet. Dann bleibt das Original,
|
||||
/// und der Titel ist offline nur so gut wie der Systemplayer.
|
||||
nonisolated func remuxDownloadURL(itemID: String, versionIndex: Int) -> URL?
|
||||
|
||||
// MARK: - Abspielen
|
||||
|
||||
/// Lässt den Server eine Wiedergabe vorbereiten und nennt die Adresse.
|
||||
@@ -178,6 +189,12 @@ public struct MediaPage: Sendable, Equatable {
|
||||
}
|
||||
|
||||
public extension MediaBackend {
|
||||
/// Vorgabe: der Server kann es nicht.
|
||||
///
|
||||
/// So muss nicht jeder Server eine Adresse erfinden, die es bei ihm nicht
|
||||
/// gibt — und wer es anbietet, sagt es ausdrücklich.
|
||||
nonisolated func remuxDownloadURL(itemID: String, versionIndex: Int) -> URL? { nil }
|
||||
|
||||
/// Der allgemeine Weg: Staffeln, dann je Staffel die Folgen.
|
||||
///
|
||||
/// Langsam, aber überall gültig — wer es besser kann, überschreibt es.
|
||||
|
||||
@@ -375,3 +375,38 @@ struct RemuxDecisionTests {
|
||||
#expect(entscheidung.method == .directPlay(engine: .vlc))
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("Was der Systemplayer öffnet")
|
||||
struct DeviceCapabilityTests {
|
||||
private let kann = DeviceCapabilities.avFoundation
|
||||
|
||||
@Test("Eine MP4 mit H.264 und AAC geht")
|
||||
func plainFilePasses() {
|
||||
#expect(kann.opens(container: "mp4", videoCodec: "h264", audioCodec: "aac"))
|
||||
}
|
||||
|
||||
@Test("Die Schachtel allein kann im Weg stehen")
|
||||
func containerBlocks() {
|
||||
// Inhaltlich tadellos, nur Matroska aussen — genau der Fall, für den
|
||||
// ein Download umgepackt wird.
|
||||
#expect(!kann.opens(container: "mkv", videoCodec: "h264", audioCodec: "ac3"))
|
||||
}
|
||||
|
||||
@Test("Der Ton allein kann im Weg stehen")
|
||||
func audioBlocks() {
|
||||
#expect(!kann.opens(container: "mp4", videoCodec: "h264", audioCodec: "dts"))
|
||||
}
|
||||
|
||||
@Test("Grossschreibung ändert nichts")
|
||||
func caseDoesNotMatter() {
|
||||
#expect(kann.opens(container: "MP4", videoCodec: "H264", audioCodec: "AAC"))
|
||||
}
|
||||
|
||||
@Test("Was der Server nicht nennt, gilt als passend")
|
||||
func missingValuesPass() {
|
||||
// Vorsichtshalber umzupacken hiesse, aus einer tadellosen Datei eine
|
||||
// neu gerechnete zu machen.
|
||||
#expect(kann.opens(container: nil, videoCodec: nil, audioCodec: nil))
|
||||
#expect(kann.opens(container: "mp4", videoCodec: "h264", audioCodec: ""))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user