Dreamio turn document ยท 2026-05-25

Fix OpenSubtitles Native Captions

OpenSubtitles candidates now survive more Stremio and OpenSubtitles payload shapes, resolve through nested download responses, attach to VLC with clearer diagnostics, and get preferred when external tracks become visible after playback has already started.

Beads: dreamio-hzj Branch: lavender/opensubtitles Native player captions

Summary

Hardened the external subtitle path so OpenSubtitles tracks are more likely to appear as selectable VLC caption tracks alongside embedded MKV subtitles. The change focuses on discovery, candidate parsing, resolver compatibility, VLC visibility timing, and debug output.

Changes Made

Context

The native captions menu was already able to show embedded VLC subtitle tracks, which narrowed the problem to external subtitle handoff. OpenSubtitles data can arrive as direct file URLs, API download URLs, nested file objects, or delayed network payloads after native playback has started. VLC also exposes subtitle slaves asynchronously, so the first menu refresh can happen before an external track exists.

Important Implementation Details

Relevant Diff Snippets

Rendered with @pierre/diffs/ssr from the working tree diff.

Dreamio/DreamioWebViewController.swift
-5+35
83 unmodified lines
84
85
86
87
88
89
42 unmodified lines
132
133
134
135
136
137
138
139
140
141
40 unmodified lines
182
183
184
185
186
187
188
189
190
7 unmodified lines
198
199
200
201
202
203
204
59 unmodified lines
264
265
266
267
268
269
83 unmodified lines
const postedSubtitleURLs = new Set();
const subtitleURLPattern = /https?:\/\/[^\s"'<>]+(?:\.srt|\.vtt|\.ass|\.ssa|\.sub|opensubtitles|subtitle)[^\s"'<>]*/ig;
const subtitleSignalPattern = /subtitle|subtitles|opensubtitles|vtt|srt|ass|ssa/i;
const looksNative = (url) => {
if (!url || typeof url !== "string") {
42 unmodified lines
const postSubtitleCandidates = (candidates, debug = {}) => {
const discoveredCount = candidates.length;
const fresh = candidates.filter((candidate) => {
if (postedSubtitleURLs.has(candidate.url)) {
return false;
}
postedSubtitleURLs.add(candidate.url);
return true;
});
if (fresh.length === 0) {
40 unmodified lines
entry.fileUrl ||
entry.fileURL
);
const url = absoluteURL(rawURL);
subtitleURLPattern.lastIndex = 0;
if (!url || !subtitleURLPattern.test(url)) {
subtitleURLPattern.lastIndex = 0;
return;
}
7 unmodified lines
language: entry && (entry.lang || entry.language) || ""
};
subtitleCandidates.push(candidate);
postSubtitleCandidates([candidate]);
};
const inspectTrack = (track) => {
59 unmodified lines
}
if (typeof payload === "object") {
addSubtitleCandidate(payload);
Object.values(payload).forEach(inspectSubtitlePayload);
}
};
83 unmodified lines
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
42 unmodified lines
145
146
147
148
149
150
151
152
153
154
155
156
157
158
40 unmodified lines
199
200
201
202
203
204
205
206
207
208
209
210
7 unmodified lines
218
219
220
221
222
223
224
225
226
227
59 unmodified lines
287
288
289
290
291
292
293
294
295
296
297
298
299
83 unmodified lines
const postedSubtitleURLs = new Set();
const subtitleURLPattern = /https?:\/\/[^\s"'<>]+(?:\.srt|\.vtt|\.ass|\.ssa|\.sub|opensubtitles|subtitle)[^\s"'<>]*/ig;
const subtitleSignalPattern = /subtitle|subtitles|opensubtitles|vtt|srt|ass|ssa/i;
const subtitleObjectKeys = [
"attributes",
"files",
"file_id",
"url",
"download",
"link",
"file",
"file_name",
"filename",
"language",
"lang"
];
const looksNative = (url) => {
if (!url || typeof url !== "string") {
42 unmodified lines
const postSubtitleCandidates = (candidates, debug = {}) => {
const discoveredCount = candidates.length;
const fresh = candidates.filter((candidate) => {
const key = candidate && (candidate.url || candidate.link || candidate.download || candidate.file || candidate.file_id);
if (!key) {
return false;
}
if (postedSubtitleURLs.has(String(key))) {
return false;
}
postedSubtitleURLs.add(String(key));
return true;
});
if (fresh.length === 0) {
40 unmodified lines
entry.fileUrl ||
entry.fileURL
);
let url = absoluteURL(rawURL);
if (!url && entry && entry.file_id) {
url = `https://api.opensubtitles.com/api/v1/download/${encodeURIComponent(String(entry.file_id))}`;
}
subtitleURLPattern.lastIndex = 0;
if (!url || (!subtitleURLPattern.test(url) && !/api\.opensubtitles\.com\/api\/v1\/download/i.test(url))) {
subtitleURLPattern.lastIndex = 0;
return;
}
7 unmodified lines
language: entry && (entry.lang || entry.language) || ""
};
subtitleCandidates.push(candidate);
postSubtitleCandidates([candidate], {
discovered: 1,
totalKnown: subtitleCandidates.length
});
};
const inspectTrack = (track) => {
59 unmodified lines
}
if (typeof payload === "object") {
addSubtitleCandidate(payload);
const likelySubtitlePayload = subtitleObjectKeys.some((key) => Object.prototype.hasOwnProperty.call(payload, key));
if (likelySubtitlePayload) {
postSubtitleCandidates([payload], {
source: "payload-object",
totalKnown: subtitleCandidates.length
});
}
Object.values(payload).forEach(inspectSubtitlePayload);
}
};
Dreamio/StreamCandidate.swift
-4+22
131 unmodified lines
132
133
134
135
136
137
138
139
54 unmodified lines
194
195
196
197
198
199
200
13 unmodified lines
214
215
216
217
218
219
220
33 unmodified lines
254
255
256
257
258
259
131 unmodified lines
enum SubtitleCandidateParser {
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?
54 unmodified lines
}
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
}
13 unmodified lines
}
private static func orderedNestedValues(in dictionary: [String: Any]) -> [Any] {
let preferredKeys = ["subtitles", "subtitle", "files", "downloads", "download"]
var visitedKeys = Set<String>()
var values: [Any] = []
33 unmodified lines
return url
}
private static func defaultLabel(for url: URL) -> String {
let lastPathComponent = url.deletingPathExtension().lastPathComponent
return lastPathComponent.isEmpty ? "External Subtitle" : lastPathComponent
131 unmodified lines
132
133
134
135
136
137
138
139
54 unmodified lines
194
195
196
197
198
199
200
201
202
13 unmodified lines
216
217
218
219
220
221
222
33 unmodified lines
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
131 unmodified lines
enum SubtitleCandidateParser {
private static let supportedExtensions = ["srt", "vtt", "ass", "ssa", "sub"]
private static let urlFields = ["url", "href", "src", "link", "subtitles", "subtitle", "subtitleUrl", "subtitleURL", "file", "download", "fileUrl", "fileURL"]
private static let labelFields = ["label", "name", "title", "file_name", "filename", "lang", "language", "id"]
private struct CandidateContext {
let label: String?
let language: String?
54 unmodified lines
}
private static func candidate(from dictionary: [String: Any], context: CandidateContext) -> SubtitleCandidate? {
guard let url = urlFields.lazy.compactMap({ subtitleURL(from: dictionary[$0] as? String) }).first
?? openSubtitlesDownloadURL(from: dictionary["file_id"])
else {
return nil
}
13 unmodified lines
}
private static func orderedNestedValues(in dictionary: [String: Any]) -> [Any] {
let preferredKeys = ["attributes", "subtitles", "subtitle", "files", "downloads", "download", "data", "results"]
var visitedKeys = Set<String>()
var values: [Any] = []
33 unmodified lines
return url
}
private static func openSubtitlesDownloadURL(from value: Any?) -> URL? {
let id: String?
if let string = value as? String, !string.isEmpty {
id = string
} else if let number = value as? NSNumber {
id = number.stringValue
} else {
id = nil
}
guard let id else {
return nil
}
return URL(string: "https://api.opensubtitles.com/api/v1/download/\(id)")
}
private static func defaultLabel(for url: URL) -> String {
let lastPathComponent = url.deletingPathExtension().lastPathComponent
return lastPathComponent.isEmpty ? "External Subtitle" : lastPathComponent
Dreamio/VLCNativePlaybackBackend.swift
-7+39
25 unmodified lines
26
27
28
29
30
31
15 unmodified lines
47
48
49
50
51
52
172 unmodified lines
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
1 unmodified line
248
249
250
251
252
253
254
255
256
9 unmodified lines
266
267
268
269
270
271
272
273
274
275
276
277
278
279
3 unmodified lines
283
284
285
286
287
288
44 unmodified lines
333
334
335
336
337
338
339
25 unmodified lines
private var didAutoSelectSubtitleTrack = false
private var didUserSelectSubtitleTrack = false
private var autoSelectedSubtitleTrackID: Int32?
override init() {
super.init()
15 unmodified lines
didAutoSelectSubtitleTrack = false
didUserSelectSubtitleTrack = false
autoSelectedSubtitleTrackID = nil
let media = VLCMedia(url: request.playbackURL)
let headerValue = request.headers
.map { "\($0.key): \($0.value)" }
172 unmodified lines
private func attachSubtitles(_ candidates: [SubtitleCandidate]) -> Int {
var attachedCount = 0
var duplicateCount = 0
candidates.forEach { candidate in
guard !attachedSubtitleURLs.contains(candidate.url) else {
duplicateCount += 1
return
}
attachedSubtitleURLs.insert(candidate.url)
mediaPlayer.addPlaybackSlave(candidate.url, type: .subtitle, enforce: false)
attachedCount += 1
#if DEBUG
print("[DreamioVLC] addPlaybackSlave subtitle=\(URLRedactor.redactedURLString(candidate.url.absoluteString)) label=\(candidate.label) language=\(candidate.language ?? "unknown") ext=\(candidate.url.pathExtension.lowercased())")
logSubtitleTracks(reason: "after-addPlaybackSlave")
#endif
}
#if DEBUG
if !candidates.isEmpty {
print("[DreamioVLC] subtitle candidates=\(candidates.count) attached=\(attachedCount) duplicates=\(duplicateCount)")
}
#endif
guard attachedCount > 0 else {
1 unmodified line
}
[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?()
}
9 unmodified lines
}
#endif
private func selectInitialSubtitleTrackIfNeeded(reason: String) {
guard !didUserSelectSubtitleTrack,
!didAutoSelectSubtitleTrack,
mediaPlayer.currentVideoSubTitleIndex < 0,
let track = subtitleTracks.first(where: { $0.id >= 0 }) else {
return
}
didAutoSelectSubtitleTrack = true
autoSelectedSubtitleTrackID = track.id
#if DEBUG
3 unmodified lines
scheduleAutoSubtitleSelectionReapply(trackID: track.id)
}
private func scheduleAutoSubtitleSelectionReapply(trackID: Int32) {
[0.3, 1.0, 2.0, 4.0].forEach { delay in
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
44 unmodified lines
case .paused, .stopped, .ended:
onStateChange?()
case .esAdded:
selectInitialSubtitleTrackIfNeeded(reason: "esAdded")
#if DEBUG
logSubtitleTracks(reason: "esAdded")
#endif
25 unmodified lines
26
27
28
29
30
31
32
33
15 unmodified lines
49
50
51
52
53
54
55
56
172 unmodified lines
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
1 unmodified line
255
256
257
258
259
260
261
262
263
264
265
266
9 unmodified lines
276
277
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
3 unmodified lines
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
44 unmodified lines
365
366
367
368
369
370
371
25 unmodified lines
private var didAutoSelectSubtitleTrack = false
private var didUserSelectSubtitleTrack = false
private var autoSelectedSubtitleTrackID: Int32?
private var externalSubtitleBaselineTrackIDs = Set<Int32>()
private var hasPendingExternalSubtitleSelection = false
override init() {
super.init()
15 unmodified lines
didAutoSelectSubtitleTrack = false
didUserSelectSubtitleTrack = false
autoSelectedSubtitleTrackID = nil
externalSubtitleBaselineTrackIDs.removeAll()
hasPendingExternalSubtitleSelection = false
let media = VLCMedia(url: request.playbackURL)
let headerValue = request.headers
.map { "\($0.key): \($0.value)" }
172 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
return
}
attachedSubtitleURLs.insert(candidate.url)
externalSubtitleBaselineTrackIDs.formUnion(baselineTrackIDs)
hasPendingExternalSubtitleSelection = true
mediaPlayer.addPlaybackSlave(candidate.url, type: .subtitle, enforce: false)
attachedCount += 1
#if DEBUG
print("[DreamioVLC] attach accepted subtitle=\(URLRedactor.redactedURLString(candidate.url.absoluteString)) label=\(candidate.label) language=\(candidate.language ?? "unknown") ext=\(candidate.url.pathExtension.lowercased()) visibleBefore=\(baselineTrackIDs.count)")
logSubtitleTracks(reason: "after-addPlaybackSlave")
#endif
}
#if DEBUG
if !candidates.isEmpty {
print("[DreamioVLC] subtitle candidates=\(candidates.count) attached=\(attachedCount) duplicates=\(duplicateCount) visible=\(subtitleTracks.filter { $0.id >= 0 }.count)")
}
#endif
guard attachedCount > 0 else {
1 unmodified line
}
[0.2, 0.6, 1.0, 2.0, 4.0].forEach { delay in
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.selectPreferredSubtitleTrackIfNeeded(reason: "delayed-refresh-\(String(format: "%.1f", delay))")
#if DEBUG
self?.logSubtitleTracks(reason: "delayed-refresh-\(String(format: "%.1f", delay))")
if delay == 4.0 {
self?.logMissingExternalSubtitleTrackIfNeeded()
}
#endif
self?.onSubtitleTracksChange?()
}
9 unmodified lines
}
#endif
private func selectPreferredSubtitleTrackIfNeeded(reason: String) {
guard !didUserSelectSubtitleTrack else {
return
}
if hasPendingExternalSubtitleSelection,
let externalTrack = subtitleTracks.first(where: { $0.id >= 0 && !externalSubtitleBaselineTrackIDs.contains($0.id) }) {
selectAutoSubtitleTrack(externalTrack, reason: "\(reason)-external")
hasPendingExternalSubtitleSelection = false
return
}
guard !didAutoSelectSubtitleTrack,
mediaPlayer.currentVideoSubTitleIndex < 0,
let track = subtitleTracks.first(where: { $0.id >= 0 }) else {
return
}
selectAutoSubtitleTrack(track, reason: reason)
}
private func selectAutoSubtitleTrack(_ track: SubtitleTrack, reason: String) {
didAutoSelectSubtitleTrack = true
autoSelectedSubtitleTrackID = track.id
#if DEBUG
3 unmodified lines
scheduleAutoSubtitleSelectionReapply(trackID: track.id)
}
#if DEBUG
private func logMissingExternalSubtitleTrackIfNeeded() {
guard hasPendingExternalSubtitleSelection else {
return
}
print("[DreamioVLC] attach accepted but no new external subtitle track visible baseline=\(externalSubtitleBaselineTrackIDs.sorted()) visible=\(subtitleTracks.filter { $0.id >= 0 }.map(\.id))")
}
#endif
private func scheduleAutoSubtitleSelectionReapply(trackID: Int32) {
[0.3, 1.0, 2.0, 4.0].forEach { delay in
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
44 unmodified lines
case .paused, .stopped, .ended:
onStateChange?()
case .esAdded:
selectPreferredSubtitleTrackIfNeeded(reason: "esAdded")
#if DEBUG
logSubtitleTracks(reason: "esAdded")
#endif
Tests/StreamResolverTests.swift
+73
9 unmodified lines
10
11
12
13
14
15
16
137 unmodified lines
154
155
156
157
158
159
19 unmodified lines
179
180
181
182
183
184
9 unmodified lines
testPlaybackTimeFormatting()
testSubtitleCandidateParsing()
testOpenSubtitlesV3CandidateParsing()
testOpenSubtitlesV3DownloadResponseResolution()
await testSubtitleResolverDownloadJSONReturningLink()
await testSubtitleResolverRedirectToDirectSubtitle()
await testSubtitleResolverRejectsNonSubtitleAPIResponse()
137 unmodified lines
assertEqual(candidates[3].url.absoluteString, "https://cdn.example.test/from-string.ass?source=opensubtitles")
}
private static func testOpenSubtitlesV3DownloadResponseResolution() {
let payload = """
{
19 unmodified lines
assertEqual(candidate?.language, "eng")
}
private static func testSubtitleResolverDownloadJSONReturningLink() async {
MockURLProtocol.handlers = [
"https://api.opensubtitles.com/api/v1/download/123": (
9 unmodified lines
10
11
12
13
14
15
16
17
18
137 unmodified lines
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
192
193
194
19 unmodified lines
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
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
9 unmodified lines
testPlaybackTimeFormatting()
testSubtitleCandidateParsing()
testOpenSubtitlesV3CandidateParsing()
testOpenSubtitlesNestedAttributesFilesParsing()
testOpenSubtitlesV3DownloadResponseResolution()
testOpenSubtitlesNestedDownloadResponseResolution()
await testSubtitleResolverDownloadJSONReturningLink()
await testSubtitleResolverRedirectToDirectSubtitle()
await testSubtitleResolverRejectsNonSubtitleAPIResponse()
137 unmodified lines
assertEqual(candidates[3].url.absoluteString, "https://cdn.example.test/from-string.ass?source=opensubtitles")
}
private static func testOpenSubtitlesNestedAttributesFilesParsing() {
let payload: [String: Any] = [
"data": [
[
"attributes": [
"language": "English",
"file_name": "episode.en.srt",
"files": [
[
"file_id": 12345,
"file_name": "nested.en.srt"
],
[
"link": "https://dl.opensubtitles.org/en/download/nested.vtt?token=secret",
"language": "eng"
]
]
]
]
]
]
let candidates = SubtitleCandidateParser.candidates(in: payload)
assertEqual(candidates.count, 2)
assertEqual(candidates[0].url.absoluteString, "https://api.opensubtitles.com/api/v1/download/12345")
assertEqual(candidates[0].label, "nested.en.srt")
assertEqual(candidates[0].language, "English")
assertEqual(candidates[1].url.absoluteString, "https://dl.opensubtitles.org/en/download/nested.vtt?token=secret")
assertEqual(candidates[1].label, "eng")
assertEqual(candidates[1].language, "eng")
}
private static func testOpenSubtitlesV3DownloadResponseResolution() {
let payload = """
{
19 unmodified lines
assertEqual(candidate?.language, "eng")
}
private static func testOpenSubtitlesNestedDownloadResponseResolution() {
let payload = """
{
"data": {
"attributes": {
"files": [
{
"file_name": "ignored.txt",
"link": "https://cdn.example.test/ignored.txt"
},
{
"file_name": "episode.en.ass",
"download": {
"link": "https://dl.opensubtitles.org/en/download/episode.en.ass?token=secret"
}
}
]
}
}
}
""".data(using: .utf8)!
let original = SubtitleCandidate(
url: URL(string: "https://api.opensubtitles.com/api/v1/download/987")!,
label: "English SDH",
language: "eng"
)
let candidate = SubtitleResolver.bestPlayableCandidate(
from: payload,
responseURL: original.url,
original: original
)
assertEqual(candidate?.url.absoluteString, "https://dl.opensubtitles.org/en/download/episode.en.ass?token=secret")
assertEqual(candidate?.label, "English SDH")
assertEqual(candidate?.language, "eng")
}
private static func testSubtitleResolverDownloadJSONReturningLink() async {
MockURLProtocol.handlers = [
"https://api.opensubtitles.com/api/v1/download/123": (

Expected Impact for End-Users

When OpenSubtitles provides usable captions, the native captions menu should show external OpenSubtitles options in addition to None and embedded subtitle tracks. If an embedded track appears first, Dreamio can still switch to the external track automatically once VLC surfaces it, unless the user already made a manual caption choice.

Validation

Issues, Limitations, and Mitigations

Follow-up Work