From c571eb88735ed2afe444c95aeaf2429cf721302a Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Mon, 25 May 2026 10:31:54 -0400 Subject: [PATCH] trace stremio subtitle discovery --- .beads/interactions.jsonl | 1 + .beads/issues.jsonl | 1 + Dreamio/DreamioWebViewController.swift | 65 ++++++++++++-- ...-05-25-prove-native-subtitle-pipeline.html | 86 +++++++++++++++++++ 4 files changed, 145 insertions(+), 8 deletions(-) diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 6223229..0b1068f 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -19,3 +19,4 @@ {"id":"int-c7246990","kind":"field_change","created_at":"2026-05-25T14:07:13.774172Z","actor":"dirtydishes","issue_id":"dreamio-e9p","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Added DEBUG-only subtitle pipeline proof logging and documented validation."}} {"id":"int-45781aa3","kind":"field_change","created_at":"2026-05-25T14:19:19.141163Z","actor":"dirtydishes","issue_id":"dreamio-c1m","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Added DEBUG-only logs for captions menu actions and VLC subtitle selection results."}} {"id":"int-6343b773","kind":"field_change","created_at":"2026-05-25T14:25:59.50764Z","actor":"dirtydishes","issue_id":"dreamio-bd9","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Stopped rebuilding the captions menu on every progress refresh and validated the build."}} +{"id":"int-26b872a1","kind":"field_change","created_at":"2026-05-25T14:31:46.83464Z","actor":"dirtydishes","issue_id":"dreamio-ese","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Added subtitle-shaped fetch/XHR inspection diagnostics and validated the build."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 3d7b0e4..c67315b 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-ese","title":"Discover Stremio external subtitle payloads","description":"Extend and instrument the injected web subtitle discovery path so Stremio/OpenSubtitles addon responses can be captured when native playback only sees embedded VLC subtitle tracks.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:29:57Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:31:47Z","started_at":"2026-05-25T14:30:03Z","closed_at":"2026-05-25T14:31:47Z","close_reason":"Added subtitle-shaped fetch/XHR inspection diagnostics and validated the build.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-bd9","title":"Stabilize captions menu refresh","description":"Stop rebuilding the captions UIMenu on every playback progress refresh so embedded subtitle actions can remain stable long enough to fire, while keeping DEBUG logs for menu state and selection.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:24:45Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:25:59Z","started_at":"2026-05-25T14:24:50Z","closed_at":"2026-05-25T14:25:59Z","close_reason":"Stopped rebuilding the captions menu on every progress refresh and validated the build.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-h5q","title":"Resolve OpenSubtitles API subtitle URLs before VLC attachment","description":"OpenSubtitles V3 can surface API/download endpoints that are not subtitle files themselves. Dreamio should resolve those endpoints to playable subtitle file URLs before handing them to VLC so Stremio does not show failed subtitle loads after native playback opens.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T13:47:17Z","created_by":"dirtydishes","updated_at":"2026-05-25T13:50:43Z","started_at":"2026-05-25T13:47:21Z","closed_at":"2026-05-25T13:50:43Z","close_reason":"Resolved OpenSubtitles V3 API-style subtitle download URLs to direct subtitle files before VLC attachment; added parser/resolver coverage and simulator build validation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-lw6","title":"forward late opensubtitles subtitles to native player","description":"Native playback only receives subtitle candidates discovered before the stream candidate is posted. OpenSubtitles V3 candidates can arrive later through addon/network responses, so the active native player needs an append path for newly discovered external subtitles.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T10:40:28Z","created_by":"dirtydishes","updated_at":"2026-05-25T10:43:22Z","started_at":"2026-05-25T10:40:36Z","closed_at":"2026-05-25T10:43:22Z","close_reason":"Implemented late subtitle forwarding into active native playback, added VLC append path and parser tests.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/Dreamio/DreamioWebViewController.swift b/Dreamio/DreamioWebViewController.swift index bc1ccf0..c38ae55 100644 --- a/Dreamio/DreamioWebViewController.swift +++ b/Dreamio/DreamioWebViewController.swift @@ -81,6 +81,7 @@ final class DreamioWebViewController: UIViewController { const subtitleCandidates = []; 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") { @@ -126,7 +127,7 @@ final class DreamioWebViewController: UIViewController { } catch (_) {} }; - const postSubtitleCandidates = (candidates) => { + const postSubtitleCandidates = (candidates, debug = {}) => { const discoveredCount = candidates.length; const fresh = candidates.filter((candidate) => { if (postedSubtitleURLs.has(candidate.url)) { @@ -143,7 +144,8 @@ final class DreamioWebViewController: UIViewController { debug: { discovered: discoveredCount, deduped: 0, - forwarded: 0 + forwarded: 0, + ...debug } }); } catch (_) {} @@ -156,14 +158,28 @@ final class DreamioWebViewController: UIViewController { debug: { discovered: discoveredCount, deduped: fresh.length, - forwarded: fresh.length + forwarded: fresh.length, + ...debug } }); } catch (_) {} }; const addSubtitleCandidate = (entry) => { - const rawURL = typeof entry === "string" ? entry : entry && (entry.url || entry.href || entry.src || entry.file || entry.download); + const rawURL = typeof entry === "string" + ? entry + : entry && ( + entry.url || + entry.href || + entry.src || + entry.link || + entry.file || + entry.download || + entry.externalUrl || + entry.externalURL || + entry.fileUrl || + entry.fileURL + ); const url = absoluteURL(rawURL); subtitleURLPattern.lastIndex = 0; if (!url || !subtitleURLPattern.test(url)) { @@ -183,6 +199,19 @@ final class DreamioWebViewController: UIViewController { postSubtitleCandidates([candidate]); }; + const postSubtitleInspection = (source, url, beforeCount, afterCount, payloadLength) => { + if (afterCount > beforeCount) { + return; + } + postSubtitleCandidates([], { + source, + inspected: true, + url: url || "", + payloadLength: payloadLength || 0, + totalKnown: subtitleCandidates.length + }); + }; + const inspectSubtitlePayload = (payload) => { if (!payload) { return; @@ -206,6 +235,12 @@ final class DreamioWebViewController: UIViewController { } }; + const inspectSubtitleText = (source, url, text) => { + const beforeCount = subtitleCandidates.length; + inspectSubtitlePayload(text); + postSubtitleInspection(source, url, beforeCount, subtitleCandidates.length, text ? text.length : 0); + }; + const originalFetch = window.fetch; if (originalFetch) { window.fetch = async (...args) => { @@ -216,10 +251,13 @@ final class DreamioWebViewController: UIViewController { subtitleURLPattern.lastIndex = 0; const shouldInspect = !contentType || /json|text|javascript|xml|subtitle|vtt|srt/i.test(contentType) - || subtitleURLPattern.test(url); + || subtitleURLPattern.test(url) + || subtitleSignalPattern.test(url); if (shouldInspect) { subtitleURLPattern.lastIndex = 0; - response.clone().text().then(inspectSubtitlePayload).catch(() => {}); + response.clone().text().then((text) => { + inspectSubtitleText("fetch", url, text); + }).catch(() => {}); } } catch (_) {} return response; @@ -235,7 +273,13 @@ final class DreamioWebViewController: UIViewController { if (responseType && responseType !== "text") { return; } - inspectSubtitlePayload(this.responseText); + const url = this.responseURL || ""; + const text = this.responseText || ""; + if (subtitleSignalPattern.test(url) || subtitleSignalPattern.test(text)) { + inspectSubtitleText("xhr", url, text); + } else { + inspectSubtitlePayload(text); + } } catch (_) {} }); } catch (_) {} @@ -685,8 +729,13 @@ final class DreamioWebViewController: UIViewController { let discovered = debug?["discovered"] as? Int ?? parsedCandidates.count let deduped = debug?["deduped"] as? Int ?? parsedCandidates.count let posted = debug?["forwarded"] as? Int ?? parsedCandidates.count + let source = debug?["source"] as? String ?? "bridge" + let inspected = debug?["inspected"] as? Bool ?? false + let inspectedURL = (debug?["url"] as? String).map(redactedURLString) ?? "none" + let payloadLength = debug?["payloadLength"] as? Int ?? 0 + let totalKnown = debug?["totalKnown"] as? Int ?? parsedCandidates.count let pageURL = dictionary?["pageUrl"] as? String - print("[DreamioSubtitles] bridge discovered=\(discovered) deduped=\(deduped) posted=\(posted) parsed=\(parsedCandidates.count) playerActive=\(currentNativePlayer != nil) page=\(pageURL.map(redactedURLString) ?? "unknown") candidates=\(SubtitleDebugFormatter.candidateSummary(parsedCandidates))") + print("[DreamioSubtitles] bridge source=\(source) inspected=\(inspected) discovered=\(discovered) deduped=\(deduped) posted=\(posted) parsed=\(parsedCandidates.count) totalKnown=\(totalKnown) payloadLength=\(payloadLength) playerActive=\(currentNativePlayer != nil) inspectedURL=\(inspectedURL) page=\(pageURL.map(redactedURLString) ?? "unknown") candidates=\(SubtitleDebugFormatter.candidateSummary(parsedCandidates))") } #endif } diff --git a/docs/turns/2026-05-25-prove-native-subtitle-pipeline.html b/docs/turns/2026-05-25-prove-native-subtitle-pipeline.html index 622d885..34e226e 100644 --- a/docs/turns/2026-05-25-prove-native-subtitle-pipeline.html +++ b/docs/turns/2026-05-25-prove-native-subtitle-pipeline.html @@ -613,6 +613,92 @@

Related Beads issue: dreamio-bd9.

+
+

New Changes as of 2026-05-25 10:32 AM EDT

+

Summary of changes

+

Extended subtitle discovery diagnostics upstream of the native captions menu so subtitle-shaped fetch and XHR responses now log even when they produce zero parseable external candidates.

+

Why this change was made

+

The latest run confirmed the app only receives VLC embedded captions for this stream. The remaining unknown is whether Stremio is making subtitle addon requests that the injected parser misses, or whether no external subtitle payload is requested at all.

+

Code diffs

+

DreamioWebViewController.swift

Dreamio/DreamioWebViewController.swift
-8+57
80 unmodified lines
81
82
83
84
85
86
39 unmodified lines
126
127
128
129
130
131
132
10 unmodified lines
143
144
145
146
147
148
149
6 unmodified lines
156
157
158
159
160
161
162
163
164
165
166
167
168
169
13 unmodified lines
183
184
185
186
187
188
17 unmodified lines
206
207
208
209
210
211
4 unmodified lines
216
217
218
219
220
221
222
223
224
225
9 unmodified lines
235
236
237
238
239
240
241
443 unmodified lines
685
686
687
688
689
690
691
692
80 unmodified lines
const subtitleCandidates = [];
const postedSubtitleURLs = new Set();
const subtitleURLPattern = /https?:\/\/[^\s"'<>]+(?:\.srt|\.vtt|\.ass|\.ssa|\.sub|opensubtitles|subtitle)[^\s"'<>]*/ig;
+
const looksNative = (url) => {
if (!url || typeof url !== "string") {
39 unmodified lines
} catch (_) {}
};
+
const postSubtitleCandidates = (candidates) => {
const discoveredCount = candidates.length;
const fresh = candidates.filter((candidate) => {
if (postedSubtitleURLs.has(candidate.url)) {
10 unmodified lines
debug: {
discovered: discoveredCount,
deduped: 0,
forwarded: 0
}
});
} catch (_) {}
6 unmodified lines
debug: {
discovered: discoveredCount,
deduped: fresh.length,
forwarded: fresh.length
}
});
} catch (_) {}
};
+
const addSubtitleCandidate = (entry) => {
const rawURL = typeof entry === "string" ? entry : entry && (entry.url || entry.href || entry.src || entry.file || entry.download);
const url = absoluteURL(rawURL);
subtitleURLPattern.lastIndex = 0;
if (!url || !subtitleURLPattern.test(url)) {
13 unmodified lines
postSubtitleCandidates([candidate]);
};
+
const inspectSubtitlePayload = (payload) => {
if (!payload) {
return;
17 unmodified lines
}
};
+
const originalFetch = window.fetch;
if (originalFetch) {
window.fetch = async (...args) => {
4 unmodified lines
subtitleURLPattern.lastIndex = 0;
const shouldInspect = !contentType
|| /json|text|javascript|xml|subtitle|vtt|srt/i.test(contentType)
|| subtitleURLPattern.test(url);
if (shouldInspect) {
subtitleURLPattern.lastIndex = 0;
response.clone().text().then(inspectSubtitlePayload).catch(() => {});
}
} catch (_) {}
return response;
9 unmodified lines
if (responseType && responseType !== "text") {
return;
}
inspectSubtitlePayload(this.responseText);
} catch (_) {}
});
} catch (_) {}
443 unmodified lines
let discovered = debug?["discovered"] as? Int ?? parsedCandidates.count
let deduped = debug?["deduped"] as? Int ?? parsedCandidates.count
let posted = debug?["forwarded"] as? Int ?? parsedCandidates.count
let pageURL = dictionary?["pageUrl"] as? String
print("[DreamioSubtitles] bridge discovered=\(discovered) deduped=\(deduped) posted=\(posted) parsed=\(parsedCandidates.count) playerActive=\(currentNativePlayer != nil) page=\(pageURL.map(redactedURLString) ?? "unknown") candidates=\(SubtitleDebugFormatter.candidateSummary(parsedCandidates))")
}
#endif
}
80 unmodified lines
81
82
83
84
85
86
87
39 unmodified lines
127
128
129
130
131
132
133
10 unmodified lines
144
145
146
147
148
149
150
151
6 unmodified lines
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
13 unmodified lines
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
17 unmodified lines
235
236
237
238
239
240
241
242
243
244
245
246
4 unmodified lines
251
252
253
254
255
256
257
258
259
260
261
262
263
9 unmodified lines
273
274
275
276
277
278
279
280
281
282
283
284
285
443 unmodified lines
729
730
731
732
733
734
735
736
737
738
739
740
741
80 unmodified lines
const subtitleCandidates = [];
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") {
39 unmodified lines
} catch (_) {}
};
+
const postSubtitleCandidates = (candidates, debug = {}) => {
const discoveredCount = candidates.length;
const fresh = candidates.filter((candidate) => {
if (postedSubtitleURLs.has(candidate.url)) {
10 unmodified lines
debug: {
discovered: discoveredCount,
deduped: 0,
forwarded: 0,
...debug
}
});
} catch (_) {}
6 unmodified lines
debug: {
discovered: discoveredCount,
deduped: fresh.length,
forwarded: fresh.length,
...debug
}
});
} catch (_) {}
};
+
const addSubtitleCandidate = (entry) => {
const rawURL = typeof entry === "string"
? entry
: entry && (
entry.url ||
entry.href ||
entry.src ||
entry.link ||
entry.file ||
entry.download ||
entry.externalUrl ||
entry.externalURL ||
entry.fileUrl ||
entry.fileURL
);
const url = absoluteURL(rawURL);
subtitleURLPattern.lastIndex = 0;
if (!url || !subtitleURLPattern.test(url)) {
13 unmodified lines
postSubtitleCandidates([candidate]);
};
+
const postSubtitleInspection = (source, url, beforeCount, afterCount, payloadLength) => {
if (afterCount > beforeCount) {
return;
}
postSubtitleCandidates([], {
source,
inspected: true,
url: url || "",
payloadLength: payloadLength || 0,
totalKnown: subtitleCandidates.length
});
};
+
const inspectSubtitlePayload = (payload) => {
if (!payload) {
return;
17 unmodified lines
}
};
+
const inspectSubtitleText = (source, url, text) => {
const beforeCount = subtitleCandidates.length;
inspectSubtitlePayload(text);
postSubtitleInspection(source, url, beforeCount, subtitleCandidates.length, text ? text.length : 0);
};
+
const originalFetch = window.fetch;
if (originalFetch) {
window.fetch = async (...args) => {
4 unmodified lines
subtitleURLPattern.lastIndex = 0;
const shouldInspect = !contentType
|| /json|text|javascript|xml|subtitle|vtt|srt/i.test(contentType)
|| subtitleURLPattern.test(url)
|| subtitleSignalPattern.test(url);
if (shouldInspect) {
subtitleURLPattern.lastIndex = 0;
response.clone().text().then((text) => {
inspectSubtitleText("fetch", url, text);
}).catch(() => {});
}
} catch (_) {}
return response;
9 unmodified lines
if (responseType && responseType !== "text") {
return;
}
const url = this.responseURL || "";
const text = this.responseText || "";
if (subtitleSignalPattern.test(url) || subtitleSignalPattern.test(text)) {
inspectSubtitleText("xhr", url, text);
} else {
inspectSubtitlePayload(text);
}
} catch (_) {}
});
} catch (_) {}
443 unmodified lines
let discovered = debug?["discovered"] as? Int ?? parsedCandidates.count
let deduped = debug?["deduped"] as? Int ?? parsedCandidates.count
let posted = debug?["forwarded"] as? Int ?? parsedCandidates.count
let source = debug?["source"] as? String ?? "bridge"
let inspected = debug?["inspected"] as? Bool ?? false
let inspectedURL = (debug?["url"] as? String).map(redactedURLString) ?? "none"
let payloadLength = debug?["payloadLength"] as? Int ?? 0
let totalKnown = debug?["totalKnown"] as? Int ?? parsedCandidates.count
let pageURL = dictionary?["pageUrl"] as? String
print("[DreamioSubtitles] bridge source=\(source) inspected=\(inspected) discovered=\(discovered) deduped=\(deduped) posted=\(posted) parsed=\(parsedCandidates.count) totalKnown=\(totalKnown) payloadLength=\(payloadLength) playerActive=\(currentNativePlayer != nil) inspectedURL=\(inspectedURL) page=\(pageURL.map(redactedURLString) ?? "unknown") candidates=\(SubtitleDebugFormatter.candidateSummary(parsedCandidates))")
}
#endif
}
+

Related issues or PRs

+

Related Beads issue: dreamio-ese.

+
+