Turn document · May 25, 2026

# Resolve OpenSubtitles subtitle downloads

Dreamio now resolves OpenSubtitles V3 API-style subtitle download responses into direct subtitle files before handing them to VLC, reducing the chance that Stremio reports “failed to load subtitles” after native playback opens.

Issue: dreamio-h5q Scope: native subtitles Validation: tests and simulator build

## Summary

Fixed the OpenSubtitles V3 subtitle handoff path by adding a resolver between Stremio subtitle discovery and VLC subtitle attachment. Direct subtitle files still attach immediately, while OpenSubtitles API/download URLs are fetched and inspected for playable `.srt`, `.vtt`, `.ass`, `.ssa`, or `.sub` links.

Before VLC received API-like OpenSubtitles URLs directly.
After Dreamio resolves those endpoints to file URLs first.
Result Subtitle tracks are more likely to attach as usable VLC subtitles.

## Changes Made

- Added a `SubtitleResolving` protocol and a concrete `SubtitleResolver`.
- Moved initial subtitle attachment out of the VLC backend and through `NativePlayerViewController.addSubtitleCandidates`, so initial and late subtitle discoveries use one path.
- Added support for OpenSubtitles response fields like `link` and `file_name`.
- Added a regression test for resolving OpenSubtitles V3 download JSON into a direct subtitle file URL.

## Context

The previous OpenSubtitles pass forwarded late subtitle discoveries to the active native player, but it still assumed every discovered URL was directly consumable by VLC. OpenSubtitles V3 commonly returns API/download endpoints that respond with JSON containing the real subtitle file link. Passing those API URLs straight to VLC can surface as Stremio’s “failed to load subtitles” message.

## Important Implementation Details

- `SubtitleResolver.resolve` returns direct subtitle file candidates unchanged.
- OpenSubtitles-like URLs are fetched with an `Accept` header that allows JSON, text, and common subtitle formats.
- JSON and text responses are parsed through the existing subtitle candidate parser, then filtered down to direct subtitle file URLs.
- The captions menu label preserves the original Stremio/OpenSubtitles label when a download response includes a raw filename.
- Duplicate tracking now records the originally discovered URL while resolution is pending, then records the resolved playable URL before VLC attachment.

## Relevant Diff Snippets

### Dreamio/NativePlaybackBackend.swift

Dreamio/NativePlaybackBackend.swift
+4

    29 unmodified lines30313233343529 unmodified lines    func stop()}
    enum NativePlaybackError: LocalizedError {    case backendUnavailable    case startupTimedOut29 unmodified lines3031323334353637383929 unmodified lines    func stop()}
    protocol SubtitleResolving {    func resolve(_ candidate: SubtitleCandidate) async -> SubtitleCandidate?}
    enum NativePlaybackError: LocalizedError {    case backendUnavailable    case startupTimedOut

### Dreamio/NativePlayerViewController.swift

Dreamio/NativePlayerViewController.swift
-15+57

    2 unmodified lines34567883 unmodified lines929394959697989910010124 unmodified lines1261271281291301311321331341351361371381391401411421431441451461471481491501515 unmodified lines1571581591601611622 unmodified linesfinal class NativePlayerViewController: UIViewController {    private let request: NativePlaybackRequest    private var backend: NativePlaybackBackend    private var startupTimer: Timer?    private var controlsTimer: Timer?    private var progressTimer: Timer?83 unmodified lines        return label    }()
        init(request: NativePlaybackRequest, backend: NativePlaybackBackend = VLCNativePlaybackBackend()) {        self.request = request        self.backend = backend        self.attachedSubtitleURLs = Set(request.subtitleCandidates.map(\.url))        super.init(nibName: nil, bundle: nil)        modalPresentationStyle = .fullScreen        modalTransitionStyle = .crossDissolve24 unmodified lines        configureLayout()        startStartupTimer()        backend.play(request: request)    }
        @discardableResult    func addSubtitleCandidates(_ candidates: [SubtitleCandidate]) -> Int {        let newCandidates = candidates.filter { candidate in            guard !attachedSubtitleURLs.contains(candidate.url) else {                return false            }            attachedSubtitleURLs.insert(candidate.url)            return true        }        let attachedCount = backend.addSubtitleCandidates(newCandidates)        if attachedCount > 0 {            refreshControls()        }#if DEBUG        let duplicateCount = candidates.count - newCandidates.count        print("[DreamioNativePlayer] subtitle candidates=\(candidates.count) forwarded=\(newCandidates.count) attached=\(attachedCount) duplicates=\(duplicateCount)")#endif        return attachedCount    }
        override func viewDidDisappear(_ animated: Bool) {5 unmodified lines        onDismiss?()    }
        private func configureBackend() {        backend.prepare(in: self)        backend.view.translatesAutoresizingMaskIntoConstraints = false2 unmodified lines345678983 unmodified lines9394959697989910010110210310410510610724 unmodified lines1321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821835 unmodified lines1891901911921931941951961971981992002012022032042 unmodified linesfinal class NativePlayerViewController: UIViewController {    private let request: NativePlaybackRequest    private var backend: NativePlaybackBackend    private let subtitleResolver: SubtitleResolving    private var startupTimer: Timer?    private var controlsTimer: Timer?    private var progressTimer: Timer?83 unmodified lines        return label    }()
        init(        request: NativePlaybackRequest,        backend: NativePlaybackBackend = VLCNativePlaybackBackend(),        subtitleResolver: SubtitleResolving = SubtitleResolver()    ) {        self.request = request        self.backend = backend        self.subtitleResolver = subtitleResolver        self.attachedSubtitleURLs = []        super.init(nibName: nil, bundle: nil)        modalPresentationStyle = .fullScreen        modalTransitionStyle = .crossDissolve24 unmodified lines        configureLayout()        startStartupTimer()        backend.play(request: request)        addSubtitleCandidates(request.subtitleCandidates)    }
        @discardableResult    func addSubtitleCandidates(_ candidates: [SubtitleCandidate]) -> Int {        let pendingCandidates = candidates.filter { !attachedSubtitleURLs.contains($0.url) }        guard !pendingCandidates.isEmpty else {#if DEBUG            print("[DreamioNativePlayer] subtitle candidates=\(candidates.count) pending=0 duplicates=\(candidates.count)")#endif            return 0        }
            pendingCandidates.forEach { attachedSubtitleURLs.insert($0.url) }
            Task { [weak self] in            guard let self else {                return            }            let resolvedCandidates = await self.resolveSubtitleCandidates(pendingCandidates)            await MainActor.run {                guard !resolvedCandidates.isEmpty else {#if DEBUG                    print("[DreamioNativePlayer] subtitle candidates=\(candidates.count) pending=\(pendingCandidates.count) resolved=0 attached=0")#endif                    return                }                let attachableCandidates = resolvedCandidates.filter { candidate in                    guard !self.attachedSubtitleURLs.contains(candidate.url) || pendingCandidates.contains(where: { $0.url == candidate.url }) else {                        return false                    }                    self.attachedSubtitleURLs.insert(candidate.url)                    return true                }                let attachedCount = self.backend.addSubtitleCandidates(attachableCandidates)                if attachedCount > 0 {                    self.refreshControls()                }#if DEBUG                let duplicateCount = candidates.count - pendingCandidates.count + resolvedCandidates.count - attachableCandidates.count                print("[DreamioNativePlayer] subtitle candidates=\(candidates.count) pending=\(pendingCandidates.count) resolved=\(resolvedCandidates.count) attached=\(attachedCount) duplicates=\(duplicateCount)")#endif            }        }
            return pendingCandidates.count    }
        override func viewDidDisappear(_ animated: Bool) {5 unmodified lines        onDismiss?()    }
        private func resolveSubtitleCandidates(_ candidates: [SubtitleCandidate]) async -> [SubtitleCandidate] {        var resolved: [SubtitleCandidate] = []        for candidate in candidates {            if let playableCandidate = await subtitleResolver.resolve(candidate) {                resolved.append(playableCandidate)            }        }        return resolved    }
        private func configureBackend() {        backend.prepare(in: self)        backend.view.translatesAutoresizingMaskIntoConstraints = false

### Dreamio/StreamCandidate.swift

Dreamio/StreamCandidate.swift
-2+2

    104 unmodified lines105106107108109110111112104 unmodified lines
    enum SubtitleCandidateParser {    private static let supportedExtensions = ["srt", "vtt", "ass", "ssa", "sub"]    private static let urlFields = ["url", "href", "src", "subtitles", "subtitle", "subtitleUrl", "subtitleURL", "file", "download"]    private static let labelFields = ["label", "name", "title", "lang", "language", "id"]
        static func candidates(in payload: Any?) -> [SubtitleCandidate] {        var results: [SubtitleCandidate] = []104 unmodified lines105106107108109110111112104 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"]
        static func candidates(in payload: Any?) -> [SubtitleCandidate] {        var results: [SubtitleCandidate] = []

### Dreamio/StreamResolver.swift

Dreamio/StreamResolver.swift
+100

    28 unmodified lines29303132333428 unmodified lines    }}
    protocol StreamResolving {    func resolve(request: NativePlaybackRequest) async throws -> ResolvedNativeStream}28 unmodified lines293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313428 unmodified lines    }}
    final class SubtitleResolver: SubtitleResolving {    private let session: URLSession
        init(session: URLSession = .shared) {        self.session = session    }
        func resolve(_ candidate: SubtitleCandidate) async -> SubtitleCandidate? {        if Self.isDirectSubtitleFile(candidate.url) {            return candidate        }
            guard Self.shouldResolve(candidate.url) else {            return nil        }
            var request = URLRequest(url: candidate.url)        request.setValue("application/json, text/plain, text/vtt, application/x-subrip, */*", forHTTPHeaderField: "Accept")
            do {            let (data, response) = try await session.data(for: request)            if let httpResponse = response as? HTTPURLResponse,               !(200...299).contains(httpResponse.statusCode) {#if DEBUG                print("[DreamioSubtitles] resolve status=\(httpResponse.statusCode) url=\(URLRedactor.redactedURLString(candidate.url.absoluteString))")#endif                return nil            }
                if let finalURL = response.url, Self.isDirectSubtitleFile(finalURL) {                return SubtitleCandidate(url: finalURL, label: candidate.label, language: candidate.language)            }
                return Self.bestPlayableCandidate(                from: data,                responseURL: response.url,                original: candidate            )        } catch {#if DEBUG            print("[DreamioSubtitles] resolve failure=\(URLRedactor.redactedURLString(error.localizedDescription)) url=\(URLRedactor.redactedURLString(candidate.url.absoluteString))")#endif            return nil        }    }
        static func bestPlayableCandidate(        from data: Data,        responseURL: URL?,        original: SubtitleCandidate    ) -> SubtitleCandidate? {        if let responseURL, isDirectSubtitleFile(responseURL) {            return SubtitleCandidate(url: responseURL, label: original.label, language: original.language)        }
            guard !data.isEmpty else {            return nil        }
            if let payload = try? JSONSerialization.jsonObject(with: data) {            return SubtitleCandidateParser.candidates(in: payload)                .first(where: { isDirectSubtitleFile($0.url) })                .map { playable in                    SubtitleCandidate(                        url: playable.url,                        label: original.label.isEmpty ? playable.label : original.label,                        language: playable.language ?? original.language                    )                }        }
            if let text = String(data: data, encoding: .utf8) {            return SubtitleCandidateParser.candidates(in: text)                .first(where: { isDirectSubtitleFile($0.url) })                .map { playable in                    SubtitleCandidate(                        url: playable.url,                        label: original.label.isEmpty ? playable.label : original.label,                        language: playable.language ?? original.language                    )                }        }
            return nil    }
        static func isDirectSubtitleFile(_ url: URL) -> Bool {        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 {        let lowercased = url.absoluteString.lowercased()        return lowercased.contains("opensubtitles")            || lowercased.contains("/subtitle")            || lowercased.contains("subtitle")    }}
    protocol StreamResolving {    func resolve(request: NativePlaybackRequest) async throws -> ResolvedNativeStream}

### Dreamio/VLCNativePlaybackBackend.swift

Dreamio/VLCNativePlaybackBackend.swift
-1

    57 unmodified lines5859606162636457 unmodified lines        print("[DreamioVLC] opening url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))")#endif        mediaPlayer.play()        addSubtitleCandidates(request.subtitleCandidates)#else        onFailure?(NativePlaybackError.backendUnavailable)#endif57 unmodified lines58596061626357 unmodified lines        print("[DreamioVLC] opening url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))")#endif        mediaPlayer.play()#else        onFailure?(NativePlaybackError.backendUnavailable)#endif

### Tests/StreamResolverTests.swift

Tests/StreamResolverTests.swift
+26

    9 unmodified lines101112131415131 unmodified lines1471481491501511529 unmodified lines        testPlaybackTimeFormatting()        testSubtitleCandidateParsing()        testOpenSubtitlesV3CandidateParsing()        testSubtitleCandidateDeduplicationPreservesLabels()        testSubtitleOptionMappingIncludesNone()        print("StreamResolverTests passed")131 unmodified lines        assertEqual(candidates[3].url.absoluteString, "https://cdn.example.test/from-string.ass?source=opensubtitles")    }
        private static func testSubtitleCandidateDeduplicationPreservesLabels() {        let payload: [String: Any] = [            "subtitles": [9 unmodified lines10111213141516131 unmodified lines1481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771789 unmodified lines        testPlaybackTimeFormatting()        testSubtitleCandidateParsing()        testOpenSubtitlesV3CandidateParsing()        testOpenSubtitlesV3DownloadResponseResolution()        testSubtitleCandidateDeduplicationPreservesLabels()        testSubtitleOptionMappingIncludesNone()        print("StreamResolverTests passed")131 unmodified lines        assertEqual(candidates[3].url.absoluteString, "https://cdn.example.test/from-string.ass?source=opensubtitles")    }
        private static func testOpenSubtitlesV3DownloadResponseResolution() {        let payload = """        {          "link": "https://dl.opensubtitles.org/en/download/subtitle.srt?token=secret",          "file_name": "episode.srt",          "requests": 1        }        """.data(using: .utf8)!        let original = SubtitleCandidate(            url: URL(string: "https://api.opensubtitles.com/api/v1/download")!,            label: "English",            language: "eng"        )
            let candidate = SubtitleResolver.bestPlayableCandidate(            from: payload,            responseURL: original.url,            original: original        )
            assertEqual(candidate?.url.absoluteString, "https://dl.opensubtitles.org/en/download/subtitle.srt?token=secret")        assertEqual(candidate?.label, "English")        assertEqual(candidate?.language, "eng")    }
        private static func testSubtitleCandidateDeduplicationPreservesLabels() {        let payload: [String: Any] = [            "subtitles": [

## Expected Impact for End-Users

When a Stremio stream opens in Dreamio’s native player, OpenSubtitles V3 captions should appear more reliably because Dreamio now gives VLC the final subtitle file URL rather than an intermediate API endpoint. The visible label should stay friendly, such as “English,” instead of degrading to a filename.

## Validation

- Ran `swiftc Dreamio/StreamCandidate.swift [temporary SubtitleResolving shim] Dreamio/StreamResolver.swift Tests/StreamResolverTests.swift -o /tmp/StreamResolverTests && /tmp/StreamResolverTests`: passed.
- Ran `xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -destination 'generic/platform=iOS Simulator' build CODE_SIGNING_ALLOWED=NO`: passed.
- The first standalone test attempt failed because `NativePlaybackBackend.swift` imports `UIKit` outside the iOS SDK compile context; the follow-up test used a temporary protocol shim for the pure resolver/parser code.

## Issues, Limitations, and Mitigations

- Live OpenSubtitles credentials and a real Stremio playback session were not available in this environment, so live subtitle rendering still needs a device check.
- If a provider returns raw subtitle text directly from an API URL without a final file URL, this change does not yet write a temporary subtitle file. The resolver is structured so that fallback can be added cleanly.
- Network failures while resolving subtitles are logged in debug builds and skipped, so playback should continue even if a subtitle endpoint fails.

## 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 lines11511611711811912079 unmodified lines200201202203204205206207208209210211212213214114 unmodified lines            }          };
              const findResolverURL = () => {            const links = Array.from(document.querySelectorAll("a[href], [data-href], [data-url]"));            const match = links79 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 lines11511611711811912012112212312412512612712812913013113213313413513613713813914079 unmodified lines220221222223224225226227228229230231114 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 = links79 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 lines2452462472482492505 unmodified lines256257258259260261244 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 lines2452462472482492502512522535 unmodified lines259260261262263264265266267268269270271272244 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 lines111213141516172 unmodified lines18919019119219319410 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 lines11121314151617172 unmodified lines19019119219319419519619719819920020120220320420520620720820921021121221321421521621721810 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

- Manually test a real OpenSubtitles V3 title on device and confirm selected captions render in VLC.
- Add a temporary-file fallback if live testing shows OpenSubtitles sometimes returns subtitle text directly instead of a file link.
- Consider surfacing subtitle resolution failures in a debug-only diagnostics panel.