From 23c924ede1788bdcce3a122bcf326221f61fb534 Mon Sep 17 00:00:00 2001 From: Rhainland Date: Tue, 29 Sep 2026 13:58:24 +0200 Subject: [PATCH 1/2] fix(offline): show every track of server-prepared downloads Server-prepared (remux/transcode) downloads now carry every audio track and embedded subtitle. Label their audio from the offline manifest, ignore the MP4 muxer's default flag on subtitle tracks, classify sup sidecars as PGS, show sidecar titles, and fall back to the file's default track when a saved audio position is out of range. --- .../OfflinePreparedTrackInventoryTests.swift | 201 ++++++++++++++++++ iosApp/Tests/SubtitleDisplayOrderTests.swift | 2 + iosApp/iosApp/Downloads/DownloadModels.swift | 10 + iosApp/iosApp/Downloads/OfflinePlayback.swift | 15 +- .../Detail/DetailPlaybackFormatting.swift | 2 +- .../Screens/Player/AetherLoadSpec.swift | 9 + .../OfflinePreparedTrackInventory.swift | 81 +++++++ .../iosApp/Screens/Player/PlayerTrack.swift | 2 +- .../Screens/Player/PlayerViewModel.swift | 48 ++++- .../Subtitles/SubtitleCodecClassifier.swift | 20 +- .../Subtitles/SubtitleDisplayOrder.swift | 2 +- 11 files changed, 374 insertions(+), 18 deletions(-) create mode 100644 iosApp/Tests/OfflinePreparedTrackInventoryTests.swift create mode 100644 iosApp/iosApp/Screens/Player/OfflinePreparedTrackInventory.swift diff --git a/iosApp/Tests/OfflinePreparedTrackInventoryTests.swift b/iosApp/Tests/OfflinePreparedTrackInventoryTests.swift new file mode 100644 index 000000000..a65785979 --- /dev/null +++ b/iosApp/Tests/OfflinePreparedTrackInventoryTests.swift @@ -0,0 +1,201 @@ +import XCTest +@testable import Silo + +/// Offline playback of a server-prepared MP4: every source audio track, timed +/// text inside the file, and ASS/PGS sidecars from the manifest. +final class OfflinePreparedTrackInventoryTests: XCTestCase { + + // MARK: - Factories + + private func track( + id: Int64, + kind: PlayerTrack.Kind, + title: String?, + lang: String?, + codec: String?, + isDefault: Bool = false, + isForced: Bool = false, + isExternal: Bool = false + ) -> PlayerTrack { + PlayerTrack( + trackId: id, + kind: kind, + title: title, + lang: lang, + codec: codec, + audioChannelCount: kind == .audio ? 2 : nil, + bitrate: nil, + isDefault: isDefault, + isForced: isForced, + isHearingImpaired: false, + isExternal: isExternal, + isSelected: false, + ffIndex: isExternal ? nil : Int(id), + srcId: nil + ) + } + + private func manifestAudio(title: String?, language: String?) -> AudioTrack { + AudioTrack( + index: nil, + codec: "aac", + channels: 2, + channelLayout: "stereo", + bitrate: nil, + sampleRate: nil, + language: language, + title: title, + embeddedTitle: nil, + isDefault: nil + ) + } + + private func manifest(deliveryFormat: String?) throws -> OfflineManifest { + var fields = [ + "\"download_id\": \"d1\"", + "\"content_id\": \"c1\"", + "\"type\": \"movie\"", + "\"title\": \"Movie\"", + "\"quality\": \"medium\"", + "\"media_file_id\": \"42\"" + ] + if let deliveryFormat { fields.append("\"delivery_format\": \"\(deliveryFormat)\"") } + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + return try decoder.decode( + OfflineManifest.self, + from: Data("{\(fields.joined(separator: ","))}".utf8) + ) + } + + // MARK: - Prepared-file detection + + func testOnlyRemuxAndTranscodeDeliveriesArePreparedFiles() throws { + XCTAssertTrue(try manifest(deliveryFormat: "transcode").isServerPreparedFile) + XCTAssertTrue(try manifest(deliveryFormat: "remux").isServerPreparedFile) + XCTAssertFalse(try manifest(deliveryFormat: "original").isServerPreparedFile) + XCTAssertFalse(try manifest(deliveryFormat: nil).isServerPreparedFile) + } + + // MARK: - Audio + + func testAudioTitlesComeFromTheManifestByPosition() { + let probed = [ + track(id: 1, kind: .audio, title: "ENG (aac)", lang: "eng", codec: "aac", isDefault: true), + track(id: 2, kind: .audio, title: "JPN (aac)", lang: "jpn", codec: "aac"), + track(id: 3, kind: .audio, title: "Track 3 (aac)", lang: nil, codec: "aac"), + ] + let described = [ + manifestAudio(title: "English 5.1", language: "en"), + manifestAudio(title: nil, language: "ja"), + manifestAudio(title: "Commentary", language: "en"), + ] + + let tracks = OfflinePreparedTrackInventory.audioTracks(probed, manifestTracks: described) + + XCTAssertEqual(tracks.map(\.title), ["English 5.1", nil, "Commentary"]) + XCTAssertEqual(tracks.map(\.lang), ["eng", "jpn", "en"]) + XCTAssertEqual(tracks.map(\.primaryLabel), ["English 5.1", "Japanese", "Commentary"]) + XCTAssertEqual(tracks.map(\.trackId), [1, 2, 3]) + XCTAssertEqual(tracks.map(\.isDefault), [true, false, false]) + } + + func testRealAudioTitleIsKept() { + let probed = [track(id: 1, kind: .audio, title: "Director's Cut Mix", lang: "eng", codec: "aac")] + let tracks = OfflinePreparedTrackInventory.audioTracks( + probed, + manifestTracks: [manifestAudio(title: "English", language: "en")] + ) + XCTAssertEqual(tracks.first?.title, "Director's Cut Mix") + } + + func testMismatchedManifestDoesNotRelabelByPosition() { + let probed = [ + track(id: 1, kind: .audio, title: "ENG (aac)", lang: "eng", codec: "aac"), + track(id: 2, kind: .audio, title: "FRE (aac)", lang: "fre", codec: "aac"), + ] + let tracks = OfflinePreparedTrackInventory.audioTracks( + probed, + manifestTracks: [manifestAudio(title: "Commentary", language: "en")] + ) + XCTAssertEqual(tracks.map(\.title), [nil, nil]) + XCTAssertEqual(tracks.map(\.primaryLabel), ["English", "French"]) + } + + // MARK: - Default audio ordinal + + func testManifestAudioOrdinalResolvesToTheProbedStream() { + XCTAssertEqual(AetherLoadSpec.offlineAudioStreamIndex(manifestOrdinal: 1, probedTrackIDs: [1, 2, 3]), 2) + XCTAssertEqual(AetherLoadSpec.offlineAudioStreamIndex(manifestOrdinal: 0, probedTrackIDs: [1]), 1) + } + + func testOutOfRangeAudioOrdinalFallsBackToTheFileDefault() { + XCTAssertNil(AetherLoadSpec.offlineAudioStreamIndex(manifestOrdinal: 3, probedTrackIDs: [1, 2, 3])) + XCTAssertNil(AetherLoadSpec.offlineAudioStreamIndex(manifestOrdinal: -1, probedTrackIDs: [1])) + XCTAssertNil(AetherLoadSpec.offlineAudioStreamIndex(manifestOrdinal: 0, probedTrackIDs: [])) + } + + // MARK: - Subtitles + + private var preparedSubtitles: [PlayerTrack] { + [ + // The MP4 muxer flags the first timed-text track default. + track(id: 4, kind: .sub, title: "ENG (mov_text)", lang: "eng", codec: "mov_text", isDefault: true), + track(id: 5, kind: .sub, title: "SDH", lang: "eng", codec: "mov_text"), + track(id: 6, kind: .sub, title: "FRE (mov_text)", lang: "fre", codec: "mov_text", isForced: true), + track(id: 0x4000_0000, kind: .sub, title: "Japanese", lang: "ja", codec: "ass", isExternal: true), + ] + } + + func testTimedTextDropsTheMuxerDefaultAndSynthesizedNames() { + let tracks = OfflinePreparedTrackInventory.subtitleTracks(preparedSubtitles) + + XCTAssertEqual(tracks.map(\.isDefault), [false, false, false, false]) + XCTAssertEqual(tracks.map(\.title), [nil, "SDH", nil, "Japanese"]) + XCTAssertEqual(tracks.map(\.isForced), [false, false, true, false]) + XCTAssertEqual(tracks.map(\.languageFirstPrimaryLabel), ["English", "English", "French", "Japanese"]) + XCTAssertNil(tracks[0].attributesLabel?.range(of: "Default")) + } + + func testMuxerDefaultDoesNotEnableSubtitlesOffline() { + // Offline manifests carry no server-resolved subtitle policy, so the + // snapshot is empty; nothing may turn a track on from the file flag. + let pick = SubtitleAutoResolver.resolve(.init( + preferredLanguage: nil, + mode: nil, + showForced: false, + trackSignature: nil, + availableSubtitles: OfflinePreparedTrackInventory.subtitleTracks(preparedSubtitles), + currentAudioLanguage: "eng" + )) + XCTAssertEqual(pick, .noChange) + } + + func testForcedPreferenceSelectsTheForcedTimedTextTrack() { + let pick = SubtitleAutoResolver.resolve(.init( + preferredLanguage: "fr", + mode: .auto, + showForced: true, + trackSignature: nil, + availableSubtitles: OfflinePreparedTrackInventory.subtitleTracks(preparedSubtitles), + currentAudioLanguage: "fre" + )) + XCTAssertEqual(pick, .select(OfflinePreparedTrackInventory.subtitleTracks(preparedSubtitles)[2])) + } + + // MARK: - Sidecar codecs + + func testSupSidecarClassifiesAsPGS() { + let codec = SubtitleCodecClassifier.externalTrackCodec(engineCodec: "subrip", declaredFormat: "sup") + XCTAssertEqual(codec, "sup") + XCTAssertTrue(SubtitleCodecClassifier.isBitmap(codec)) + } + + func testRecognizedSidecarCodecsKeepTheEngineName() { + XCTAssertEqual(SubtitleCodecClassifier.externalTrackCodec(engineCodec: "ass", declaredFormat: "ass"), "ass") + XCTAssertEqual(SubtitleCodecClassifier.externalTrackCodec(engineCodec: "subrip", declaredFormat: "srt"), "subrip") + XCTAssertEqual(SubtitleCodecClassifier.externalTrackCodec(engineCodec: "webvtt", declaredFormat: "vtt"), "webvtt") + XCTAssertEqual(SubtitleCodecClassifier.externalTrackCodec(engineCodec: "subrip", declaredFormat: nil), "subrip") + XCTAssertFalse(SubtitleCodecClassifier.isBitmap("subrip")) + } +} diff --git a/iosApp/Tests/SubtitleDisplayOrderTests.swift b/iosApp/Tests/SubtitleDisplayOrderTests.swift index bc3b372e4..c0d7d1da0 100644 --- a/iosApp/Tests/SubtitleDisplayOrderTests.swift +++ b/iosApp/Tests/SubtitleDisplayOrderTests.swift @@ -133,6 +133,8 @@ final class SubtitleDisplayOrderTests: XCTestCase { XCTAssertLessThan(SubtitleDisplayOrder.formatRank("hdmv_pgs_subtitle"), SubtitleDisplayOrder.formatRank("dvd_subtitle")) XCTAssertLessThan(SubtitleDisplayOrder.formatRank("dvd_subtitle"), SubtitleDisplayOrder.formatRank(nil)) XCTAssertEqual(SubtitleDisplayOrder.formatRank("srt"), SubtitleDisplayOrder.formatRank("subrip")) + // Offline manifests name PGS sidecars by their `sup` file format. + XCTAssertEqual(SubtitleDisplayOrder.formatRank("sup"), SubtitleDisplayOrder.formatRank("pgssub")) } func testCanonicalLanguageKey() { diff --git a/iosApp/iosApp/Downloads/DownloadModels.swift b/iosApp/iosApp/Downloads/DownloadModels.swift index 743f5b684..a26af0ba2 100644 --- a/iosApp/iosApp/Downloads/DownloadModels.swift +++ b/iosApp/iosApp/Downloads/DownloadModels.swift @@ -199,6 +199,15 @@ struct OfflineManifest: Codable, Hashable, Sendable { /// the former public `format` name. var format: String { quality } + /// Whether the downloaded file is the server's remux/transcode output + /// rather than the original source file. + var isServerPreparedFile: Bool { + guard let delivery = deliveryFormat? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased(), !delivery.isEmpty else { return false } + return delivery != "original" + } + private enum CodingKeys: String, CodingKey { case downloadId case contentId @@ -386,6 +395,7 @@ struct OfflineAudioTrack: Codable, Hashable, Sendable { /// origin (`external:{index}` or `downloaded:{id}`). struct OfflineSubtitle: Codable, Hashable, Sendable { let language: String? + let title: String? let format: String? let forced: Bool? let hearingImpaired: Bool? diff --git a/iosApp/iosApp/Downloads/OfflinePlayback.swift b/iosApp/iosApp/Downloads/OfflinePlayback.swift index 1b796d30e..151227c6d 100644 --- a/iosApp/iosApp/Downloads/OfflinePlayback.swift +++ b/iosApp/iosApp/Downloads/OfflinePlayback.swift @@ -32,6 +32,9 @@ struct OfflinePreparedPlayback { /// Cached poster on disk, so the Now Playing widget gets artwork /// without a catalog fetch. let posterFileURL: URL? + /// The file is an MP4 the server remuxed or transcoded, not the source. + /// Its container metadata needs `OfflinePreparedTrackInventory`. + let isServerPreparedFile: Bool } /// Synthesizes the same `PreparedPlayback` the online path produces, but @@ -86,7 +89,8 @@ enum OfflinePlaybackBuilder { prepared: prepared, downloadId: record.id, mediaItemId: leafId, - posterFileURL: posterFileURL + posterFileURL: posterFileURL, + isServerPreparedFile: manifest.isServerPreparedFile ) } @@ -110,12 +114,15 @@ enum OfflinePlaybackBuilder { index: index, language: subtitle.language, codec: subtitle.format, - label: nil, + label: subtitle.title, // Manifest subtitles are always sidecar files - // (`external:{i}` / `downloaded:{id}`), never embedded, so - // they must survive Aether's embedded-track inventory merge. + // (`external:{i}`, `downloaded:{id}`, or an `embedded:{i}` + // ASS/PGS track extracted for a prepared MP4), never streams + // of the downloaded file, so they must survive Aether's + // embedded-track inventory merge. source: "external", forced: subtitle.forced, + hearingImpaired: subtitle.hearingImpaired, url: fileURL.absoluteString )) } diff --git a/iosApp/iosApp/Screens/Detail/DetailPlaybackFormatting.swift b/iosApp/iosApp/Screens/Detail/DetailPlaybackFormatting.swift index 8ae67bda9..a84747081 100644 --- a/iosApp/iosApp/Screens/Detail/DetailPlaybackFormatting.swift +++ b/iosApp/iosApp/Screens/Detail/DetailPlaybackFormatting.swift @@ -601,7 +601,7 @@ enum DetailPlaybackFormatting { if codec == "ass" || codec.contains("ass") { return "ASS" } if codec == "ssa" || codec.contains("ssa") { return "SSA" } if codec == "vtt" || codec.contains("webvtt") { return "WebVTT" } - if codec.contains("pgs") || codec.contains("hdmv") { return "PGS" } + if codec == "sup" || codec.contains("pgs") || codec.contains("hdmv") { return "PGS" } if codec.contains("dvd") || codec.contains("vobsub") { return "VobSub" } if codec.contains("mov_text") || codec.contains("tx3g") { return "TX3G" } return codec.uppercased() diff --git a/iosApp/iosApp/Screens/Player/AetherLoadSpec.swift b/iosApp/iosApp/Screens/Player/AetherLoadSpec.swift index 315c498a2..244f3f714 100644 --- a/iosApp/iosApp/Screens/Player/AetherLoadSpec.swift +++ b/iosApp/iosApp/Screens/Player/AetherLoadSpec.swift @@ -162,6 +162,15 @@ struct AetherLoadSpec { /// Also Aether's own default; see `PlayerSettings.deinterlaceFieldRate`. static let defaultDeinterlaceFieldRate: DeinterlaceFieldRate = .field + /// Resolve a download manifest's audio ordinal (`selected_audio_track_index`, + /// a position among the delivered file's audio streams) to the stream id + /// Aether selects by. Nil when the ordinal is outside the probed file, so + /// the load falls back to the file's default track. + static func offlineAudioStreamIndex(manifestOrdinal: Int, probedTrackIDs: [Int]) -> Int32? { + guard probedTrackIDs.indices.contains(manifestOrdinal) else { return nil } + return Int32(exactly: probedTrackIDs[manifestOrdinal]) + } + @MainActor init( offlineURL: URL, diff --git a/iosApp/iosApp/Screens/Player/OfflinePreparedTrackInventory.swift b/iosApp/iosApp/Screens/Player/OfflinePreparedTrackInventory.swift new file mode 100644 index 000000000..3143f9231 --- /dev/null +++ b/iosApp/iosApp/Screens/Player/OfflinePreparedTrackInventory.swift @@ -0,0 +1,81 @@ +import Foundation + +/// Local-playback corrections to the track inventory AetherEngine probes. +/// +/// A server-prepared download (remux or transcode) is an MP4 the server muxed +/// itself, and its container metadata is not the source's: +/// - MP4 stores a track title only as its handler name, which FFmpeg does not +/// read back as `title`, so Aether falls back to a synthesized +/// "ENG (aac)" / "Track 2 (mov_text)" name; +/// - the MP4 muxer marks the first timed-text track default whatever the +/// source said, so that flag carries no authoring intent. +/// The manifest's `audio_tracks` list the delivered file's audio streams in +/// order, so it restores the audio titles. Original-quality downloads play the +/// source file itself and are left exactly as probed. +enum OfflinePreparedTrackInventory { + + /// Replace Aether's synthesized audio names with the manifest's titles. + /// When the counts disagree the file is not the one the manifest + /// describes, so ordinals cannot be trusted and the tracks stay as probed. + static func audioTracks(_ tracks: [PlayerTrack], manifestTracks: [AudioTrack]?) -> [PlayerTrack] { + let described = manifestTracks ?? [] + guard described.count == tracks.count else { + return tracks.map { track in + track.replacing(title: hasSynthesizedName(track) ? nil : track.title) + } + } + return zip(tracks, described).map { track, manifestTrack in + guard hasSynthesizedName(track) else { return track } + return track.replacing( + title: PlayerTrack.normalizedText(manifestTrack.title), + lang: track.lang ?? PlayerTrack.normalizedText(manifestTrack.language) + ) + } + } + + /// Drop the muxer's default flag and synthesized names from the timed-text + /// tracks inside the file. Sidecars keep the manifest's own metadata. + static func subtitleTracks(_ tracks: [PlayerTrack]) -> [PlayerTrack] { + tracks.map { track in + guard !track.isExternal else { return track } + return track.replacing( + title: hasSynthesizedName(track) ? nil : track.title, + isDefault: false + ) + } + } + + /// Aether names an untitled stream " ()", or + /// "Track ()" without a language (`Demuxer.trackInfo`). + static func hasSynthesizedName(_ track: PlayerTrack) -> Bool { + guard let title = track.title, let codec = track.codec else { return false } + if let lang = track.lang, title == "\(lang.uppercased()) (\(codec))" { + return true + } + if let ffIndex = track.ffIndex, title == "Track \(ffIndex) (\(codec))" { + return true + } + return false + } +} + +private extension PlayerTrack { + func replacing(title: String?, lang: String? = nil, isDefault: Bool? = nil) -> PlayerTrack { + PlayerTrack( + trackId: trackId, + kind: kind, + title: title, + lang: lang ?? self.lang, + codec: codec, + audioChannelCount: audioChannelCount, + bitrate: bitrate, + isDefault: isDefault ?? self.isDefault, + isForced: isForced, + isHearingImpaired: isHearingImpaired, + isExternal: isExternal, + isSelected: isSelected, + ffIndex: ffIndex, + srcId: srcId + ) + } +} diff --git a/iosApp/iosApp/Screens/Player/PlayerTrack.swift b/iosApp/iosApp/Screens/Player/PlayerTrack.swift index 6dcc5cd85..888ec82f1 100644 --- a/iosApp/iosApp/Screens/Player/PlayerTrack.swift +++ b/iosApp/iosApp/Screens/Player/PlayerTrack.swift @@ -194,7 +194,7 @@ struct PlayerTrack: Identifiable, Equatable, Hashable { return locale.localizedString(forLanguageCode: code)?.capitalized ?? code.uppercased() } - private static func normalizedText(_ value: String?) -> String? { + static func normalizedText(_ value: String?) -> String? { guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { return nil diff --git a/iosApp/iosApp/Screens/Player/PlayerViewModel.swift b/iosApp/iosApp/Screens/Player/PlayerViewModel.swift index c2762d656..57a0a6a45 100644 --- a/iosApp/iosApp/Screens/Player/PlayerViewModel.swift +++ b/iosApp/iosApp/Screens/Player/PlayerViewModel.swift @@ -702,6 +702,7 @@ class PlayerViewModel { private struct OfflinePlaybackContext { let downloadId: String let mediaItemId: String + let isServerPreparedFile: Bool } private var offlinePlaybackContext: OfflinePlaybackContext? /// Mirrors the server's default watched threshold (90%) so an offline @@ -3015,10 +3016,17 @@ class PlayerViewModel { try AetherEngine.probe(url: localURL) }.value try requireCurrentStreamLoad(expectedStreamLoadGeneration) - guard probe.audioTracks.indices.contains(ordinal) else { - throw AetherLoadSpec.ValidationError.invalidAudioTrackIndex(ordinal) + audioStreamIndex = AetherLoadSpec.offlineAudioStreamIndex( + manifestOrdinal: ordinal, + probedTrackIDs: probe.audioTracks.map(\.id) + ) + if audioStreamIndex == nil { + // A download has no server to replan against, so a stale + // manifest ordinal must not fail playback outright. + Self.logger.warning( + "Offline audio ordinal \(ordinal, privacy: .public) is outside the file's \(probe.audioTracks.count, privacy: .public) audio tracks; using the file default" + ) } - audioStreamIndex = Int32(probe.audioTracks[ordinal].id) } else { audioStreamIndex = nil } @@ -3196,19 +3204,37 @@ class PlayerViewModel { srcId: ordinal ) } - audioTracks = ApplePlaybackV3PlanAdapter.audioPickerTracks( + let isServerPreparedFile = offlinePlaybackContext?.isServerPreparedFile == true + let pickerAudioTracks = ApplePlaybackV3PlanAdapter.audioPickerTracks( aetherTracks: aetherAudioTracks, plan: activePreparedProtocolV3?.plan, version: currentSelectedVersion ) - let aetherSubtitleTracks = engine.subtitleTracks.map { track in + audioTracks = isServerPreparedFile + ? OfflinePreparedTrackInventory.audioTracks( + pickerAudioTracks, + manifestTracks: currentSelectedVersion?.audioTracks + ) + : pickerAudioTracks + let probedSubtitleTracks = engine.subtitleTracks.map { track in let appTrackID = aetherPlaybackController.appSubtitleID(forAetherID: track.id) + let sidecarIndex = track.isExternal + ? SubtitleTrackIdSpace.sidecarIndex(from: appTrackID) + : nil + let codec = track.isExternal + ? SubtitleCodecClassifier.externalTrackCodec( + engineCodec: track.codec, + declaredFormat: sidecarIndex.flatMap { index in + knownExternalSubtitles.first { $0.index == index }?.codec + } + ) + : track.codec return PlayerTrack( trackId: appTrackID, kind: .sub, title: track.name, lang: track.language, - codec: track.codec, + codec: codec, audioChannelCount: nil, bitrate: nil, isDefault: track.isDefault, @@ -3217,11 +3243,12 @@ class PlayerViewModel { isExternal: track.isExternal, isSelected: engine.activeSubtitleTrackIndex == track.id, ffIndex: track.isExternal ? nil : track.id, - srcId: track.isExternal - ? SubtitleTrackIdSpace.sidecarIndex(from: appTrackID) - : nil + srcId: sidecarIndex ) } + let aetherSubtitleTracks = isServerPreparedFile + ? OfflinePreparedTrackInventory.subtitleTracks(probedSubtitleTracks) + : probedSubtitleTracks // V3 inventory is an authoritative menu, not a preload list. Aether // receives only the current plan's artifact; presenting its probed // embedded tracks alongside every server sidecar would create two @@ -4245,7 +4272,8 @@ class PlayerViewModel { ) preparedOfflineContext = OfflinePlaybackContext( downloadId: offline.downloadId, - mediaItemId: offline.mediaItemId + mediaItemId: offline.mediaItemId, + isServerPreparedFile: offline.isServerPreparedFile ) preparedOfflineArtworkURL = offline.posterFileURL prepared = offline.prepared diff --git a/iosApp/iosApp/Screens/Player/Subtitles/SubtitleCodecClassifier.swift b/iosApp/iosApp/Screens/Player/Subtitles/SubtitleCodecClassifier.swift index cf42b9e07..9934d8103 100644 --- a/iosApp/iosApp/Screens/Player/Subtitles/SubtitleCodecClassifier.swift +++ b/iosApp/iosApp/Screens/Player/Subtitles/SubtitleCodecClassifier.swift @@ -1,8 +1,10 @@ import Foundation enum SubtitleCodecClassifier { + /// `sup` is the PGS elementary-stream file format: offline manifests + /// name PGS sidecars by it, so it classifies as PGS. private static let bitmapCodecs: Set = [ - "hdmv_pgs_subtitle", "pgssub", "pgs", + "hdmv_pgs_subtitle", "pgssub", "pgs", "sup", "dvd_subtitle", "dvdsub", "vobsub", "dvb_subtitle", "dvbsub", "xsub", ] @@ -19,4 +21,20 @@ enum SubtitleCodecClassifier { || codec.contains("dvb_sub") || codec.contains("vobsub") } + + /// The codec to publish for a host-registered sidecar. AetherEngine names + /// an external track from its format hint and reports "subrip" for any + /// hint it does not know, including `sup`; the declared format is the + /// truth there, so a PGS sidecar still classifies and ranks as bitmap. + static func externalTrackCodec(engineCodec: String?, declaredFormat: String?) -> String? { + guard engineCodec?.lowercased() == "subrip", + let declared = declaredFormat? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased(), + !declared.isEmpty, + !["srt", "subrip"].contains(declared) else { + return engineCodec + } + return declared + } } diff --git a/iosApp/iosApp/Screens/Player/Subtitles/SubtitleDisplayOrder.swift b/iosApp/iosApp/Screens/Player/Subtitles/SubtitleDisplayOrder.swift index d2fb3c75a..26f4e4c12 100644 --- a/iosApp/iosApp/Screens/Player/Subtitles/SubtitleDisplayOrder.swift +++ b/iosApp/iosApp/Screens/Player/Subtitles/SubtitleDisplayOrder.swift @@ -104,7 +104,7 @@ enum SubtitleDisplayOrder { if codec.contains("ssa") { return 2 } if codec == "vtt" || codec.contains("webvtt") { return 3 } if codec.contains("mov_text") || codec.contains("movtext") || codec.contains("tx3g") { return 4 } - if codec.contains("pgs") || codec.contains("hdmv") { return 5 } + if codec == "sup" || codec.contains("pgs") || codec.contains("hdmv") { return 5 } if codec.contains("dvd") || codec.contains("vobsub") || codec.contains("dvb") { return 6 } return 7 } From 35c698a7b05475106263747e9030cdfb40523e86 Mon Sep 17 00:00:00 2001 From: Rhainland Date: Tue, 29 Sep 2026 14:32:56 +0200 Subject: [PATCH 2/2] fix(offline): take the manifest language for an undetermined audio track --- iosApp/Tests/OfflinePreparedTrackInventoryTests.swift | 9 +++++++++ .../Screens/Player/OfflinePreparedTrackInventory.swift | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/iosApp/Tests/OfflinePreparedTrackInventoryTests.swift b/iosApp/Tests/OfflinePreparedTrackInventoryTests.swift index a65785979..b583c9da6 100644 --- a/iosApp/Tests/OfflinePreparedTrackInventoryTests.swift +++ b/iosApp/Tests/OfflinePreparedTrackInventoryTests.swift @@ -100,6 +100,15 @@ final class OfflinePreparedTrackInventoryTests: XCTestCase { XCTAssertEqual(tracks.map(\.isDefault), [true, false, false]) } + func testUndeterminedProbedLanguageTakesTheManifestLanguage() { + let probed = [track(id: 1, kind: .audio, title: "UND (aac)", lang: "und", codec: "aac")] + let tracks = OfflinePreparedTrackInventory.audioTracks( + probed, + manifestTracks: [manifestAudio(title: nil, language: "de")] + ) + XCTAssertEqual(tracks.first?.lang, "de") + } + func testRealAudioTitleIsKept() { let probed = [track(id: 1, kind: .audio, title: "Director's Cut Mix", lang: "eng", codec: "aac")] let tracks = OfflinePreparedTrackInventory.audioTracks( diff --git a/iosApp/iosApp/Screens/Player/OfflinePreparedTrackInventory.swift b/iosApp/iosApp/Screens/Player/OfflinePreparedTrackInventory.swift index 3143f9231..b8b4de0d6 100644 --- a/iosApp/iosApp/Screens/Player/OfflinePreparedTrackInventory.swift +++ b/iosApp/iosApp/Screens/Player/OfflinePreparedTrackInventory.swift @@ -28,7 +28,7 @@ enum OfflinePreparedTrackInventory { guard hasSynthesizedName(track) else { return track } return track.replacing( title: PlayerTrack.normalizedText(manifestTrack.title), - lang: track.lang ?? PlayerTrack.normalizedText(manifestTrack.language) + lang: track.normalizedLanguageCode ?? PlayerTrack.normalizedText(manifestTrack.language) ) } }