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

- Updated the injected WebKit subtitle bridge to treat `strem.io` and `*.strem.io` download paths as subtitle URLs.
- Updated `SubtitleCandidateParser` to parse the same Stremio download URL shape from Stremio payloads.
- Updated `SubtitleResolver` so Stremio subtitle download URLs are considered directly attachable instead of requiring a second resolver response.
- Added a focused regression test for the exact `subs5.strem.io/en/download/...` form from the runtime log.

## 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

- The accepted Stremio pattern is intentionally narrow: hosts must be `strem.io` or end in `.strem.io`, and paths must include `/download`, with language-prefixed forms such as `/en/download/...` supported.
- Image and addon endpoints such as `www.strem.io/images/addons/opensubtitles-logo.png` are still rejected by the non-subtitle extension filter.
- Marking these URLs as direct subtitle files lets VLC receive the URL directly through `addPlaybackSlave`, which matches the way Stremio labels the track URL.

## Relevant Diff Snippets

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

### Dreamio/DreamioWebViewController.swift

Dreamio/DreamioWebViewController.swift
-1+14

    168 unmodified lines169170171172173174175176177178179180168 unmodified lines            }          };
              const isSubtitleURL = (url) => {            if (!url || isOpenSubtitlesManifestID(url)) {              return false;            }            return !isProbablyNonSubtitleAssetURL(url)              && (isDirectSubtitleFileURL(url) || isOpenSubtitlesDownloadURL(url));          };
              const findResolverURL = () => {168 unmodified lines169170171172173174175176177178179180181182183184185186187188189190191192193168 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 lines25625725825926026120 unmodified lines282283284285286287255 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 false255 unmodified lines25625725825926026126220 unmodified lines283284285286287288289290291292293294295296297298299300255 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 lines1311321331341351361 unmodified line138139140141142143130 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 lines1311321331341351361371 unmodified line139140141142143144145146147148149150151152153154155156157130 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 lines131415161718221 unmodified lines24024124224324424512 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 lines13141516171819221 unmodified lines24124224324424524624724824925025125225325425525625725825926026126226326426526626726826927012 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

- Passed: `swiftc Dreamio/StreamCandidate.swift Dreamio/StreamResolver.swift Tests/StreamResolverTests.swift -o /tmp/StreamResolverTests && /tmp/StreamResolverTests`
- Passed: `DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -configuration Debug -sdk iphonesimulator build`

## Issues, Limitations, and Mitigations

- This was validated against the URL shape visible in the logs, not by replaying the exact remote Stremio session inside the app.
- If Stremio introduces a different subtitle CDN path that does not include `/download`, another narrow allow-list entry may be needed.
- The existing debug logging should now show parsed candidates for this URL form, which makes the next runtime check straightforward.

## 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.