Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 210 additions & 0 deletions iosApp/Tests/OfflinePreparedTrackInventoryTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
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 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(
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"))
}
}
2 changes: 2 additions & 0 deletions iosApp/Tests/SubtitleDisplayOrderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
10 changes: 10 additions & 0 deletions iosApp/iosApp/Downloads/DownloadModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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?
Expand Down
15 changes: 11 additions & 4 deletions iosApp/iosApp/Downloads/OfflinePlayback.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -86,7 +89,8 @@ enum OfflinePlaybackBuilder {
prepared: prepared,
downloadId: record.id,
mediaItemId: leafId,
posterFileURL: posterFileURL
posterFileURL: posterFileURL,
isServerPreparedFile: manifest.isServerPreparedFile
)
}

Expand All @@ -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
))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
9 changes: 9 additions & 0 deletions iosApp/iosApp/Screens/Player/AetherLoadSpec.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
81 changes: 81 additions & 0 deletions iosApp/iosApp/Screens/Player/OfflinePreparedTrackInventory.swift
Original file line number Diff line number Diff line change
@@ -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.normalizedLanguageCode ?? 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
)
Comment on lines +41 to +44

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Bug high

Unwanted subtitle playback persists because clearing isDefault does not undo Aether's muxer-default selection: when the offline manifest has no subtitle policy, the auto-resolver returns .noChange and adoptAetherInventory retains engine.activeSubtitleTrackIndex. Clear the engine's selection after the offline load when there is no explicit or automatic subtitle choice, before publishing the inventory.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

​

​

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't happen. AetherEngine only activates a subtitle at load through LoadOptions.preferredSubtitleLanguages (AetherEngine+Subtitles.swift, the preferred-subtitle auto-select); a container's default disposition never selects one. Offline loads with no subtitle preference leave activeSubtitleTrackIndex nil, so adoptAetherInventory has nothing to keep. testMuxerDefaultDoesNotEnableSubtitlesOffline covers the resolver side. On an iOS simulator, prepared downloads whose first MP4 subtitle carries the muxer default started with subtitles off during cue windows. Clearing isDefault here only keeps the menu and later preference resolution from treating that flag as intent.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You’re right. I conflated the muxer’s default disposition with subtitle activation. On an offline load without a preferred subtitle language, activeSubtitleTrackIndex remains nil, so the default flag does not turn subtitles on. The resolver test and simulator check support that distinction. Clearing isDefault here prevents the flag from being treated as intent in the menu or later preference resolution. I withdraw that concern.

}
}

/// Aether names an untitled stream "<LANG> (<codec>)", or
/// "Track <id> (<codec>)" 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
)
}
}
2 changes: 1 addition & 1 deletion iosApp/iosApp/Screens/Player/PlayerTrack.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading