From 6008272d0a0faf3a529e78d6a0ee459b8b9100d8 Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Mon, 25 May 2026 12:18:45 -0400 Subject: [PATCH] fix opensubtitles manifest subtitle urls --- .beads/interactions.jsonl | 1 + .beads/issues.jsonl | 1 + Dreamio/DreamioWebViewController.swift | 27 ++- Dreamio/StreamCandidate.swift | 11 + Tests/StreamResolverTests.swift | 24 ++ ...olve-opensubtitles-subtitle-downloads.html | 216 ++++++++++++++++++ 6 files changed, 275 insertions(+), 5 deletions(-) diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 63c41ef..1417109 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -30,3 +30,4 @@ {"id":"int-323d3a68","kind":"field_change","created_at":"2026-05-25T16:02:09.791701Z","actor":"dirtydishes","issue_id":"dreamio-656","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"fixed"}} {"id":"int-6e411a6a","kind":"field_change","created_at":"2026-05-25T16:03:23.023525Z","actor":"dirtydishes","issue_id":"dreamio-656","extra":{"field":"status","new_value":"in_progress","old_value":"open"}} {"id":"int-fe1c7364","kind":"field_change","created_at":"2026-05-25T16:04:54.482803Z","actor":"dirtydishes","issue_id":"dreamio-656","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"fixed"}} +{"id":"int-f9deecdb","kind":"field_change","created_at":"2026-05-25T16:18:29.458162Z","actor":"dirtydishes","issue_id":"dreamio-urs","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed by rejecting OpenSubtitles manifest.json_N identifiers as playable subtitle URLs, promoting file_id values to API download URLs, and adding parser coverage for the live log shape."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 8073c1f..2260149 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-urs","title":"Fix OpenSubtitles manifest-style subtitle URLs","description":"OpenSubtitles subtitle candidates discovered from Stremio are being resolved as manifest.json_N URLs, producing 404s and leaving only embedded subtitles visible. Preserve and resolve real subtitle URLs so external subtitle tracks can attach in the native player.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T16:16:52Z","created_by":"dirtydishes","updated_at":"2026-05-25T16:18:29Z","started_at":"2026-05-25T16:16:57Z","closed_at":"2026-05-25T16:18:29Z","close_reason":"Fixed by rejecting OpenSubtitles manifest.json_N identifiers as playable subtitle URLs, promoting file_id values to API download URLs, and adding parser coverage for the live log shape.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-656","title":"Capture OpenSubtitles candidates from Stremio app-state messages","description":"OpenSubtitlesV3 appears loaded in Stremio before native playback launches, but Dreamio forwards zero external subtitle candidates. The likely failure is not native-player timing; it is that the injected WebKit bridge does not extract Stremio's loaded subtitle metadata/state into URL candidates before opening VLC.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T16:00:09Z","created_by":"dirtydishes","updated_at":"2026-05-25T16:04:54Z","started_at":"2026-05-25T16:00:18Z","closed_at":"2026-05-25T16:04:54Z","close_reason":"fixed","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} diff --git a/Dreamio/DreamioWebViewController.swift b/Dreamio/DreamioWebViewController.swift index 78ffbcd..daa6544 100644 --- a/Dreamio/DreamioWebViewController.swift +++ b/Dreamio/DreamioWebViewController.swift @@ -115,6 +115,26 @@ final class DreamioWebViewController: UIViewController { } }; + const isOpenSubtitlesManifestID = (url) => { + try { + const parsed = new URL(url, window.location.href); + return /opensubtitles/i.test(parsed.hostname) + && /\/manifest\.json(?:_\d+)?$/i.test(parsed.pathname); + } catch (_) { + return false; + } + }; + + const isSubtitleURL = (url) => { + if (!url || isOpenSubtitlesManifestID(url)) { + return false; + } + subtitleURLPattern.lastIndex = 0; + const matches = subtitleURLPattern.test(url) || /api\.opensubtitles\.com\/api\/v1\/download/i.test(url); + subtitleURLPattern.lastIndex = 0; + return matches; + }; + const findResolverURL = () => { const links = Array.from(document.querySelectorAll("a[href], [data-href], [data-url]")); const match = links @@ -200,15 +220,12 @@ final class DreamioWebViewController: UIViewController { entry.fileURL ); let url = absoluteURL(rawURL); - if (!url && entry && entry.file_id) { + if ((!url || isOpenSubtitlesManifestID(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; + if (!isSubtitleURL(url)) { return; } - subtitleURLPattern.lastIndex = 0; if (subtitleCandidates.some((candidate) => candidate.url === url)) { return; } diff --git a/Dreamio/StreamCandidate.swift b/Dreamio/StreamCandidate.swift index 3e09d57..99f971c 100644 --- a/Dreamio/StreamCandidate.swift +++ b/Dreamio/StreamCandidate.swift @@ -245,6 +245,9 @@ enum SubtitleCandidateParser { } let lowercased = url.absoluteString.lowercased() + if isOpenSubtitlesManifestIdentifier(url) { + return nil + } guard supportedExtensions.contains(url.pathExtension.lowercased()) || supportedExtensions.contains(where: { lowercased.contains(".\($0)?") || lowercased.contains(".\($0)&") }) || lowercased.contains("subtitle") @@ -256,6 +259,14 @@ enum SubtitleCandidateParser { return url } + private static func isOpenSubtitlesManifestIdentifier(_ url: URL) -> Bool { + guard url.host?.localizedCaseInsensitiveContains("opensubtitles") == true else { + return false + } + let path = url.path.lowercased() + return path == "/manifest.json" || path.range(of: #"/manifest\.json_\d+$"#, options: .regularExpression) != nil + } + private static func openSubtitlesDownloadURL(from value: Any?) -> URL? { let id: String? if let string = value as? String, !string.isEmpty { diff --git a/Tests/StreamResolverTests.swift b/Tests/StreamResolverTests.swift index 7137f48..330bf53 100644 --- a/Tests/StreamResolverTests.swift +++ b/Tests/StreamResolverTests.swift @@ -11,6 +11,7 @@ struct StreamResolverTests { testSubtitleCandidateParsing() testOpenSubtitlesV3CandidateParsing() testOpenSubtitlesNestedAttributesFilesParsing() + testOpenSubtitlesManifestIDsAreNotResolvedAsSubtitles() testOpenSubtitlesV3DownloadResponseResolution() testOpenSubtitlesNestedDownloadResponseResolution() await testSubtitleResolverDownloadJSONReturningLink() @@ -189,6 +190,29 @@ struct StreamResolverTests { assertEqual(candidates[1].language, "eng") } + private static func testOpenSubtitlesManifestIDsAreNotResolvedAsSubtitles() { + let payload: [String: Any] = [ + "subtitles": [ + [ + "url": "https://opensubtitles-v3.strem.io/manifest.json_14", + "file_id": 98765, + "lang": "eng" + ], + [ + "url": "https://opensubtitles-v3.strem.io/manifest.json_15", + "lang": "spa" + ], + "https://opensubtitles-v3.strem.io/manifest.json_16" + ] + ] + + let candidates = SubtitleCandidateParser.candidates(in: payload) + + assertEqual(candidates.count, 1) + assertEqual(candidates[0].url.absoluteString, "https://api.opensubtitles.com/api/v1/download/98765") + assertEqual(candidates[0].language, "eng") + } + private static func testOpenSubtitlesV3DownloadResponseResolution() { let payload = """ { diff --git a/docs/turns/2026-05-25-resolve-opensubtitles-subtitle-downloads.html b/docs/turns/2026-05-25-resolve-opensubtitles-subtitle-downloads.html index 2431244..be19e88 100644 --- a/docs/turns/2026-05-25-resolve-opensubtitles-subtitle-downloads.html +++ b/docs/turns/2026-05-25-resolve-opensubtitles-subtitle-downloads.html @@ -624,6 +624,222 @@ + +
+

New Changes as of May 25, 2026 at 12:18 PM EDT

+

Summary of changes

+

Dreamio now rejects OpenSubtitles addon manifest identifiers such as manifest.json_14 as playable subtitle URLs. When the same payload includes a real OpenSubtitles file_id, Dreamio promotes that ID to the API download endpoint instead.

+

Why this change was made

+

Live debug logs showed twenty OpenSubtitles candidates reaching the native player, but every candidate resolved as https://opensubtitles-v3.strem.io/manifest.json_N and returned HTTP 404. That left VLC with only the embedded MKV subtitle track visible in the UI.

+

Code diffs

+

DreamioWebViewController.swift

Dreamio/DreamioWebViewController.swift
-5+22
114 unmodified lines
115
116
117
118
119
120
79 unmodified lines
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
114 unmodified lines
}
};
+
const findResolverURL = () => {
const links = Array.from(document.querySelectorAll("a[href], [data-href], [data-url]"));
const match = links
79 unmodified lines
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;
}
subtitleURLPattern.lastIndex = 0;
if (subtitleCandidates.some((candidate) => candidate.url === url)) {
return;
}
114 unmodified lines
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
79 unmodified lines
220
221
222
223
224
225
226
227
228
229
230
231
114 unmodified lines
}
};
+
const isOpenSubtitlesManifestID = (url) => {
try {
const parsed = new URL(url, window.location.href);
return /opensubtitles/i.test(parsed.hostname)
&& /\/manifest\.json(?:_\d+)?$/i.test(parsed.pathname);
} catch (_) {
return false;
}
};
+
const isSubtitleURL = (url) => {
if (!url || isOpenSubtitlesManifestID(url)) {
return false;
}
subtitleURLPattern.lastIndex = 0;
const matches = subtitleURLPattern.test(url) || /api\.opensubtitles\.com\/api\/v1\/download/i.test(url);
subtitleURLPattern.lastIndex = 0;
return matches;
};
+
const findResolverURL = () => {
const links = Array.from(document.querySelectorAll("a[href], [data-href], [data-url]"));
const match = links
79 unmodified lines
entry.fileURL
);
let url = absoluteURL(rawURL);
if ((!url || isOpenSubtitlesManifestID(url)) && entry && entry.file_id) {
url = `https://api.opensubtitles.com/api/v1/download/${encodeURIComponent(String(entry.file_id))}`;
}
if (!isSubtitleURL(url)) {
return;
}
if (subtitleCandidates.some((candidate) => candidate.url === url)) {
return;
}
+

StreamCandidate.swift

Dreamio/StreamCandidate.swift
+11
244 unmodified lines
245
246
247
248
249
250
5 unmodified lines
256
257
258
259
260
261
244 unmodified lines
}
+
let lowercased = url.absoluteString.lowercased()
guard supportedExtensions.contains(url.pathExtension.lowercased())
|| supportedExtensions.contains(where: { lowercased.contains(".\($0)?") || lowercased.contains(".\($0)&") })
|| lowercased.contains("subtitle")
5 unmodified lines
return url
}
+
private static func openSubtitlesDownloadURL(from value: Any?) -> URL? {
let id: String?
if let string = value as? String, !string.isEmpty {
244 unmodified lines
245
246
247
248
249
250
251
252
253
5 unmodified lines
259
260
261
262
263
264
265
266
267
268
269
270
271
272
244 unmodified lines
}
+
let lowercased = url.absoluteString.lowercased()
if isOpenSubtitlesManifestIdentifier(url) {
return nil
}
guard supportedExtensions.contains(url.pathExtension.lowercased())
|| supportedExtensions.contains(where: { lowercased.contains(".\($0)?") || lowercased.contains(".\($0)&") })
|| lowercased.contains("subtitle")
5 unmodified lines
return url
}
+
private static func isOpenSubtitlesManifestIdentifier(_ url: URL) -> Bool {
guard url.host?.localizedCaseInsensitiveContains("opensubtitles") == true else {
return false
}
let path = url.path.lowercased()
return path == "/manifest.json" || path.range(of: #"/manifest\.json_\d+$"#, options: .regularExpression) != nil
}
+
private static func openSubtitlesDownloadURL(from value: Any?) -> URL? {
let id: String?
if let string = value as? String, !string.isEmpty {
+

StreamResolverTests.swift

Tests/StreamResolverTests.swift
+24
10 unmodified lines
11
12
13
14
15
16
172 unmodified lines
189
190
191
192
193
194
10 unmodified lines
testSubtitleCandidateParsing()
testOpenSubtitlesV3CandidateParsing()
testOpenSubtitlesNestedAttributesFilesParsing()
testOpenSubtitlesV3DownloadResponseResolution()
testOpenSubtitlesNestedDownloadResponseResolution()
await testSubtitleResolverDownloadJSONReturningLink()
172 unmodified lines
assertEqual(candidates[1].language, "eng")
}
+
private static func testOpenSubtitlesV3DownloadResponseResolution() {
let payload = """
{
10 unmodified lines
11
12
13
14
15
16
17
172 unmodified lines
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
10 unmodified lines
testSubtitleCandidateParsing()
testOpenSubtitlesV3CandidateParsing()
testOpenSubtitlesNestedAttributesFilesParsing()
testOpenSubtitlesManifestIDsAreNotResolvedAsSubtitles()
testOpenSubtitlesV3DownloadResponseResolution()
testOpenSubtitlesNestedDownloadResponseResolution()
await testSubtitleResolverDownloadJSONReturningLink()
172 unmodified lines
assertEqual(candidates[1].language, "eng")
}
+
private static func testOpenSubtitlesManifestIDsAreNotResolvedAsSubtitles() {
let payload: [String: Any] = [
"subtitles": [
[
"url": "https://opensubtitles-v3.strem.io/manifest.json_14",
"file_id": 98765,
"lang": "eng"
],
[
"url": "https://opensubtitles-v3.strem.io/manifest.json_15",
"lang": "spa"
],
"https://opensubtitles-v3.strem.io/manifest.json_16"
]
]
+
let candidates = SubtitleCandidateParser.candidates(in: payload)
+
assertEqual(candidates.count, 1)
assertEqual(candidates[0].url.absoluteString, "https://api.opensubtitles.com/api/v1/download/98765")
assertEqual(candidates[0].language, "eng")
}
+
private static func testOpenSubtitlesV3DownloadResponseResolution() {
let payload = """
{
+ +

Related issues or PRs

+

Related Beads issue: dreamio-urs.

+
+

Follow-up Work