From ae996e7ffb5847cdc6502f5791147dc0509b72c1 Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Mon, 25 May 2026 12:55:10 -0400 Subject: [PATCH] preserve opensubtitles caption labels --- .beads/interactions.jsonl | 1 + .beads/issues.jsonl | 1 + Dreamio/StreamCandidate.swift | 102 +++++ Dreamio/VLCNativePlaybackBackend.swift | 46 +- Tests/StreamResolverTests.swift | 52 +++ ...subtitles-languages-in-caption-tracks.html | 431 ++++++++++++++++++ 6 files changed, 628 insertions(+), 5 deletions(-) create mode 100644 docs/turns/2026-05-25-show-opensubtitles-languages-in-caption-tracks.html diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 072f8e3..cbab511 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -33,3 +33,4 @@ {"id":"int-f9deecdb","kind":"field_change","created_at":"2026-05-25T16:18:29.458162Z","actor":"dirtydishes","issue_id":"dreamio-urs","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed by rejecting OpenSubtitles manifest.json_N identifiers as playable subtitle URLs, promoting file_id values to API download URLs, and adding parser coverage for the live log shape."}} {"id":"int-569ee372","kind":"field_change","created_at":"2026-05-25T16:22:50.024736Z","actor":"dirtydishes","issue_id":"dreamio-433","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed by tightening OpenSubtitles subtitle URL filtering in the web bridge and Swift parser, plus adding regression coverage for logged artwork and addon endpoint false positives."}} {"id":"int-eca1f7f8","kind":"field_change","created_at":"2026-05-25T16:33:55.331041Z","actor":"dirtydishes","issue_id":"dreamio-9sp","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Accepted Stremio subtitle download URLs in the bridge, parser, resolver, and regression tests."}} +{"id":"int-99b3cb8b","kind":"field_change","created_at":"2026-05-25T16:54:58.390731Z","actor":"dirtydishes","issue_id":"dreamio-2ju","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed by preserving OpenSubtitles subtitle display metadata through VLC external track attachment and adding display-name tests."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 179f5e5..467bc55 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -23,6 +23,7 @@ {"_type":"issue","id":"dreamio-l68","title":"Add native playback for direct debrid streams","description":"Implement a WKWebView JavaScript bridge that detects direct-file debrid media URLs and routes unsupported containers to a native player backend, initially MobileVLCKit, while preserving normal Stremio Web playback for compatible streams.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T03:13:19Z","created_by":"dirtydishes","updated_at":"2026-05-25T03:20:17Z","started_at":"2026-05-25T03:13:28Z","closed_at":"2026-05-25T03:20:17Z","close_reason":"Implemented native direct-stream bridge, classification, MobileVLCKit backend wiring, CocoaPods workflow docs, and turn documentation. Full iOS build is blocked locally by missing CocoaPods and iPhoneOS SDK.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-tnv","title":"Fix iOS bundle identifier install failure","description":"Xcode built Dreamio.app without a valid CFBundleIdentifier, causing device install to fail with CoreDeviceError 3000/3002. Investigate project bundle settings, fix the source configuration, validate the app bundle Info.plist, and document the change.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T01:23:00Z","created_by":"dirtydishes","updated_at":"2026-05-25T01:25:36Z","started_at":"2026-05-25T01:23:07Z","closed_at":"2026-05-25T01:25:36Z","close_reason":"Added bundle metadata to Info.plist and validated processed app bundle identifier.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-4yn","title":"Build WKWebView MVP shell","description":"Create the first Dreamio MVP implementation: a minimal iOS WKWebView wrapper around hosted Stremio Web, with configuration, launch behavior, diagnostics, and documentation for real-device viability testing.","acceptance_criteria":"App project exists; WKWebView loads hosted Stremio Web; external/new-window navigation is handled; basic diagnostics and manual test documentation exist; quality gates are run or documented.","status":"closed","priority":1,"issue_type":"feature","owner":"dishes@dpdrm.com","created_at":"2026-05-24T14:55:12Z","created_by":"dirtydishes","updated_at":"2026-05-24T14:59:44Z","closed_at":"2026-05-24T14:59:44Z","close_reason":"Implemented the MVP WKWebView iOS shell, added run and validation documentation, and recorded current validation limits.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-2ju","title":"Show OpenSubtitles languages in caption tracks","description":"Preserve external subtitle metadata after VLC attaches OpenSubtitles tracks so the captions menu shows useful language labels instead of generic VLC track names.","status":"closed","priority":2,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T16:52:44Z","created_by":"dirtydishes","updated_at":"2026-05-25T16:54:58Z","started_at":"2026-05-25T16:52:49Z","closed_at":"2026-05-25T16:54:58Z","close_reason":"Fixed by preserving OpenSubtitles subtitle display metadata through VLC external track attachment and adding display-name tests.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-h5n","title":"Throttle VLC subtitle reapply during buffering","description":"VLC subtitle auto-selection currently reapplies the same subtitle track on every buffering state notification, producing noisy logs and unnecessary repeated player writes. Limit state-driven reapply to meaningful selection recovery or state transitions while preserving delayed retries after initial auto-selection.","status":"closed","priority":2,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T15:06:48Z","created_by":"dirtydishes","updated_at":"2026-05-25T15:09:02Z","started_at":"2026-05-25T15:06:55Z","closed_at":"2026-05-25T15:09:02Z","close_reason":"Limited VLC auto-subtitle reapply to real selection recovery while keeping bounded delayed startup confirmations.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-c1m","title":"Add captions selection proof logging","description":"Add DEBUG-only logs around the native captions menu and VLC subtitle selection path so subtitle tap actions prove whether the UI fires and whether VLC accepts the selected embedded track index.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:18:06Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:19:19Z","started_at":"2026-05-25T14:18:11Z","closed_at":"2026-05-25T14:19:19Z","close_reason":"Added DEBUG-only logs for captions menu actions and VLC subtitle selection results.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-e9p","title":"Add native subtitle pipeline proof logging","description":"Add DEBUG-only logs across the web bridge, native player, subtitle resolution, and VLC attachment points so the next Xcode run can identify where external subtitles disappear without changing playback behavior.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:03:18Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:07:14Z","started_at":"2026-05-25T14:03:22Z","closed_at":"2026-05-25T14:07:14Z","close_reason":"Added DEBUG-only subtitle pipeline proof logging and documented validation.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/Dreamio/StreamCandidate.swift b/Dreamio/StreamCandidate.swift index b2345a4..6972036 100644 --- a/Dreamio/StreamCandidate.swift +++ b/Dreamio/StreamCandidate.swift @@ -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 { diff --git a/Dreamio/VLCNativePlaybackBackend.swift b/Dreamio/VLCNativePlaybackBackend.swift index 166d59b..8935c21 100644 --- a/Dreamio/VLCNativePlaybackBackend.swift +++ b/Dreamio/VLCNativePlaybackBackend.swift @@ -28,6 +28,8 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { private var autoSelectedSubtitleTrackID: Int32? private var externalSubtitleBaselineTrackIDs = Set() 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] ?? [] diff --git a/Tests/StreamResolverTests.swift b/Tests/StreamResolverTests.swift index 8fc7c48..c7cada5 100644 --- a/Tests/StreamResolverTests.swift +++ b/Tests/StreamResolverTests.swift @@ -21,6 +21,8 @@ struct StreamResolverTests { await testSubtitleResolverRejectsNonSubtitleAPIResponse() testSubtitleCandidateDeduplicationPreservesLabels() testSubtitleCandidateDeduplicationUpgradesLabels() + testSubtitleDisplayNameNormalization() + testSubtitleDisplayNameUsesPreservedNamesForGenericVLCTracks() testSubtitleOptionMappingIncludesNone() print("StreamResolverTests passed") } @@ -437,6 +439,56 @@ struct StreamResolverTests { assertEqual(options.first?.id, -1) } + private static func testSubtitleDisplayNameNormalization() { + assertEqual( + SubtitleDisplayName.displayName(for: SubtitleCandidate( + url: URL(string: "https://opensubtitles.example.test/download/subtitle.srt")!, + label: "Track 1", + language: "eng" + )), + "English" + ) + assertEqual( + SubtitleDisplayName.displayName(for: SubtitleCandidate( + url: URL(string: "https://opensubtitles.example.test/download/subtitle.srt")!, + label: "Track 2", + language: "Spanish" + )), + "Spanish" + ) + assertEqual( + SubtitleDisplayName.displayName(for: SubtitleCandidate( + url: URL(string: "https://opensubtitles.example.test/download/subtitle.srt")!, + label: "English SDH", + language: "eng" + )), + "English SDH" + ) + assertEqual( + SubtitleDisplayName.displayName(for: SubtitleCandidate( + url: URL(string: "https://cdn.example.test/subtitles/movie.es.srt")!, + label: "External Subtitle", + language: nil + )), + "movie.es" + ) + } + + private static func testSubtitleDisplayNameUsesPreservedNamesForGenericVLCTracks() { + let options = SubtitleOptionMapper.options(from: [ + SubtitleTrack( + id: 3, + name: SubtitleDisplayName.name(forVLCTrackName: "Track 1", preservedName: "English") + ), + SubtitleTrack( + id: 4, + name: SubtitleDisplayName.name(forVLCTrackName: "Commentary", preservedName: "Spanish") + ) + ]) + + assertEqual(options.map(\.name), ["None", "English", "Commentary"]) + } + private static func assertEqual(_ actual: T?, _ expected: T, file: StaticString = #file, line: UInt = #line) { assert(actual == expected, "Expected \(String(describing: expected)), got \(String(describing: actual))", file: file, line: line) } diff --git a/docs/turns/2026-05-25-show-opensubtitles-languages-in-caption-tracks.html b/docs/turns/2026-05-25-show-opensubtitles-languages-in-caption-tracks.html new file mode 100644 index 0000000..2dfb66f --- /dev/null +++ b/docs/turns/2026-05-25-show-opensubtitles-languages-in-caption-tracks.html @@ -0,0 +1,431 @@ + + + + + + Show OpenSubtitles Languages in Caption Tracks + + + +
+
+

Dreamio Turn Document ยท May 25, 2026

+

Show OpenSubtitles Languages in Caption Tracks

+

External subtitles attached through VLC now keep Dreamio's parsed language metadata, so the captions menu can show useful names like English, Spanish, or English SDH instead of generic VLC labels such as Track 1.

+
+ Issue: dreamio-2ju + Scope: subtitle display names + Validation: tests and simulator build passed +
+
+ +
+

Summary

+

Dreamio already parsed OpenSubtitles labels and language codes, but VLC exposed attached external subtitle tracks later with generic names. This change preserves each external candidate's best display name, maps it onto the new VLC track ID once it appears, and returns the preserved name whenever VLC's reported name is generic or empty.

+
+ +
+

Changes Made

+
    +
  • Added SubtitleDisplayName, an internal helper for turning subtitle labels and language metadata into menu-ready names.
  • +
  • Added VLC backend state for pending external subtitle display names and track-ID-to-display-name mappings.
  • +
  • Updated subtitleTracks so generic VLC names are replaced only when Dreamio has a preserved external subtitle name for that specific track ID.
  • +
  • Added unit coverage for language-code normalization, meaningful labels, filename fallback, and menu option names.
  • +
+
+ +
+

Context

+

OpenSubtitles and Stremio payloads often carry useful metadata such as eng, Spanish, or English SDH. The parser kept that metadata on SubtitleCandidate, but once addPlaybackSlave handed a URL to VLC, the eventual track list could contain only Track 1, Track 2, or an empty string. The captions menu reads from backend.subtitleTracks, so the safest fix is to improve that backend output.

+
+ +
+

Important Implementation Details

+
    +
  • Labels are trusted when they are meaningful. English SDH stays English SDH.
  • +
  • Generic labels such as Track 1, External Subtitle, empty strings, and numeric-only labels fall back to language metadata.
  • +
  • Common language codes are normalized explicitly, including eng/en, spa/es, and fre/fra/fr. The helper also uses Locale for broader short-code support.
  • +
  • The VLC backend captures baseline subtitle track IDs before attachment, then assigns preserved names to newly visible external track IDs in attachment order.
  • +
  • Embedded tracks are left alone unless they match a newly mapped external ID and VLC reports a generic or empty name.
  • +
+
+ +
+

Relevant Diff Snippets

+

Dreamio/StreamCandidate.swift

Dreamio/StreamCandidate.swift
+102
39 unmodified lines
40
41
42
43
44
45
39 unmodified lines
let name: String
}
+
#if DEBUG
enum SubtitleDebugFormatter {
static func candidateSummary(_ candidates: [SubtitleCandidate]) -> String {
39 unmodified lines
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
39 unmodified lines
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 {
+

Dreamio/VLCNativePlaybackBackend.swift

Dreamio/VLCNativePlaybackBackend.swift
-5+41
27 unmodified lines
28
29
30
31
32
33
17 unmodified lines
51
52
53
54
55
56
142 unmodified lines
199
200
201
202
203
204
205
206
207
208
20 unmodified lines
229
230
231
232
233
234
235
2 unmodified lines
238
239
240
241
242
243
24 unmodified lines
268
269
270
271
272
273
27 unmodified lines
private var autoSelectedSubtitleTrackID: Int32?
private var externalSubtitleBaselineTrackIDs = Set<Int32>()
private var hasPendingExternalSubtitleSelection = false
+
override init() {
super.init()
17 unmodified lines
autoSelectedSubtitleTrackID = nil
externalSubtitleBaselineTrackIDs.removeAll()
hasPendingExternalSubtitleSelection = false
let media = VLCMedia(url: request.playbackURL)
let headerValue = request.headers
.map { "\($0.key): \($0.value)" }
142 unmodified lines
+
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)
}
#else
[]
20 unmodified lines
private func attachSubtitles(_ candidates: [SubtitleCandidate]) -> Int {
var attachedCount = 0
var duplicateCount = 0
let baselineTrackIDs = Set(subtitleTracks.filter { $0.id >= 0 }.map(\.id))
candidates.forEach { candidate in
guard !attachedSubtitleURLs.contains(candidate.url) else {
duplicateCount += 1
2 unmodified lines
attachedSubtitleURLs.insert(candidate.url)
externalSubtitleBaselineTrackIDs.formUnion(baselineTrackIDs)
hasPendingExternalSubtitleSelection = true
mediaPlayer.addPlaybackSlave(candidate.url, type: .subtitle, enforce: false)
attachedCount += 1
#if DEBUG
24 unmodified lines
return attachedCount
}
+
#if DEBUG
private func logSubtitleTracks(reason: String) {
let names = mediaPlayer.videoSubTitlesNames as? [String] ?? []
27 unmodified lines
28
29
30
31
32
33
34
35
17 unmodified lines
53
54
55
56
57
58
59
60
142 unmodified lines
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
20 unmodified lines
238
239
240
241
242
243
244
2 unmodified lines
247
248
249
250
251
252
253
24 unmodified lines
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
27 unmodified lines
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()
17 unmodified lines
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)" }
142 unmodified lines
+
var subtitleTracks: [SubtitleTrack] {
#if canImport(MobileVLCKit)
reconcileExternalSubtitleDisplayNames()
return rawSubtitleTracks().map { track in
SubtitleTrack(
id: track.id,
name: SubtitleDisplayName.name(
forVLCTrackName: track.name,
preservedName: externalSubtitleDisplayNamesByTrackID[track.id]
)
)
}
#else
[]
20 unmodified lines
private func attachSubtitles(_ candidates: [SubtitleCandidate]) -> Int {
var attachedCount = 0
var duplicateCount = 0
let baselineTrackIDs = Set(rawSubtitleTracks().filter { $0.id >= 0 }.map(\.id))
candidates.forEach { candidate in
guard !attachedSubtitleURLs.contains(candidate.url) else {
duplicateCount += 1
2 unmodified lines
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
24 unmodified lines
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] ?? []
+

Tests/StreamResolverTests.swift

Tests/StreamResolverTests.swift
+52
20 unmodified lines
21
22
23
24
25
26
410 unmodified lines
437
438
439
440
441
442
20 unmodified lines
await testSubtitleResolverRejectsNonSubtitleAPIResponse()
testSubtitleCandidateDeduplicationPreservesLabels()
testSubtitleCandidateDeduplicationUpgradesLabels()
testSubtitleOptionMappingIncludesNone()
print("StreamResolverTests passed")
}
410 unmodified lines
assertEqual(options.first?.id, -1)
}
+
private static func assertEqual<T: Equatable>(_ actual: T?, _ expected: T, file: StaticString = #file, line: UInt = #line) {
assert(actual == expected, "Expected \(String(describing: expected)), got \(String(describing: actual))", file: file, line: line)
}
20 unmodified lines
21
22
23
24
25
26
27
28
410 unmodified lines
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
20 unmodified lines
await testSubtitleResolverRejectsNonSubtitleAPIResponse()
testSubtitleCandidateDeduplicationPreservesLabels()
testSubtitleCandidateDeduplicationUpgradesLabels()
testSubtitleDisplayNameNormalization()
testSubtitleDisplayNameUsesPreservedNamesForGenericVLCTracks()
testSubtitleOptionMappingIncludesNone()
print("StreamResolverTests passed")
}
410 unmodified lines
assertEqual(options.first?.id, -1)
}
+
private static func testSubtitleDisplayNameNormalization() {
assertEqual(
SubtitleDisplayName.displayName(for: SubtitleCandidate(
url: URL(string: "https://opensubtitles.example.test/download/subtitle.srt")!,
label: "Track 1",
language: "eng"
)),
"English"
)
assertEqual(
SubtitleDisplayName.displayName(for: SubtitleCandidate(
url: URL(string: "https://opensubtitles.example.test/download/subtitle.srt")!,
label: "Track 2",
language: "Spanish"
)),
"Spanish"
)
assertEqual(
SubtitleDisplayName.displayName(for: SubtitleCandidate(
url: URL(string: "https://opensubtitles.example.test/download/subtitle.srt")!,
label: "English SDH",
language: "eng"
)),
"English SDH"
)
assertEqual(
SubtitleDisplayName.displayName(for: SubtitleCandidate(
url: URL(string: "https://cdn.example.test/subtitles/movie.es.srt")!,
label: "External Subtitle",
language: nil
)),
"movie.es"
)
}
+
private static func testSubtitleDisplayNameUsesPreservedNamesForGenericVLCTracks() {
let options = SubtitleOptionMapper.options(from: [
SubtitleTrack(
id: 3,
name: SubtitleDisplayName.name(forVLCTrackName: "Track 1", preservedName: "English")
),
SubtitleTrack(
id: 4,
name: SubtitleDisplayName.name(forVLCTrackName: "Commentary", preservedName: "Spanish")
)
])
+
assertEqual(options.map(\.name), ["None", "English", "Commentary"])
}
+
private static func assertEqual<T: Equatable>(_ actual: T?, _ expected: T, file: StaticString = #file, line: UInt = #line) {
assert(actual == expected, "Expected \(String(describing: expected)), got \(String(describing: actual))", file: file, line: line)
}
+
+ +
+

Expected Impact for End-Users

+

Users should see clearer captions menu choices after external OpenSubtitles tracks attach. Instead of guessing between Track 1 and Track 2, they should see language-aware labels that make track selection understandable at a glance.

+
+ +
+

Validation

+
    +
  • Passed: swiftc Dreamio/StreamCandidate.swift Dreamio/StreamResolver.swift Tests/StreamResolverTests.swift -o /tmp/dreamio-stream-tests && /tmp/dreamio-stream-tests
  • +
  • Passed: xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -sdk iphonesimulator -configuration Debug build CODE_SIGNING_ALLOWED=NO
  • +
+

Manual app verification with a live OpenSubtitles playback source was not performed in this turn.

+
+ +
+

Issues, Limitations, and Mitigations

+
    +
  • The external ID mapping assumes VLC exposes newly attached subtitle tracks in the same order Dreamio attached them. This matches the intended flow and is constrained to tracks outside the captured baseline.
  • +
  • If VLC later returns a meaningful track name for an external subtitle, Dreamio keeps VLC's name instead of overriding it.
  • +
  • The language helper covers common OpenSubtitles/Stremio codes directly and delegates broader short-code support to Locale.
  • +
+
+ +
+

Follow-up Work

+

No required follow-up was filed. A useful future improvement would be a lightweight integration test seam around VLC subtitle-track reconciliation, if the backend gets a mockable media-player adapter later.

+
+ + +
+ + \ No newline at end of file