Dreamio turn document · May 25, 2026 · Beads issue dreamio-dow

Fix Stremio External Subtitle Handoff To VLC

Made external subtitles part of the native playback handoff instead of a side channel that could disappear before MobileVLCKit was ready.

PipelineBuffered subtitle candidates by active stream key and forwarded them after native player presentation.
ParserPreserved OpenSubtitles labels, languages, nested direct files, and stronger duplicate metadata.
ValidationSwift parser/resolver tests and iOS simulator build passed.

Summary

Dreamio now keeps Stremio and OpenSubtitlesV3 subtitle discoveries tied to the active native playback stream. Candidates found before presentation, inside the stream candidate message, and during playback are merged, deduped, resolved when needed, and attached to VLC as external subtitle slaves.

Changes Made

Context

Stremio can surface external subtitles from several places: the native stream candidate, OpenSubtitlesV3 API responses, nested file records, and late web requests while the player is opening. Before this change, discoveries that arrived before currentNativePlayer existed were logged but dropped. That meant VLC could successfully open the video while Dreamio’s captions menu never received the corresponding external subtitle track.

Important Implementation Details

Relevant Diff Snippets

Dreamio/DreamioWebViewController.swift

Dreamio/DreamioWebViewController.swift
-8+79
56 unmodified lines
57
58
59
60
61
62
524 unmodified lines
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
2 unmodified lines
606
607
608
609
610
611
612
613
614
615
616
617
5 unmodified lines
623
624
625
626
627
628
629
630
631
12 unmodified lines
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
56 unmodified lines
private var progressObservation: NSKeyValueObservation?
private var userAgent: String?
private var lastNativePlaybackURL: URL?
private weak var currentNativePlayer: NativePlayerViewController?
private let streamResolver: StreamResolving = StremioStreamResolver()
524 unmodified lines
let duplicateKey = request.resolverURL ?? request.playbackURL
if lastNativePlaybackURL == duplicateKey {
return
}
lastNativePlaybackURL = duplicateKey
#if DEBUG
let classification = request.classification
print("[DreamioStream] class=\(classification.sourceKind.rawValue) container=\(classification.containerGuess.rawValue) reason=\(classification.reason) subtitles=\(request.subtitleCandidates.count) observed=\(classification.sanitizedObservedURL) resolver=\(classification.sanitizedResolverURL ?? "none")")
#endif
Task { [weak self] in
await self?.resolveAndPresentNativePlayback(request)
}
}
2 unmodified lines
return
}
#if DEBUG
print("[DreamioSubtitles] native discovered=\(candidates.count) playerActive=\(currentNativePlayer != nil) candidates=\(SubtitleDebugFormatter.candidateSummary(candidates))")
#endif
guard let currentNativePlayer else {
#if DEBUG
print("[DreamioSubtitles] discovered=\(candidates.count) forwarded=0 reason=no-active-native-player")
#endif
return
}
5 unmodified lines
}
@MainActor
private func resolveAndPresentNativePlayback(_ request: NativePlaybackRequest) async {
guard VLCNativePlaybackBackend.isAvailable else {
lastNativePlaybackURL = nil
showNativePlaybackUnavailableAlert()
return
}
12 unmodified lines
referer: request.referer,
headers: resolved.headers,
classification: request.classification,
subtitleCandidates: request.subtitleCandidates
)
let player = NativePlayerViewController(request: resolvedRequest)
currentNativePlayer = player
player.onDismiss = { [weak self] in
self?.lastNativePlaybackURL = nil
self?.currentNativePlayer = nil
self?.cleanUpStremioPlayerAfterNativeDismiss()
}
present(player, animated: true)
} catch {
#if DEBUG
print("[DreamioStreamResolver] failure=\(URLRedactor.redactedURLString(error.localizedDescription)) resolver=\(request.resolverURL.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none")")
#endif
lastNativePlaybackURL = nil
showNativePlaybackResolutionFailure(error)
}
}
private func showNativePlaybackResolutionFailure(_ error: Error) {
let alert = UIAlertController(
title: "Could not open stream",
56 unmodified lines
57
58
59
60
61
62
63
64
524 unmodified lines
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
2 unmodified lines
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
5 unmodified lines
646
647
648
649
650
651
652
653
654
655
12 unmodified lines
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
56 unmodified lines
private var progressObservation: NSKeyValueObservation?
private var userAgent: String?
private var lastNativePlaybackURL: URL?
private var pendingSubtitleCandidatesByStreamKey: [URL: [SubtitleCandidate]] = [:]
private var currentNativePlaybackKey: URL?
private weak var currentNativePlayer: NativePlayerViewController?
private let streamResolver: StreamResolving = StremioStreamResolver()
524 unmodified lines
let duplicateKey = request.resolverURL ?? request.playbackURL
if lastNativePlaybackURL == duplicateKey {
mergeSubtitleCandidates(candidate.subtitleCandidates, for: duplicateKey)
return
}
lastNativePlaybackURL = duplicateKey
currentNativePlaybackKey = duplicateKey
mergeSubtitleCandidates(request.subtitleCandidates, for: duplicateKey)
let mergedSubtitleCandidates = subtitleCandidates(for: duplicateKey)
#if DEBUG
let classification = request.classification
print("[DreamioStream] class=\(classification.sourceKind.rawValue) container=\(classification.containerGuess.rawValue) reason=\(classification.reason) subtitles=\(mergedSubtitleCandidates.count) observed=\(classification.sanitizedObservedURL) resolver=\(classification.sanitizedResolverURL ?? "none")")
#endif
let playbackRequest = NativePlaybackRequest(
playbackURL: request.playbackURL,
observedURL: request.observedURL,
resolverURL: request.resolverURL,
pageURL: request.pageURL,
userAgent: request.userAgent,
referer: request.referer,
headers: request.headers,
classification: request.classification,
subtitleCandidates: mergedSubtitleCandidates
)
Task { [weak self] in
await self?.resolveAndPresentNativePlayback(playbackRequest, streamKey: duplicateKey)
}
}
2 unmodified lines
return
}
let streamKey = currentNativePlaybackKey ?? lastNativePlaybackURL
if let streamKey {
mergeSubtitleCandidates(candidates, for: streamKey)
}
#if DEBUG
print("[DreamioSubtitles] native discovered=\(candidates.count) playerActive=\(currentNativePlayer != nil) streamKey=\(streamKey.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none") candidates=\(SubtitleDebugFormatter.candidateSummary(candidates))")
#endif
guard let currentNativePlayer else {
#if DEBUG
print("[DreamioSubtitles] discovered=\(candidates.count) forwarded=0 reason=no-active-native-player buffered=\(streamKey != nil)")
#endif
return
}
5 unmodified lines
}
@MainActor
private func resolveAndPresentNativePlayback(_ request: NativePlaybackRequest, streamKey: URL) async {
guard VLCNativePlaybackBackend.isAvailable else {
lastNativePlaybackURL = nil
currentNativePlaybackKey = nil
showNativePlaybackUnavailableAlert()
return
}
12 unmodified lines
referer: request.referer,
headers: resolved.headers,
classification: request.classification,
subtitleCandidates: subtitleCandidates(for: streamKey)
)
let player = NativePlayerViewController(request: resolvedRequest)
player.onDismiss = { [weak self] in
self?.lastNativePlaybackURL = nil
self?.currentNativePlaybackKey = nil
self?.currentNativePlayer = nil
self?.pendingSubtitleCandidatesByStreamKey.removeValue(forKey: streamKey)
self?.cleanUpStremioPlayerAfterNativeDismiss()
}
present(player, animated: true) { [weak self, weak player] in
guard let self, let player else {
return
}
self.currentNativePlayer = player
let lateBufferedCandidates = self.subtitleCandidates(for: streamKey)
let forwarded = player.addSubtitleCandidates(lateBufferedCandidates)
#if DEBUG
print("[DreamioSubtitles] presented buffered=\(lateBufferedCandidates.count) forwarded=\(forwarded) streamKey=\(URLRedactor.redactedURLString(streamKey.absoluteString))")
#endif
}
} catch {
#if DEBUG
print("[DreamioStreamResolver] failure=\(URLRedactor.redactedURLString(error.localizedDescription)) resolver=\(request.resolverURL.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none")")
#endif
lastNativePlaybackURL = nil
currentNativePlaybackKey = nil
pendingSubtitleCandidatesByStreamKey.removeValue(forKey: streamKey)
showNativePlaybackResolutionFailure(error)
}
}
private func mergeSubtitleCandidates(_ candidates: [SubtitleCandidate], for streamKey: URL) {
guard !candidates.isEmpty else {
return
}
let existing = pendingSubtitleCandidatesByStreamKey[streamKey] ?? []
pendingSubtitleCandidatesByStreamKey[streamKey] = Self.mergedSubtitleCandidates(existing + candidates)
}
private func subtitleCandidates(for streamKey: URL) -> [SubtitleCandidate] {
pendingSubtitleCandidatesByStreamKey[streamKey] ?? []
}
private static func mergedSubtitleCandidates(_ candidates: [SubtitleCandidate]) -> [SubtitleCandidate] {
var orderedKeys: [String] = []
var bestByURL: [String: SubtitleCandidate] = [:]
candidates.forEach { candidate in
let key = candidate.url.absoluteString
if bestByURL[key] == nil {
orderedKeys.append(key)
bestByURL[key] = candidate
} else if let current = bestByURL[key],
subtitleCandidateScore(candidate) > subtitleCandidateScore(current) {
bestByURL[key] = candidate
}
}
return orderedKeys.compactMap { bestByURL[$0] }
}
private static func subtitleCandidateScore(_ candidate: SubtitleCandidate) -> Int {
let hasUsefulLabel = !candidate.label.isEmpty && candidate.label != candidate.url.deletingPathExtension().lastPathComponent
return (hasUsefulLabel ? 2 : 0) + ((candidate.language?.isEmpty == false) ? 1 : 0)
}
private func showNativePlaybackResolutionFailure(_ error: Error) {
let alert = UIAlertController(
title: "Could not open stream",

Dreamio/NativePlaybackBackend.swift

Dreamio/NativePlaybackBackend.swift
-4
29 unmodified lines
30
31
32
33
34
35
36
37
38
39
29 unmodified lines
func stop()
}
protocol SubtitleResolving {
func resolve(_ candidate: SubtitleCandidate) async -> SubtitleCandidate?
}
enum NativePlaybackError: LocalizedError {
case backendUnavailable
case startupTimedOut
29 unmodified lines
30
31
32
33
34
35
29 unmodified lines
func stop()
}
enum NativePlaybackError: LocalizedError {
case backendUnavailable
case startupTimedOut

Dreamio/StreamCandidate.swift

Dreamio/StreamCandidate.swift
-16+43
133 unmodified lines
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
1 unmodified line
172
173
174
175
176
177
178
2 unmodified lines
181
182
183
184
185
186
187
188
189
190
191
133 unmodified lines
private static let supportedExtensions = ["srt", "vtt", "ass", "ssa", "sub"]
private static let urlFields = ["url", "href", "src", "link", "subtitles", "subtitle", "subtitleUrl", "subtitleURL", "file", "download"]
private static let labelFields = ["label", "name", "title", "file_name", "lang", "language", "id"]
static func candidates(in payload: Any?) -> [SubtitleCandidate] {
var results: [SubtitleCandidate] = []
collect(from: payload, into: &results)
var seen = Set<String>()
return results.filter { candidate in
let key = candidate.url.absoluteString
guard !seen.contains(key) else {
return false
}
seen.insert(key)
return true
}
}
private static func collect(from value: Any?, into results: inout [SubtitleCandidate]) {
switch value {
case let dictionary as [String: Any]:
if let candidate = candidate(from: dictionary) {
results.append(candidate)
}
orderedNestedValues(in: dictionary).forEach { collect(from: $0, into: &results) }
case let array as [Any]:
array.forEach { collect(from: $0, into: &results) }
case let string as String:
if let url = subtitleURL(from: string) {
results.append(SubtitleCandidate(url: url, label: defaultLabel(for: url), language: nil))
} else {
extractSubtitleURLs(from: string).forEach { url in
results.append(SubtitleCandidate(url: url, label: defaultLabel(for: url), language: nil))
}
}
default:
1 unmodified line
}
}
private static func candidate(from dictionary: [String: Any]) -> SubtitleCandidate? {
guard let url = urlFields.lazy.compactMap({ subtitleURL(from: dictionary[$0] as? String) }).first else {
return nil
}
2 unmodified lines
let language = (dictionary["lang"] as? String) ?? (dictionary["language"] as? String)
return SubtitleCandidate(
url: url,
label: label?.isEmpty == false ? label! : defaultLabel(for: url),
language: language
)
}
private static func orderedNestedValues(in dictionary: [String: Any]) -> [Any] {
let preferredKeys = ["subtitles", "subtitle", "files", "downloads", "download"]
var visitedKeys = Set<String>()
133 unmodified lines
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
1 unmodified line
193
194
195
196
197
198
199
2 unmodified lines
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
133 unmodified lines
private static let supportedExtensions = ["srt", "vtt", "ass", "ssa", "sub"]
private static let urlFields = ["url", "href", "src", "link", "subtitles", "subtitle", "subtitleUrl", "subtitleURL", "file", "download"]
private static let labelFields = ["label", "name", "title", "file_name", "lang", "language", "id"]
private struct CandidateContext {
let label: String?
let language: String?
func merged(with dictionary: [String: Any]) -> CandidateContext {
let label = Self.firstString(in: dictionary, fields: labelFields) ?? self.label
let language = (dictionary["lang"] as? String)
?? (dictionary["language"] as? String)
?? self.language
return CandidateContext(label: label, language: language)
}
private static func firstString(in dictionary: [String: Any], fields: [String]) -> String? {
fields.lazy.compactMap { dictionary[$0] as? String }.first { !$0.isEmpty }
}
}
static func candidates(in payload: Any?) -> [SubtitleCandidate] {
var results: [SubtitleCandidate] = []
collect(from: payload, context: CandidateContext(label: nil, language: nil), into: &results)
var orderedKeys: [String] = []
var bestByURL: [String: SubtitleCandidate] = [:]
results.forEach { candidate in
let key = candidate.url.absoluteString
if bestByURL[key] == nil {
orderedKeys.append(key)
bestByURL[key] = candidate
} else if let current = bestByURL[key],
candidateScore(candidate) > candidateScore(current) {
bestByURL[key] = candidate
}
}
return orderedKeys.compactMap { bestByURL[$0] }
}
private static func collect(from value: Any?, context: CandidateContext, into results: inout [SubtitleCandidate]) {
switch value {
case let dictionary as [String: Any]:
let nextContext = context.merged(with: dictionary)
if let candidate = candidate(from: dictionary, context: nextContext) {
results.append(candidate)
}
orderedNestedValues(in: dictionary).forEach { collect(from: $0, context: nextContext, into: &results) }
case let array as [Any]:
array.forEach { collect(from: $0, context: context, into: &results) }
case let string as String:
if let url = subtitleURL(from: string) {
results.append(SubtitleCandidate(url: url, label: context.label ?? defaultLabel(for: url), language: context.language))
} else {
extractSubtitleURLs(from: string).forEach { url in
results.append(SubtitleCandidate(url: url, label: context.label ?? defaultLabel(for: url), language: context.language))
}
}
default:
1 unmodified line
}
}
private static func candidate(from dictionary: [String: Any], context: CandidateContext) -> SubtitleCandidate? {
guard let url = urlFields.lazy.compactMap({ subtitleURL(from: dictionary[$0] as? String) }).first else {
return nil
}
2 unmodified lines
let language = (dictionary["lang"] as? String) ?? (dictionary["language"] as? String)
return SubtitleCandidate(
url: url,
label: label?.isEmpty == false ? label! : (context.label ?? defaultLabel(for: url)),
language: language ?? context.language
)
}
private static func candidateScore(_ candidate: SubtitleCandidate) -> Int {
let defaultLabel = defaultLabel(for: candidate.url)
let hasUsefulLabel = !candidate.label.isEmpty && candidate.label != defaultLabel
return (hasUsefulLabel ? 2 : 0) + ((candidate.language?.isEmpty == false) ? 1 : 0)
}
private static func orderedNestedValues(in dictionary: [String: Any]) -> [Any] {
let preferredKeys = ["subtitles", "subtitle", "files", "downloads", "download"]
var visitedKeys = Set<String>()

Dreamio/StreamResolver.swift

Dreamio/StreamResolver.swift
-1+31
5 unmodified lines
6
7
8
9
10
11
35 unmodified lines
47
48
49
50
51
52
13 unmodified lines
66
67
68
69
70
71
72
54 unmodified lines
127
128
129
130
131
132
5 unmodified lines
let source: String
}
enum StreamResolverError: LocalizedError {
case noResolverURL
case httpStatus(Int)
35 unmodified lines
var request = URLRequest(url: candidate.url)
request.setValue("application/json, text/plain, text/vtt, application/x-subrip, */*", forHTTPHeaderField: "Accept")
do {
let (data, response) = try await session.data(for: request)
13 unmodified lines
from: data,
responseURL: response.url,
original: candidate
)
} catch {
#if DEBUG
print("[DreamioSubtitles] resolve failure=\(URLRedactor.redactedURLString(error.localizedDescription)) url=\(URLRedactor.redactedURLString(candidate.url.absoluteString))")
54 unmodified lines
|| lowercased.contains("/subtitle")
|| lowercased.contains("subtitle")
}
}
protocol StreamResolving {
5 unmodified lines
6
7
8
9
10
11
12
13
14
15
35 unmodified lines
51
52
53
54
55
56
57
58
59
13 unmodified lines
73
74
75
76
77
78
79
80
81
82
83
84
54 unmodified lines
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
5 unmodified lines
let source: String
}
protocol SubtitleResolving {
func resolve(_ candidate: SubtitleCandidate) async -> SubtitleCandidate?
}
enum StreamResolverError: LocalizedError {
case noResolverURL
case httpStatus(Int)
35 unmodified lines
var request = URLRequest(url: candidate.url)
request.setValue("application/json, text/plain, text/vtt, application/x-subrip, */*", forHTTPHeaderField: "Accept")
StreamClassifier.defaultHeaders(userAgent: nil).forEach { key, value in
request.setValue(value, forHTTPHeaderField: key)
}
do {
let (data, response) = try await session.data(for: request)
13 unmodified lines
from: data,
responseURL: response.url,
original: candidate
).map { resolved in
#if DEBUG
print("[DreamioSubtitles] resolved candidate from=\(URLRedactor.redactedURLString(candidate.url.absoluteString)) to=\(URLRedactor.redactedURLString(resolved.url.absoluteString))")
#endif
return resolved
} ?? Self.logRejected(candidate, responseURL: response.url, data: data)
} catch {
#if DEBUG
print("[DreamioSubtitles] resolve failure=\(URLRedactor.redactedURLString(error.localizedDescription)) url=\(URLRedactor.redactedURLString(candidate.url.absoluteString))")
54 unmodified lines
|| lowercased.contains("/subtitle")
|| lowercased.contains("subtitle")
}
private static func logRejected(_ candidate: SubtitleCandidate, responseURL: URL?, data: Data) -> SubtitleCandidate? {
#if DEBUG
let responseDescription = responseURL.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none"
let bodyKind: String
if data.isEmpty {
bodyKind = "empty"
} else if (try? JSONSerialization.jsonObject(with: data)) != nil {
bodyKind = "json-without-direct-subtitle"
} else if String(data: data, encoding: .utf8) != nil {
bodyKind = "text-without-direct-subtitle"
} else {
bodyKind = "unreadable"
}
print("[DreamioSubtitles] rejected candidate reason=\(bodyKind) url=\(URLRedactor.redactedURLString(candidate.url.absoluteString)) responseURL=\(responseDescription)")
#endif
return nil
}
}
protocol StreamResolving {

Dreamio/VLCNativePlaybackBackend.swift

Dreamio/VLCNativePlaybackBackend.swift
-8+12
245 unmodified lines
246
247
248
249
250
251
252
253
254
255
256
257
42 unmodified lines
300
301
302
303
304
305
306
307
308
309
310
245 unmodified lines
guard attachedCount > 0 else {
return attachedCount
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
self?.selectInitialSubtitleTrackIfNeeded(reason: "delayed-refresh")
#if DEBUG
self?.logSubtitleTracks(reason: "delayed-refresh")
#endif
self?.onSubtitleTracksChange?()
}
return attachedCount
}
42 unmodified lines
}
let selectedTrackID = mediaPlayer.currentVideoSubTitleIndex
guard selectedTrackID != trackID || shouldLogNoop else {
return
}
mediaPlayer.currentVideoSubTitleIndex = trackID
#if DEBUG
let action = selectedTrackID == trackID ? "confirm" : "recover"
print("[DreamioVLC] reapply subtitle id=\(trackID) reason=\(reason) action=\(action) selected=\(mediaPlayer.currentVideoSubTitleIndex)")
245 unmodified lines
246
247
248
249
250
251
252
253
254
255
256
257
258
259
42 unmodified lines
302
303
304
305
306
307
308
309
310
311
312
313
314
245 unmodified lines
guard attachedCount > 0 else {
return attachedCount
}
[0.2, 0.6, 1.0, 2.0, 4.0].forEach { delay in
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.selectInitialSubtitleTrackIfNeeded(reason: "delayed-refresh-\(String(format: "%.1f", delay))")
#if DEBUG
self?.logSubtitleTracks(reason: "delayed-refresh-\(String(format: "%.1f", delay))")
#endif
self?.onSubtitleTracksChange?()
}
}
return attachedCount
}
42 unmodified lines
}
let selectedTrackID = mediaPlayer.currentVideoSubTitleIndex
guard selectedTrackID < 0 || (selectedTrackID == trackID && shouldLogNoop) else {
return
}
if selectedTrackID < 0 {
mediaPlayer.currentVideoSubTitleIndex = trackID
}
#if DEBUG
let action = selectedTrackID == trackID ? "confirm" : "recover"
print("[DreamioVLC] reapply subtitle id=\(trackID) reason=\(reason) action=\(action) selected=\(mediaPlayer.currentVideoSubTitleIndex)")

Tests/StreamResolverTests.swift

Tests/StreamResolverTests.swift
-1+121
1 unmodified line
2
3
4
5
6
7
8
2 unmodified lines
11
12
13
14
15
16
17
125 unmodified lines
143
144
145
146
147
148
24 unmodified lines
173
174
175
176
177
178
18 unmodified lines
197
198
199
200
201
202
7 unmodified lines
210
211
212
213
1 unmodified line
@main
struct StreamResolverTests {
static func main() {
testClassifierPrefersObservedDirectFile()
testResolverSelectsUnsupportedDirectURLAndHeaders()
testResolverRejectsHLSOnlyResponse()
2 unmodified lines
testSubtitleCandidateParsing()
testOpenSubtitlesV3CandidateParsing()
testOpenSubtitlesV3DownloadResponseResolution()
testSubtitleCandidateDeduplicationPreservesLabels()
testSubtitleOptionMappingIncludesNone()
print("StreamResolverTests passed")
}
125 unmodified lines
assertEqual(candidates[0].label, "English")
assertEqual(candidates[0].language, "English")
assertEqual(candidates[1].url.absoluteString, "https://dl.opensubtitles.org/en/subtitle.vtt?download=1")
assertEqual(candidates[2].label, "spa")
assertEqual(candidates[2].language, "spa")
assertEqual(candidates[3].url.absoluteString, "https://cdn.example.test/from-string.ass?source=opensubtitles")
24 unmodified lines
assertEqual(candidate?.language, "eng")
}
private static func testSubtitleCandidateDeduplicationPreservesLabels() {
let payload: [String: Any] = [
"subtitles": [
18 unmodified lines
assertEqual(candidates[0].language, "eng")
}
private static func testSubtitleOptionMappingIncludesNone() {
let options = SubtitleOptionMapper.options(from: [
SubtitleTrack(id: 2, name: "English"),
7 unmodified lines
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)
}
}
1 unmodified line
2
3
4
5
6
7
8
2 unmodified lines
11
12
13
14
15
16
17
18
19
20
21
125 unmodified lines
147
148
149
150
151
152
153
154
24 unmodified lines
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
18 unmodified lines
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
7 unmodified lines
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
1 unmodified line
@main
struct StreamResolverTests {
static func main() async {
testClassifierPrefersObservedDirectFile()
testResolverSelectsUnsupportedDirectURLAndHeaders()
testResolverRejectsHLSOnlyResponse()
2 unmodified lines
testSubtitleCandidateParsing()
testOpenSubtitlesV3CandidateParsing()
testOpenSubtitlesV3DownloadResponseResolution()
await testSubtitleResolverDownloadJSONReturningLink()
await testSubtitleResolverRedirectToDirectSubtitle()
await testSubtitleResolverRejectsNonSubtitleAPIResponse()
testSubtitleCandidateDeduplicationPreservesLabels()
testSubtitleCandidateDeduplicationUpgradesLabels()
testSubtitleOptionMappingIncludesNone()
print("StreamResolverTests passed")
}
125 unmodified lines
assertEqual(candidates[0].label, "English")
assertEqual(candidates[0].language, "English")
assertEqual(candidates[1].url.absoluteString, "https://dl.opensubtitles.org/en/subtitle.vtt?download=1")
assertEqual(candidates[1].label, "English")
assertEqual(candidates[1].language, "English")
assertEqual(candidates[2].label, "spa")
assertEqual(candidates[2].language, "spa")
assertEqual(candidates[3].url.absoluteString, "https://cdn.example.test/from-string.ass?source=opensubtitles")
24 unmodified lines
assertEqual(candidate?.language, "eng")
}
private static func testSubtitleResolverDownloadJSONReturningLink() async {
MockURLProtocol.handlers = [
"https://api.opensubtitles.com/api/v1/download/123": (
200,
URL(string: "https://api.opensubtitles.com/api/v1/download/123")!,
#"{"link":"https://dl.opensubtitles.org/en/download/movie.srt?token=secret"}"#.data(using: .utf8)!
)
]
let resolver = SubtitleResolver(session: mockSession())
let candidate = await resolver.resolve(SubtitleCandidate(
url: URL(string: "https://api.opensubtitles.com/api/v1/download/123")!,
label: "English",
language: "eng"
))
assertEqual(candidate?.url.absoluteString, "https://dl.opensubtitles.org/en/download/movie.srt?token=secret")
assertEqual(candidate?.label, "English")
assertEqual(candidate?.language, "eng")
}
private static func testSubtitleResolverRedirectToDirectSubtitle() async {
MockURLProtocol.handlers = [
"https://api.opensubtitles.com/api/v1/download/redirect": (
200,
URL(string: "https://dl.opensubtitles.org/en/redirected.vtt?download=1")!,
Data()
)
]
let resolver = SubtitleResolver(session: mockSession())
let candidate = await resolver.resolve(SubtitleCandidate(
url: URL(string: "https://api.opensubtitles.com/api/v1/download/redirect")!,
label: "English",
language: "eng"
))
assertEqual(candidate?.url.absoluteString, "https://dl.opensubtitles.org/en/redirected.vtt?download=1")
}
private static func testSubtitleResolverRejectsNonSubtitleAPIResponse() async {
MockURLProtocol.handlers = [
"https://api.opensubtitles.com/api/v1/download/not-found": (
200,
URL(string: "https://api.opensubtitles.com/api/v1/download/not-found")!,
#"{"message":"not found"}"#.data(using: .utf8)!
)
]
let resolver = SubtitleResolver(session: mockSession())
let candidate = await resolver.resolve(SubtitleCandidate(
url: URL(string: "https://api.opensubtitles.com/api/v1/download/not-found")!,
label: "English",
language: "eng"
))
assert(candidate == nil, "Expected non-subtitle API response to be rejected")
}
private static func testSubtitleCandidateDeduplicationPreservesLabels() {
let payload: [String: Any] = [
"subtitles": [
18 unmodified lines
assertEqual(candidates[0].language, "eng")
}
private static func testSubtitleCandidateDeduplicationUpgradesLabels() {
let payload: [String: Any] = [
"subtitles": [
"https://opensubtitles.example.test/download/duplicate.srt",
[
"label": "English SDH",
"lang": "eng",
"url": "https://opensubtitles.example.test/download/duplicate.srt"
]
]
]
let candidates = SubtitleCandidateParser.candidates(in: payload)
assertEqual(candidates.count, 1)
assertEqual(candidates[0].label, "English SDH")
assertEqual(candidates[0].language, "eng")
}
private static func testSubtitleOptionMappingIncludesNone() {
let options = SubtitleOptionMapper.options(from: [
SubtitleTrack(id: 2, name: "English"),
7 unmodified lines
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)
}
private static func mockSession() -> URLSession {
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [MockURLProtocol.self]
return URLSession(configuration: configuration)
}
}
private final class MockURLProtocol: URLProtocol {
static var handlers: [String: (status: Int, url: URL, data: Data)] = [:]
override class func canInit(with request: URLRequest) -> Bool {
true
}
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
request
}
override func startLoading() {
guard let url = request.url,
let handler = Self.handlers[url.absoluteString],
let response = HTTPURLResponse(
url: handler.url,
statusCode: handler.status,
httpVersion: "HTTP/1.1",
headerFields: nil
)
else {
client?.urlProtocol(self, didFailWithError: URLError(.badURL))
return
}
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: handler.data)
client?.urlProtocolDidFinishLoading(self)
}
override func stopLoading() {}
}

Expected Impact for End-Users

Users starting native playback from a Stremio stream with OpenSubtitlesV3 external subtitles should see the captions button become available once VLC exposes the subtitle track. Selecting “None” should turn captions off, selecting the external track should turn them back on, and opening a later stream should not inherit subtitle candidates from the previous playback session.

Validation

Issues, Limitations, and Mitigations

MobileVLCKit exposes external subtitle tracks asynchronously, so the backend now schedules several refreshes after each attachment. This mitigates the common timing gap but does not replace real-device validation against the exact OpenSubtitles stream flow.

The Xcode build still reports the existing CocoaPods script warning that the MobileVLCKit prepare phase has no declared outputs. The build succeeds, and this change does not alter that script phase.

Follow-up Work