preserve opensubtitles caption labels

This commit is contained in:
dirtydishes 2026-05-25 12:55:10 -04:00
parent 046444f9ad
commit ae996e7ffb
6 changed files with 628 additions and 5 deletions

View file

@ -40,6 +40,108 @@ struct SubtitleTrack: Equatable {
let name: String
}
enum SubtitleDisplayName {
private static let genericLabels = [
"external subtitle",
"subtitle",
"unknown"
]
private static let languageCodeAliases = [
"eng": "en",
"en": "en",
"spa": "es",
"es": "es",
"fre": "fr",
"fra": "fr",
"fr": "fr"
]
static func displayName(for candidate: SubtitleCandidate) -> String {
if let label = meaningfulDisplayText(candidate.label) {
return label
}
if let languageName = languageName(for: candidate.language) {
return languageName
}
return fallbackName(from: candidate)
}
static func name(forVLCTrackName trackName: String, preservedName: String?) -> String {
guard isGenericLabel(trackName), let preservedName else {
return trackName
}
return preservedName
}
static func isGenericLabel(_ value: String) -> Bool {
let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalized.isEmpty else {
return true
}
let lowercased = normalized.lowercased()
if genericLabels.contains(lowercased) {
return true
}
if Int(normalized) != nil {
return true
}
if lowercased.range(of: #"^track\s*\d+$"#, options: .regularExpression) != nil {
return true
}
return false
}
private static func meaningfulDisplayText(_ value: String?) -> String? {
guard let value else {
return nil
}
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !isGenericLabel(trimmed) else {
return nil
}
guard trimmed.count <= 3 else {
return trimmed
}
return languageName(for: trimmed) ?? trimmed
}
private static func languageName(for value: String?) -> String? {
guard let value else {
return nil
}
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, Int(trimmed) == nil else {
return nil
}
if trimmed.count > 3 {
return trimmed.capitalized
}
let lowercased = trimmed.lowercased()
let languageCode = languageCodeAliases[lowercased] ?? lowercased
guard let name = Locale.current.localizedString(forLanguageCode: languageCode) else {
return nil
}
return name.capitalized
}
private static func fallbackName(from candidate: SubtitleCandidate) -> String {
let trimmedLabel = candidate.label.trimmingCharacters(in: .whitespacesAndNewlines)
if !isGenericLabel(trimmedLabel) {
return trimmedLabel
}
let fileName = candidate.url.deletingPathExtension().lastPathComponent
return fileName.isEmpty ? "External Subtitle" : fileName
}
}
#if DEBUG
enum SubtitleDebugFormatter {
static func candidateSummary(_ candidates: [SubtitleCandidate]) -> String {

View file

@ -28,6 +28,8 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
private var autoSelectedSubtitleTrackID: Int32?
private var externalSubtitleBaselineTrackIDs = Set<Int32>()
private var hasPendingExternalSubtitleSelection = false
private var pendingExternalSubtitleDisplayNames: [String] = []
private var externalSubtitleDisplayNamesByTrackID: [Int32: String] = [:]
override init() {
super.init()
@ -51,6 +53,8 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
autoSelectedSubtitleTrackID = nil
externalSubtitleBaselineTrackIDs.removeAll()
hasPendingExternalSubtitleSelection = false
pendingExternalSubtitleDisplayNames.removeAll()
externalSubtitleDisplayNamesByTrackID.removeAll()
let media = VLCMedia(url: request.playbackURL)
let headerValue = request.headers
.map { "\($0.key): \($0.value)" }
@ -199,10 +203,15 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
var subtitleTracks: [SubtitleTrack] {
#if canImport(MobileVLCKit)
let names = mediaPlayer.videoSubTitlesNames as? [String] ?? []
let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber] ?? []
return zip(indexes, names).map { index, name in
SubtitleTrack(id: index.int32Value, name: name)
reconcileExternalSubtitleDisplayNames()
return rawSubtitleTracks().map { track in
SubtitleTrack(
id: track.id,
name: SubtitleDisplayName.name(
forVLCTrackName: track.name,
preservedName: externalSubtitleDisplayNamesByTrackID[track.id]
)
)
}
#else
[]
@ -229,7 +238,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
private func attachSubtitles(_ candidates: [SubtitleCandidate]) -> Int {
var attachedCount = 0
var duplicateCount = 0
let baselineTrackIDs = Set(subtitleTracks.filter { $0.id >= 0 }.map(\.id))
let baselineTrackIDs = Set(rawSubtitleTracks().filter { $0.id >= 0 }.map(\.id))
candidates.forEach { candidate in
guard !attachedSubtitleURLs.contains(candidate.url) else {
duplicateCount += 1
@ -238,6 +247,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
attachedSubtitleURLs.insert(candidate.url)
externalSubtitleBaselineTrackIDs.formUnion(baselineTrackIDs)
hasPendingExternalSubtitleSelection = true
pendingExternalSubtitleDisplayNames.append(SubtitleDisplayName.displayName(for: candidate))
mediaPlayer.addPlaybackSlave(candidate.url, type: .subtitle, enforce: false)
attachedCount += 1
#if DEBUG
@ -268,6 +278,32 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
return attachedCount
}
private func rawSubtitleTracks() -> [SubtitleTrack] {
let names = mediaPlayer.videoSubTitlesNames as? [String] ?? []
let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber] ?? []
return zip(indexes, names).map { index, name in
SubtitleTrack(id: index.int32Value, name: name)
}
}
private func reconcileExternalSubtitleDisplayNames() {
guard !pendingExternalSubtitleDisplayNames.isEmpty else {
return
}
rawSubtitleTracks()
.filter { $0.id >= 0 }
.filter { !externalSubtitleBaselineTrackIDs.contains($0.id) }
.filter { externalSubtitleDisplayNamesByTrackID[$0.id] == nil }
.sorted { $0.id < $1.id }
.forEach { track in
guard !pendingExternalSubtitleDisplayNames.isEmpty else {
return
}
externalSubtitleDisplayNamesByTrackID[track.id] = pendingExternalSubtitleDisplayNames.removeFirst()
}
}
#if DEBUG
private func logSubtitleTracks(reason: String) {
let names = mediaPlayer.videoSubTitlesNames as? [String] ?? []