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.
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.
Changes Made
- Added a
SubtitleResolvingprotocol and a concreteSubtitleResolver. - 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
linkandfile_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.resolvereturns direct subtitle file candidates unchanged.- OpenSubtitles-like URLs are fetched with an
Acceptheader 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
29 unmodified lines30313233343529 unmodified linesfunc stop()}enum NativePlaybackError: LocalizedError {case backendUnavailablecase startupTimedOut29 unmodified lines3031323334353637383929 unmodified linesfunc stop()}protocol SubtitleResolving {func resolve(_ candidate: SubtitleCandidate) async -> SubtitleCandidate?}enum NativePlaybackError: LocalizedError {case backendUnavailablecase startupTimedOut
Dreamio/NativePlayerViewController.swift
2 unmodified lines34567883 unmodified lines929394959697989910010124 unmodified lines1261271281291301311321331341351361371381391401411421431441451461471481491501515 unmodified lines1571581591601611622 unmodified linesfinal class NativePlayerViewController: UIViewController {private let request: NativePlaybackRequestprivate var backend: NativePlaybackBackendprivate var startupTimer: Timer?private var controlsTimer: Timer?private var progressTimer: Timer?83 unmodified linesreturn label}()init(request: NativePlaybackRequest, backend: NativePlaybackBackend = VLCNativePlaybackBackend()) {self.request = requestself.backend = backendself.attachedSubtitleURLs = Set(request.subtitleCandidates.map(\.url))super.init(nibName: nil, bundle: nil)modalPresentationStyle = .fullScreenmodalTransitionStyle = .crossDissolve24 unmodified linesconfigureLayout()startStartupTimer()backend.play(request: request)}@discardableResultfunc addSubtitleCandidates(_ candidates: [SubtitleCandidate]) -> Int {let newCandidates = candidates.filter { candidate inguard !attachedSubtitleURLs.contains(candidate.url) else {return false}attachedSubtitleURLs.insert(candidate.url)return true}let attachedCount = backend.addSubtitleCandidates(newCandidates)if attachedCount > 0 {refreshControls()}#if DEBUGlet duplicateCount = candidates.count - newCandidates.countprint("[DreamioNativePlayer] subtitle candidates=\(candidates.count) forwarded=\(newCandidates.count) attached=\(attachedCount) duplicates=\(duplicateCount)")#endifreturn attachedCount}override func viewDidDisappear(_ animated: Bool) {5 unmodified linesonDismiss?()}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: NativePlaybackRequestprivate var backend: NativePlaybackBackendprivate let subtitleResolver: SubtitleResolvingprivate var startupTimer: Timer?private var controlsTimer: Timer?private var progressTimer: Timer?83 unmodified linesreturn label}()init(request: NativePlaybackRequest,backend: NativePlaybackBackend = VLCNativePlaybackBackend(),subtitleResolver: SubtitleResolving = SubtitleResolver()) {self.request = requestself.backend = backendself.subtitleResolver = subtitleResolverself.attachedSubtitleURLs = []super.init(nibName: nil, bundle: nil)modalPresentationStyle = .fullScreenmodalTransitionStyle = .crossDissolve24 unmodified linesconfigureLayout()startStartupTimer()backend.play(request: request)addSubtitleCandidates(request.subtitleCandidates)}@discardableResultfunc addSubtitleCandidates(_ candidates: [SubtitleCandidate]) -> Int {let pendingCandidates = candidates.filter { !attachedSubtitleURLs.contains($0.url) }guard !pendingCandidates.isEmpty else {#if DEBUGprint("[DreamioNativePlayer] subtitle candidates=\(candidates.count) pending=0 duplicates=\(candidates.count)")#endifreturn 0}pendingCandidates.forEach { attachedSubtitleURLs.insert($0.url) }Task { [weak self] inguard let self else {return}let resolvedCandidates = await self.resolveSubtitleCandidates(pendingCandidates)await MainActor.run {guard !resolvedCandidates.isEmpty else {#if DEBUGprint("[DreamioNativePlayer] subtitle candidates=\(candidates.count) pending=\(pendingCandidates.count) resolved=0 attached=0")#endifreturn}let attachableCandidates = resolvedCandidates.filter { candidate inguard !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 DEBUGlet duplicateCount = candidates.count - pendingCandidates.count + resolvedCandidates.count - attachableCandidates.countprint("[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 linesonDismiss?()}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
104 unmodified lines105106107108109110111112104 unmodified linesenum 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 linesenum 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
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: URLSessioninit(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 DEBUGprint("[DreamioSubtitles] resolve status=\(httpResponse.statusCode) url=\(URLRedactor.redactedURLString(candidate.url.absoluteString))")#endifreturn 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 DEBUGprint("[DreamioSubtitles] resolve failure=\(URLRedactor.redactedURLString(error.localizedDescription)) url=\(URLRedactor.redactedURLString(candidate.url.absoluteString))")#endifreturn 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 inSubtitleCandidate(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 inSubtitleCandidate(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
57 unmodified lines5859606162636457 unmodified linesprint("[DreamioVLC] opening url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))")#endifmediaPlayer.play()addSubtitleCandidates(request.subtitleCandidates)#elseonFailure?(NativePlaybackError.backendUnavailable)#endif57 unmodified lines58596061626357 unmodified linesprint("[DreamioVLC] opening url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))")#endifmediaPlayer.play()#elseonFailure?(NativePlaybackError.backendUnavailable)#endif
Tests/StreamResolverTests.swift
9 unmodified lines101112131415131 unmodified lines1471481491501511529 unmodified linestestPlaybackTimeFormatting()testSubtitleCandidateParsing()testOpenSubtitlesV3CandidateParsing()testSubtitleCandidateDeduplicationPreservesLabels()testSubtitleOptionMappingIncludesNone()print("StreamResolverTests passed")131 unmodified linesassertEqual(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 linestestPlaybackTimeFormatting()testSubtitleCandidateParsing()testOpenSubtitlesV3CandidateParsing()testOpenSubtitlesV3DownloadResponseResolution()testSubtitleCandidateDeduplicationPreservesLabels()testSubtitleOptionMappingIncludesNone()print("StreamResolverTests passed")131 unmodified linesassertEqual(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.swiftimportsUIKitoutside 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.
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.