From f34d60af1b7efe838b60c72811cbeebbcf470f60 Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Mon, 25 May 2026 11:54:14 -0400 Subject: [PATCH] fix opensubtitles native captions --- .beads/interactions.jsonl | 1 + .beads/issues.jsonl | 1 + Dreamio/DreamioWebViewController.swift | 40 +- Dreamio/StreamCandidate.swift | 26 +- Dreamio/VLCNativePlaybackBackend.swift | 46 +- Tests/StreamResolverTests.swift | 73 +++ ...-25-fix-opensubtitles-native-captions.html | 495 ++++++++++++++++++ 7 files changed, 666 insertions(+), 16 deletions(-) create mode 100644 docs/turns/2026-05-25-fix-opensubtitles-native-captions.html diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index c5103ea..c9116fb 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -25,3 +25,4 @@ {"id":"int-027cec57","kind":"field_change","created_at":"2026-05-25T14:51:44.599319Z","actor":"dirtydishes","issue_id":"dreamio-3xi","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Captured OpenSubtitles V3 subtitle URLs from browser track elements and textTracks so they can be forwarded to native playback."}} {"id":"int-3acaadff","kind":"field_change","created_at":"2026-05-25T15:09:02.023077Z","actor":"dirtydishes","issue_id":"dreamio-h5n","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Limited VLC auto-subtitle reapply to real selection recovery while keeping bounded delayed startup confirmations."}} {"id":"int-c526b5ae","kind":"field_change","created_at":"2026-05-25T15:32:37.748454Z","actor":"dirtydishes","issue_id":"dreamio-dow","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented stream-keyed subtitle buffering, OpenSubtitles parser/resolver hardening, VLC refresh behavior, and focused validation."}} +{"id":"int-320e7321","kind":"field_change","created_at":"2026-05-25T15:53:52.866657Z","actor":"dirtydishes","issue_id":"dreamio-hzj","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Hardened OpenSubtitles candidate discovery, nested payload resolution, VLC external subtitle visibility selection, diagnostics, tests, and turn documentation."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index fbc4139..a13d943 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,4 +1,5 @@ {"_type":"issue","id":"dreamio-8cz","title":"fix stremio external subtitle loading regression","description":"After adding late subtitle forwarding for native playback, Stremio external subtitle loading is failing. Investigate the injected bridge and native subtitle forwarding path, then adjust behavior so Stremio can still load external subtitles while native playback receives late candidates.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T11:05:42Z","created_by":"dirtydishes","updated_at":"2026-05-25T11:07:35Z","started_at":"2026-05-25T11:05:55Z","closed_at":"2026-05-25T11:07:35Z","close_reason":"Hardened subtitle bridge network observers so non-text Stremio subtitle loads are not touched, and made parser traversal deterministic for metadata preservation.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-hzj","title":"OpenSubtitles tracks missing from native captions menu","description":"OpenSubtitles subtitle candidates can be discovered or resolved inconsistently, and external VLC subtitle slaves may not become visible quickly enough to show as selectable native caption tracks. Harden discovery, resolution, attachment, diagnostics, tests, and turn documentation for the native captions path.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T15:51:07Z","created_by":"dirtydishes","updated_at":"2026-05-25T15:53:53Z","started_at":"2026-05-25T15:51:13Z","closed_at":"2026-05-25T15:53:53Z","close_reason":"Hardened OpenSubtitles candidate discovery, nested payload resolution, VLC external subtitle visibility selection, diagnostics, tests, and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-dow","title":"fix stremio external subtitle handoff to vlc","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T15:17:16Z","created_by":"dirtydishes","updated_at":"2026-05-25T15:32:38Z","started_at":"2026-05-25T15:17:25Z","closed_at":"2026-05-25T15:32:38Z","close_reason":"Implemented stream-keyed subtitle buffering, OpenSubtitles parser/resolver hardening, VLC refresh behavior, and focused validation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-bao","title":"add native player audio track selection","description":"Add audio track discovery and selection to the native VLC-backed player so multi-language files can be filtered from the player controls.","status":"closed","priority":1,"issue_type":"feature","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:57:14Z","created_by":"dirtydishes","updated_at":"2026-05-25T15:01:36Z","closed_at":"2026-05-25T15:01:36Z","close_reason":"Implemented native audio track discovery and selection with a far-left audio menu in the VLC-backed player.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-3xi","title":"Capture browser text tracks for OpenSubtitles V3","description":"OpenSubtitles V3 subtitles can be attached to the Stremio web player as HTML track/textTrack entries rather than appearing in the initial stream candidate. Extend the web bridge to inspect track elements and textTracks so external subtitles can be forwarded to native playback.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:49:50Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:51:45Z","started_at":"2026-05-25T14:49:52Z","closed_at":"2026-05-25T14:51:45Z","close_reason":"Captured OpenSubtitles V3 subtitle URLs from browser track elements and textTracks so they can be forwarded to native playback.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/Dreamio/DreamioWebViewController.swift b/Dreamio/DreamioWebViewController.swift index b0c7ade..95d5e26 100644 --- a/Dreamio/DreamioWebViewController.swift +++ b/Dreamio/DreamioWebViewController.swift @@ -84,6 +84,19 @@ final class DreamioWebViewController: UIViewController { 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") { @@ -132,10 +145,14 @@ final class DreamioWebViewController: UIViewController { const postSubtitleCandidates = (candidates, debug = {}) => { const discoveredCount = candidates.length; const fresh = candidates.filter((candidate) => { - if (postedSubtitleURLs.has(candidate.url)) { + const key = candidate && (candidate.url || candidate.link || candidate.download || candidate.file || candidate.file_id); + if (!key) { return false; } - postedSubtitleURLs.add(candidate.url); + if (postedSubtitleURLs.has(String(key))) { + return false; + } + postedSubtitleURLs.add(String(key)); return true; }); if (fresh.length === 0) { @@ -182,9 +199,12 @@ final class DreamioWebViewController: UIViewController { entry.fileUrl || entry.fileURL ); - const url = absoluteURL(rawURL); + 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)) { + if (!url || (!subtitleURLPattern.test(url) && !/api\.opensubtitles\.com\/api\/v1\/download/i.test(url))) { subtitleURLPattern.lastIndex = 0; return; } @@ -198,7 +218,10 @@ final class DreamioWebViewController: UIViewController { language: entry && (entry.lang || entry.language) || "" }; subtitleCandidates.push(candidate); - postSubtitleCandidates([candidate]); + postSubtitleCandidates([candidate], { + discovered: 1, + totalKnown: subtitleCandidates.length + }); }; const inspectTrack = (track) => { @@ -264,6 +287,13 @@ final class DreamioWebViewController: UIViewController { } 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); } }; diff --git a/Dreamio/StreamCandidate.swift b/Dreamio/StreamCandidate.swift index 4ddc3c5..3e09d57 100644 --- a/Dreamio/StreamCandidate.swift +++ b/Dreamio/StreamCandidate.swift @@ -132,8 +132,8 @@ struct StreamCandidate { 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 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? @@ -194,7 +194,9 @@ enum SubtitleCandidateParser { } 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 { + guard let url = urlFields.lazy.compactMap({ subtitleURL(from: dictionary[$0] as? String) }).first + ?? openSubtitlesDownloadURL(from: dictionary["file_id"]) + else { return nil } @@ -214,7 +216,7 @@ enum SubtitleCandidateParser { } private static func orderedNestedValues(in dictionary: [String: Any]) -> [Any] { - let preferredKeys = ["subtitles", "subtitle", "files", "downloads", "download"] + let preferredKeys = ["attributes", "subtitles", "subtitle", "files", "downloads", "download", "data", "results"] var visitedKeys = Set() var values: [Any] = [] @@ -254,6 +256,22 @@ enum SubtitleCandidateParser { 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 diff --git a/Dreamio/VLCNativePlaybackBackend.swift b/Dreamio/VLCNativePlaybackBackend.swift index 2e9009f..166d59b 100644 --- a/Dreamio/VLCNativePlaybackBackend.swift +++ b/Dreamio/VLCNativePlaybackBackend.swift @@ -26,6 +26,8 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { private var didAutoSelectSubtitleTrack = false private var didUserSelectSubtitleTrack = false private var autoSelectedSubtitleTrackID: Int32? + private var externalSubtitleBaselineTrackIDs = Set() + private var hasPendingExternalSubtitleSelection = false override init() { super.init() @@ -47,6 +49,8 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { 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)" } @@ -225,22 +229,25 @@ 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)) 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] addPlaybackSlave subtitle=\(URLRedactor.redactedURLString(candidate.url.absoluteString)) label=\(candidate.label) language=\(candidate.language ?? "unknown") ext=\(candidate.url.pathExtension.lowercased())") + 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)") + print("[DreamioVLC] subtitle candidates=\(candidates.count) attached=\(attachedCount) duplicates=\(duplicateCount) visible=\(subtitleTracks.filter { $0.id >= 0 }.count)") } #endif guard attachedCount > 0 else { @@ -248,9 +255,12 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { } [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))") + 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?() } @@ -266,14 +276,27 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { } #endif - private func selectInitialSubtitleTrackIfNeeded(reason: String) { - guard !didUserSelectSubtitleTrack, - !didAutoSelectSubtitleTrack, + 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 @@ -283,6 +306,15 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { 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 @@ -333,7 +365,7 @@ extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate { case .paused, .stopped, .ended: onStateChange?() case .esAdded: - selectInitialSubtitleTrackIfNeeded(reason: "esAdded") + selectPreferredSubtitleTrackIfNeeded(reason: "esAdded") #if DEBUG logSubtitleTracks(reason: "esAdded") #endif diff --git a/Tests/StreamResolverTests.swift b/Tests/StreamResolverTests.swift index 2bab41b..7137f48 100644 --- a/Tests/StreamResolverTests.swift +++ b/Tests/StreamResolverTests.swift @@ -10,7 +10,9 @@ struct StreamResolverTests { testPlaybackTimeFormatting() testSubtitleCandidateParsing() testOpenSubtitlesV3CandidateParsing() + testOpenSubtitlesNestedAttributesFilesParsing() testOpenSubtitlesV3DownloadResponseResolution() + testOpenSubtitlesNestedDownloadResponseResolution() await testSubtitleResolverDownloadJSONReturningLink() await testSubtitleResolverRedirectToDirectSubtitle() await testSubtitleResolverRejectsNonSubtitleAPIResponse() @@ -154,6 +156,39 @@ struct StreamResolverTests { 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 = """ { @@ -179,6 +214,44 @@ struct StreamResolverTests { 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": ( diff --git a/docs/turns/2026-05-25-fix-opensubtitles-native-captions.html b/docs/turns/2026-05-25-fix-opensubtitles-native-captions.html new file mode 100644 index 0000000..0901e6b --- /dev/null +++ b/docs/turns/2026-05-25-fix-opensubtitles-native-captions.html @@ -0,0 +1,495 @@ + + + + + + Fix OpenSubtitles Native Captions + + + +
+
+

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

+
    +
  • Added Beads bug dreamio-hzj before implementation.
  • +
  • Expanded the web bridge to recognize subtitle objects with attributes, files, file_id, download, link, file_name, and language metadata.
  • +
  • Extended Swift subtitle candidate parsing for nested OpenSubtitles payloads and file_id download candidates.
  • +
  • Kept parent label and language metadata when nested subtitle URLs are selected during resolution.
  • +
  • Changed VLC attachment behavior so newly visible external tracks are preferred over an earlier automatic embedded-track selection when the user has not manually chosen a track.
  • +
  • Added focused tests for nested OpenSubtitles attributes/files payloads and nested API download responses.
  • +
+
+ +
+

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

+
    +
  • The browser bridge now posts likely subtitle-shaped objects even when they are not immediately reduced to a direct URL. Swift performs the final recursive parsing.
  • +
  • SubtitleCandidateParser now walks attributes, data, and results early, which better matches OpenSubtitles API structures.
  • +
  • file_id values are converted into OpenSubtitles download API candidates so the existing resolver path can try to turn them into direct subtitle files.
  • +
  • VLC records the subtitle track IDs visible before external subtitle attachment. Delayed refreshes use that baseline to identify a newly visible external track.
  • +
  • If VLC accepts a subtitle slave but no new track appears by the final delayed refresh, the debug log now says so explicitly with baseline and visible track counts.
  • +
+
+ +
+

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

+
    +
  • Passed swiftc Dreamio/StreamCandidate.swift Dreamio/StreamResolver.swift Tests/StreamResolverTests.swift -o /tmp/dreamio-stream-tests && /tmp/dreamio-stream-tests.
  • +
  • Passed swiftc -typecheck Dreamio/StreamCandidate.swift Dreamio/StreamResolver.swift.
  • +
  • Passed DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -configuration Debug -sdk iphonesimulator build.
  • +
  • Manual device validation was not performed in this turn. The next device run should verify that OpenSubtitles options appear in the captions menu for a title with OpenSubtitles enabled.
  • +
+
+ +
+

Issues, Limitations, and Mitigations

+
    +
  • OpenSubtitles API download URLs may still require provider-specific authorization. This change preserves and resolves more candidate shapes, but it does not add new API credentials.
  • +
  • VLC subtitle slave exposure is asynchronous and backend-dependent. Delayed refreshes and explicit missing-track logs make that timing easier to diagnose.
  • +
  • The existing dirty Xcode workspace user-state file was present before this work and was not intentionally edited.
  • +
+
+ +
+

Follow-up Work

+
    +
  • Run on a physical device with OpenSubtitles enabled and confirm the menu shows external OpenSubtitles tracks plus embedded tracks.
  • +
  • If device logs still show accepted slaves with no visible VLC track, capture the URL shape and VLC track arrays from the new diagnostics.
  • +
  • Consider exposing external track provenance in the menu if VLC track names remain too generic.
  • +
+
+
+ +