Dreamio turn document

Accept Stremio subtitle download URLs

External subtitles from Stremio can arrive as subs*.strem.io/en/download/... URLs with no subtitle file extension. Dreamio now recognizes that shape in the injected bridge, Swift parser, and native subtitle resolver so those tracks can reach VLC instead of disappearing before playback.

Date: 2026-05-25 Issue: dreamio-9sp Scope: subtitles, native playback

Summary

The pasted logs showed Stremio reporting a failed external subtitle track at https://subs5.strem.io/en/download/subencoding-stremio-utf8/src-api/file/1952341941, while Dreamio logged zero parsed subtitle candidates. The fix adds Stremio subtitle download URL recognition so those URLs are accepted as real external subtitle candidates.

Changes Made

Context

Before this change, Dreamio only accepted direct subtitle file extensions or OpenSubtitles-looking download endpoints. Stremio’s web player can expose external subtitles through a host like subs5.strem.io, where the path identifies a subtitle download but the URL does not end in .srt, .vtt, or similar.

That mismatch explains the log pattern: bridge inspection saw likely payload objects, but Swift parsed zero usable candidates and the native player only saw embedded VLC subtitle tracks.

Important Implementation Details

Relevant Diff Snippets

Rendered with @pierre/diffs/ssr using one patch per changed file.

Dreamio/DreamioWebViewController.swift

Dreamio/DreamioWebViewController.swift
-1+14
168 unmodified lines
169
170
171
172
173
174
175
176
177
178
179
180
168 unmodified lines
}
};
const isSubtitleURL = (url) => {
if (!url || isOpenSubtitlesManifestID(url)) {
return false;
}
return !isProbablyNonSubtitleAssetURL(url)
&& (isDirectSubtitleFileURL(url) || isOpenSubtitlesDownloadURL(url));
};
const findResolverURL = () => {
168 unmodified lines
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
168 unmodified lines
}
};
const isStremioSubtitleDownloadURL = (url) => {
try {
const parsed = new URL(url, window.location.href);
const host = parsed.hostname.toLowerCase();
const path = parsed.pathname.toLowerCase();
return host === "strem.io" || host.endsWith(".strem.io")
? /\/[a-z]{2,3}\/download(?:\/|$)/i.test(path) || /\/download(?:\/|$)/i.test(path)
: false;
} catch (_) {
return false;
}
};
const isSubtitleURL = (url) => {
if (!url || isOpenSubtitlesManifestID(url)) {
return false;
}
return !isProbablyNonSubtitleAssetURL(url)
&& (isDirectSubtitleFileURL(url) || isOpenSubtitlesDownloadURL(url) || isStremioSubtitleDownloadURL(url));
};
const findResolverURL = () => {

Dreamio/StreamCandidate.swift

Dreamio/StreamCandidate.swift
+13
255 unmodified lines
256
257
258
259
260
261
20 unmodified lines
282
283
284
285
286
287
255 unmodified lines
}
guard isDirectSubtitleFile(url)
|| isOpenSubtitlesDownloadURL(url)
else {
return nil
}
20 unmodified lines
|| path.range(of: #"(^|/)subtitles?(/|$)"#, options: .regularExpression) != nil
}
private static func isOpenSubtitlesManifestIdentifier(_ url: URL) -> Bool {
guard url.host?.localizedCaseInsensitiveContains("opensubtitles") == true else {
return false
255 unmodified lines
256
257
258
259
260
261
262
20 unmodified lines
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
255 unmodified lines
}
guard isDirectSubtitleFile(url)
|| isOpenSubtitlesDownloadURL(url)
|| isStremioSubtitleDownloadURL(url)
else {
return nil
}
20 unmodified lines
|| path.range(of: #"(^|/)subtitles?(/|$)"#, options: .regularExpression) != nil
}
private static func isStremioSubtitleDownloadURL(_ url: URL) -> Bool {
guard let host = url.host?.lowercased(),
host == "strem.io" || host.hasSuffix(".strem.io")
else {
return false
}
let path = url.path.lowercased()
return path.range(of: #"^/[a-z]{2,3}/download(/|$)"#, options: .regularExpression) != nil
|| path.range(of: #"(^|/)download(/|$)"#, options: .regularExpression) != nil
}
private static func isOpenSubtitlesManifestIdentifier(_ url: URL) -> Bool {
guard url.host?.localizedCaseInsensitiveContains("opensubtitles") == true else {
return false

Dreamio/StreamResolver.swift

Dreamio/StreamResolver.swift
+14
130 unmodified lines
131
132
133
134
135
136
1 unmodified line
138
139
140
141
142
143
130 unmodified lines
let lowercased = url.absoluteString.lowercased()
return ["srt", "vtt", "ass", "ssa", "sub"].contains(url.pathExtension.lowercased())
|| [".srt?", ".vtt?", ".ass?", ".ssa?", ".sub?", ".srt&", ".vtt&", ".ass&", ".ssa&", ".sub&"].contains(where: lowercased.contains)
}
private static func shouldResolve(_ url: URL) -> Bool {
1 unmodified line
return lowercased.contains("opensubtitles")
|| lowercased.contains("/subtitle")
|| lowercased.contains("subtitle")
}
private static func logRejected(_ candidate: SubtitleCandidate, responseURL: URL?, data: Data) -> SubtitleCandidate? {
130 unmodified lines
131
132
133
134
135
136
137
1 unmodified line
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
130 unmodified lines
let lowercased = url.absoluteString.lowercased()
return ["srt", "vtt", "ass", "ssa", "sub"].contains(url.pathExtension.lowercased())
|| [".srt?", ".vtt?", ".ass?", ".ssa?", ".sub?", ".srt&", ".vtt&", ".ass&", ".ssa&", ".sub&"].contains(where: lowercased.contains)
|| isStremioSubtitleDownloadURL(url)
}
private static func shouldResolve(_ url: URL) -> Bool {
1 unmodified line
return lowercased.contains("opensubtitles")
|| lowercased.contains("/subtitle")
|| lowercased.contains("subtitle")
|| isStremioSubtitleDownloadURL(url)
}
private static func isStremioSubtitleDownloadURL(_ url: URL) -> Bool {
guard let host = url.host?.lowercased(),
host == "strem.io" || host.hasSuffix(".strem.io")
else {
return false
}
let path = url.path.lowercased()
return path.range(of: #"^/[a-z]{2,3}/download(/|$)"#, options: .regularExpression) != nil
|| path.range(of: #"(^|/)download(/|$)"#, options: .regularExpression) != nil
}
private static func logRejected(_ candidate: SubtitleCandidate, responseURL: URL?, data: Data) -> SubtitleCandidate? {

Tests/StreamResolverTests.swift

Tests/StreamResolverTests.swift
+25
12 unmodified lines
13
14
15
16
17
18
221 unmodified lines
240
241
242
243
244
245
12 unmodified lines
testOpenSubtitlesNestedAttributesFilesParsing()
testOpenSubtitlesManifestIDsAreNotResolvedAsSubtitles()
testOpenSubtitlesArtworkAndAddonEndpointsAreIgnored()
testOpenSubtitlesV3DownloadResponseResolution()
testOpenSubtitlesNestedDownloadResponseResolution()
await testSubtitleResolverDownloadJSONReturningLink()
221 unmodified lines
assertEqual(candidates[0].label, "English")
}
private static func testOpenSubtitlesV3DownloadResponseResolution() {
let payload = """
{
12 unmodified lines
13
14
15
16
17
18
19
221 unmodified lines
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
12 unmodified lines
testOpenSubtitlesNestedAttributesFilesParsing()
testOpenSubtitlesManifestIDsAreNotResolvedAsSubtitles()
testOpenSubtitlesArtworkAndAddonEndpointsAreIgnored()
testStremioSubtitleDownloadURLParsing()
testOpenSubtitlesV3DownloadResponseResolution()
testOpenSubtitlesNestedDownloadResponseResolution()
await testSubtitleResolverDownloadJSONReturningLink()
221 unmodified lines
assertEqual(candidates[0].label, "English")
}
private static func testStremioSubtitleDownloadURLParsing() {
let payload: [String: Any] = [
"subtitles": [
[
"label": "English",
"lang": "eng",
"url": "https://subs5.strem.io/en/download/subencoding-stremio-utf8/src-api/file/1952341941"
],
[
"label": "Not a subtitle",
"url": "https://www.strem.io/images/addons/opensubtitles-logo.png"
]
]
]
let candidates = SubtitleCandidateParser.candidates(in: payload)
assertEqual(candidates.count, 1)
assertEqual(candidates[0].url.absoluteString, "https://subs5.strem.io/en/download/subencoding-stremio-utf8/src-api/file/1952341941")
assertEqual(candidates[0].label, "English")
assertEqual(candidates[0].language, "eng")
assert(SubtitleResolver.isDirectSubtitleFile(candidates[0].url), "Expected Stremio subtitle downloads to be attachable without another resolver hop")
}
private static func testOpenSubtitlesV3DownloadResponseResolution() {
let payload = """
{

Expected Impact for End-Users

When Stremio exposes external subtitles through subs*.strem.io download URLs, Dreamio should now carry those subtitles into the native VLC player instead of showing only embedded subtitle tracks in the UI.

Validation

Issues, Limitations, and Mitigations

Follow-up Work

No new follow-up issue was filed. The next useful check is runtime validation on the same episode: look for parsed=1, nonzero native subtitle candidates, and a [DreamioVLC] attach accepted subtitle=... line for the Stremio subtitle download URL.