Dreamio turn document · May 25, 2026 · Beads issue dreamio-dow
Fix Stremio External Subtitle Handoff To VLC
Made external subtitles part of the native playback handoff instead of a side channel that could disappear before MobileVLCKit was ready.
Summary
Dreamio now keeps Stremio and OpenSubtitlesV3 subtitle discoveries tied to the active native playback stream. Candidates found before presentation, inside the stream candidate message, and during playback are merged, deduped, resolved when needed, and attached to VLC as external subtitle slaves.
Changes Made
- Added a stream-keyed subtitle candidate buffer in
DreamioWebViewController. - Merged subtitle candidates from the stream message with candidates found before and after native player presentation.
- Forwarded buffered candidates after presentation completion so the native player has run
viewDidLoadbefore late additions are resolved. - Moved
SubtitleResolvingbeside the Foundation-based resolver so parser and resolver tests compile without UIKit. - Updated
SubtitleCandidateParserso nested file URLs inherit parent label and language metadata. - Changed subtitle deduplication to keep the best metadata for each resolved URL instead of always keeping the first observation.
- Added default Stremio-style headers to subtitle resolution requests and clearer debug logging for rejected API payloads.
- Scheduled multiple VLC subtitle track refreshes after external subtitle attachment and limited auto-reapply to VLC resetting back to “None”.
- Expanded the StreamResolver test harness with OpenSubtitlesV3 parser and resolver coverage.
Context
Stremio can surface external subtitles from several places: the native stream candidate, OpenSubtitlesV3 API responses, nested file records, and late web requests while the player is opening. Before this change, discoveries that arrived before currentNativePlayer existed were logged but dropped. That meant VLC could successfully open the video while Dreamio’s captions menu never received the corresponding external subtitle track.
Important Implementation Details
- The active stream key is the resolver URL when available, otherwise the observed playback URL. This matches the duplicate native playback guard.
- The pending buffer is cleared on native player dismissal or resolver failure so subtitles from one stream do not leak into the next stream.
- The parser still accepts broad OpenSubtitles and subtitle-looking URLs, but direct playback attachment remains gated by
SubtitleResolver.isDirectSubtitleFile. - VLC auto-selection still happens only when the user has not manually selected a subtitle track. After a manual selection, the backend leaves VLC’s selected track alone.
- Auto-reapply now recovers the saved track only when VLC falls back to a negative “None” track, avoiding accidental overrides of another real track.
Relevant Diff Snippets
Dreamio/DreamioWebViewController.swift
56 unmodified lines575859606162524 unmodified lines5875885895905915925935945955965975985996006016026032 unmodified lines6066076086096106116126136146156166175 unmodified lines62362462562662762862963063112 unmodified lines64464564664764864965065165265365465565665765865966066166266366466566666766856 unmodified linesprivate var progressObservation: NSKeyValueObservation?private var userAgent: String?private var lastNativePlaybackURL: URL?private weak var currentNativePlayer: NativePlayerViewController?private let streamResolver: StreamResolving = StremioStreamResolver()524 unmodified lineslet duplicateKey = request.resolverURL ?? request.playbackURLif lastNativePlaybackURL == duplicateKey {return}lastNativePlaybackURL = duplicateKey#if DEBUGlet classification = request.classificationprint("[DreamioStream] class=\(classification.sourceKind.rawValue) container=\(classification.containerGuess.rawValue) reason=\(classification.reason) subtitles=\(request.subtitleCandidates.count) observed=\(classification.sanitizedObservedURL) resolver=\(classification.sanitizedResolverURL ?? "none")")#endifTask { [weak self] inawait self?.resolveAndPresentNativePlayback(request)}}2 unmodified linesreturn}#if DEBUGprint("[DreamioSubtitles] native discovered=\(candidates.count) playerActive=\(currentNativePlayer != nil) candidates=\(SubtitleDebugFormatter.candidateSummary(candidates))")#endifguard let currentNativePlayer else {#if DEBUGprint("[DreamioSubtitles] discovered=\(candidates.count) forwarded=0 reason=no-active-native-player")#endifreturn}5 unmodified lines}@MainActorprivate func resolveAndPresentNativePlayback(_ request: NativePlaybackRequest) async {guard VLCNativePlaybackBackend.isAvailable else {lastNativePlaybackURL = nilshowNativePlaybackUnavailableAlert()return}12 unmodified linesreferer: request.referer,headers: resolved.headers,classification: request.classification,subtitleCandidates: request.subtitleCandidates)let player = NativePlayerViewController(request: resolvedRequest)currentNativePlayer = playerplayer.onDismiss = { [weak self] inself?.lastNativePlaybackURL = nilself?.currentNativePlayer = nilself?.cleanUpStremioPlayerAfterNativeDismiss()}present(player, animated: true)} catch {#if DEBUGprint("[DreamioStreamResolver] failure=\(URLRedactor.redactedURLString(error.localizedDescription)) resolver=\(request.resolverURL.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none")")#endiflastNativePlaybackURL = nilshowNativePlaybackResolutionFailure(error)}}private func showNativePlaybackResolutionFailure(_ error: Error) {let alert = UIAlertController(title: "Could not open stream",56 unmodified lines5758596061626364524 unmodified lines5895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206212 unmodified lines6246256266276286296306316326336346356366376386396405 unmodified lines64664764864965065165265365465512 unmodified lines66866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873956 unmodified linesprivate var progressObservation: NSKeyValueObservation?private var userAgent: String?private var lastNativePlaybackURL: URL?private var pendingSubtitleCandidatesByStreamKey: [URL: [SubtitleCandidate]] = [:]private var currentNativePlaybackKey: URL?private weak var currentNativePlayer: NativePlayerViewController?private let streamResolver: StreamResolving = StremioStreamResolver()524 unmodified lineslet duplicateKey = request.resolverURL ?? request.playbackURLif lastNativePlaybackURL == duplicateKey {mergeSubtitleCandidates(candidate.subtitleCandidates, for: duplicateKey)return}lastNativePlaybackURL = duplicateKeycurrentNativePlaybackKey = duplicateKeymergeSubtitleCandidates(request.subtitleCandidates, for: duplicateKey)let mergedSubtitleCandidates = subtitleCandidates(for: duplicateKey)#if DEBUGlet classification = request.classificationprint("[DreamioStream] class=\(classification.sourceKind.rawValue) container=\(classification.containerGuess.rawValue) reason=\(classification.reason) subtitles=\(mergedSubtitleCandidates.count) observed=\(classification.sanitizedObservedURL) resolver=\(classification.sanitizedResolverURL ?? "none")")#endiflet playbackRequest = NativePlaybackRequest(playbackURL: request.playbackURL,observedURL: request.observedURL,resolverURL: request.resolverURL,pageURL: request.pageURL,userAgent: request.userAgent,referer: request.referer,headers: request.headers,classification: request.classification,subtitleCandidates: mergedSubtitleCandidates)Task { [weak self] inawait self?.resolveAndPresentNativePlayback(playbackRequest, streamKey: duplicateKey)}}2 unmodified linesreturn}let streamKey = currentNativePlaybackKey ?? lastNativePlaybackURLif let streamKey {mergeSubtitleCandidates(candidates, for: streamKey)}#if DEBUGprint("[DreamioSubtitles] native discovered=\(candidates.count) playerActive=\(currentNativePlayer != nil) streamKey=\(streamKey.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none") candidates=\(SubtitleDebugFormatter.candidateSummary(candidates))")#endifguard let currentNativePlayer else {#if DEBUGprint("[DreamioSubtitles] discovered=\(candidates.count) forwarded=0 reason=no-active-native-player buffered=\(streamKey != nil)")#endifreturn}5 unmodified lines}@MainActorprivate func resolveAndPresentNativePlayback(_ request: NativePlaybackRequest, streamKey: URL) async {guard VLCNativePlaybackBackend.isAvailable else {lastNativePlaybackURL = nilcurrentNativePlaybackKey = nilshowNativePlaybackUnavailableAlert()return}12 unmodified linesreferer: request.referer,headers: resolved.headers,classification: request.classification,subtitleCandidates: subtitleCandidates(for: streamKey))let player = NativePlayerViewController(request: resolvedRequest)player.onDismiss = { [weak self] inself?.lastNativePlaybackURL = nilself?.currentNativePlaybackKey = nilself?.currentNativePlayer = nilself?.pendingSubtitleCandidatesByStreamKey.removeValue(forKey: streamKey)self?.cleanUpStremioPlayerAfterNativeDismiss()}present(player, animated: true) { [weak self, weak player] inguard let self, let player else {return}self.currentNativePlayer = playerlet lateBufferedCandidates = self.subtitleCandidates(for: streamKey)let forwarded = player.addSubtitleCandidates(lateBufferedCandidates)#if DEBUGprint("[DreamioSubtitles] presented buffered=\(lateBufferedCandidates.count) forwarded=\(forwarded) streamKey=\(URLRedactor.redactedURLString(streamKey.absoluteString))")#endif}} catch {#if DEBUGprint("[DreamioStreamResolver] failure=\(URLRedactor.redactedURLString(error.localizedDescription)) resolver=\(request.resolverURL.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none")")#endiflastNativePlaybackURL = nilcurrentNativePlaybackKey = nilpendingSubtitleCandidatesByStreamKey.removeValue(forKey: streamKey)showNativePlaybackResolutionFailure(error)}}private func mergeSubtitleCandidates(_ candidates: [SubtitleCandidate], for streamKey: URL) {guard !candidates.isEmpty else {return}let existing = pendingSubtitleCandidatesByStreamKey[streamKey] ?? []pendingSubtitleCandidatesByStreamKey[streamKey] = Self.mergedSubtitleCandidates(existing + candidates)}private func subtitleCandidates(for streamKey: URL) -> [SubtitleCandidate] {pendingSubtitleCandidatesByStreamKey[streamKey] ?? []}private static func mergedSubtitleCandidates(_ candidates: [SubtitleCandidate]) -> [SubtitleCandidate] {var orderedKeys: [String] = []var bestByURL: [String: SubtitleCandidate] = [:]candidates.forEach { candidate inlet key = candidate.url.absoluteStringif bestByURL[key] == nil {orderedKeys.append(key)bestByURL[key] = candidate} else if let current = bestByURL[key],subtitleCandidateScore(candidate) > subtitleCandidateScore(current) {bestByURL[key] = candidate}}return orderedKeys.compactMap { bestByURL[$0] }}private static func subtitleCandidateScore(_ candidate: SubtitleCandidate) -> Int {let hasUsefulLabel = !candidate.label.isEmpty && candidate.label != candidate.url.deletingPathExtension().lastPathComponentreturn (hasUsefulLabel ? 2 : 0) + ((candidate.language?.isEmpty == false) ? 1 : 0)}private func showNativePlaybackResolutionFailure(_ error: Error) {let alert = UIAlertController(title: "Could not open stream",
Dreamio/NativePlaybackBackend.swift
29 unmodified lines3031323334353637383929 unmodified linesfunc stop()}protocol SubtitleResolving {func resolve(_ candidate: SubtitleCandidate) async -> SubtitleCandidate?}enum NativePlaybackError: LocalizedError {case backendUnavailablecase startupTimedOut29 unmodified lines30313233343529 unmodified linesfunc stop()}enum NativePlaybackError: LocalizedError {case backendUnavailablecase startupTimedOut
Dreamio/StreamCandidate.swift
133 unmodified lines1341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701 unmodified line1721731741751761771782 unmodified lines181182183184185186187188189190191133 unmodified linesprivate 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] = []collect(from: payload, into: &results)var seen = Set<String>()return results.filter { candidate inlet key = candidate.url.absoluteStringguard !seen.contains(key) else {return false}seen.insert(key)return true}}private static func collect(from value: Any?, into results: inout [SubtitleCandidate]) {switch value {case let dictionary as [String: Any]:if let candidate = candidate(from: dictionary) {results.append(candidate)}orderedNestedValues(in: dictionary).forEach { collect(from: $0, into: &results) }case let array as [Any]:array.forEach { collect(from: $0, into: &results) }case let string as String:if let url = subtitleURL(from: string) {results.append(SubtitleCandidate(url: url, label: defaultLabel(for: url), language: nil))} else {extractSubtitleURLs(from: string).forEach { url inresults.append(SubtitleCandidate(url: url, label: defaultLabel(for: url), language: nil))}}default:1 unmodified line}}private static func candidate(from dictionary: [String: Any]) -> SubtitleCandidate? {guard let url = urlFields.lazy.compactMap({ subtitleURL(from: dictionary[$0] as? String) }).first else {return nil}2 unmodified lineslet language = (dictionary["lang"] as? String) ?? (dictionary["language"] as? String)return SubtitleCandidate(url: url,label: label?.isEmpty == false ? label! : defaultLabel(for: url),language: language)}private static func orderedNestedValues(in dictionary: [String: Any]) -> [Any] {let preferredKeys = ["subtitles", "subtitle", "files", "downloads", "download"]var visitedKeys = Set<String>()133 unmodified lines1341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911 unmodified line1931941951961971981992 unmodified lines202203204205206207208209210211212213214215216217218133 unmodified linesprivate 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"]private struct CandidateContext {let label: String?let language: String?func merged(with dictionary: [String: Any]) -> CandidateContext {let label = Self.firstString(in: dictionary, fields: labelFields) ?? self.labellet language = (dictionary["lang"] as? String)?? (dictionary["language"] as? String)?? self.languagereturn CandidateContext(label: label, language: language)}private static func firstString(in dictionary: [String: Any], fields: [String]) -> String? {fields.lazy.compactMap { dictionary[$0] as? String }.first { !$0.isEmpty }}}static func candidates(in payload: Any?) -> [SubtitleCandidate] {var results: [SubtitleCandidate] = []collect(from: payload, context: CandidateContext(label: nil, language: nil), into: &results)var orderedKeys: [String] = []var bestByURL: [String: SubtitleCandidate] = [:]results.forEach { candidate inlet key = candidate.url.absoluteStringif bestByURL[key] == nil {orderedKeys.append(key)bestByURL[key] = candidate} else if let current = bestByURL[key],candidateScore(candidate) > candidateScore(current) {bestByURL[key] = candidate}}return orderedKeys.compactMap { bestByURL[$0] }}private static func collect(from value: Any?, context: CandidateContext, into results: inout [SubtitleCandidate]) {switch value {case let dictionary as [String: Any]:let nextContext = context.merged(with: dictionary)if let candidate = candidate(from: dictionary, context: nextContext) {results.append(candidate)}orderedNestedValues(in: dictionary).forEach { collect(from: $0, context: nextContext, into: &results) }case let array as [Any]:array.forEach { collect(from: $0, context: context, into: &results) }case let string as String:if let url = subtitleURL(from: string) {results.append(SubtitleCandidate(url: url, label: context.label ?? defaultLabel(for: url), language: context.language))} else {extractSubtitleURLs(from: string).forEach { url inresults.append(SubtitleCandidate(url: url, label: context.label ?? defaultLabel(for: url), language: context.language))}}default:1 unmodified line}}private static func candidate(from dictionary: [String: Any], context: CandidateContext) -> SubtitleCandidate? {guard let url = urlFields.lazy.compactMap({ subtitleURL(from: dictionary[$0] as? String) }).first else {return nil}2 unmodified lineslet language = (dictionary["lang"] as? String) ?? (dictionary["language"] as? String)return SubtitleCandidate(url: url,label: label?.isEmpty == false ? label! : (context.label ?? defaultLabel(for: url)),language: language ?? context.language)}private static func candidateScore(_ candidate: SubtitleCandidate) -> Int {let defaultLabel = defaultLabel(for: candidate.url)let hasUsefulLabel = !candidate.label.isEmpty && candidate.label != defaultLabelreturn (hasUsefulLabel ? 2 : 0) + ((candidate.language?.isEmpty == false) ? 1 : 0)}private static func orderedNestedValues(in dictionary: [String: Any]) -> [Any] {let preferredKeys = ["subtitles", "subtitle", "files", "downloads", "download"]var visitedKeys = Set<String>()
Dreamio/StreamResolver.swift
5 unmodified lines6789101135 unmodified lines47484950515213 unmodified lines6667686970717254 unmodified lines1271281291301311325 unmodified lineslet source: String}enum StreamResolverError: LocalizedError {case noResolverURLcase httpStatus(Int)35 unmodified linesvar 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)13 unmodified linesfrom: data,responseURL: response.url,original: candidate)} catch {#if DEBUGprint("[DreamioSubtitles] resolve failure=\(URLRedactor.redactedURLString(error.localizedDescription)) url=\(URLRedactor.redactedURLString(candidate.url.absoluteString))")54 unmodified lines|| lowercased.contains("/subtitle")|| lowercased.contains("subtitle")}}protocol StreamResolving {5 unmodified lines678910111213141535 unmodified lines51525354555657585913 unmodified lines73747576777879808182838454 unmodified lines1391401411421431441451461471481491501511521531541551561571581591601611625 unmodified lineslet source: String}protocol SubtitleResolving {func resolve(_ candidate: SubtitleCandidate) async -> SubtitleCandidate?}enum StreamResolverError: LocalizedError {case noResolverURLcase httpStatus(Int)35 unmodified linesvar request = URLRequest(url: candidate.url)request.setValue("application/json, text/plain, text/vtt, application/x-subrip, */*", forHTTPHeaderField: "Accept")StreamClassifier.defaultHeaders(userAgent: nil).forEach { key, value inrequest.setValue(value, forHTTPHeaderField: key)}do {let (data, response) = try await session.data(for: request)13 unmodified linesfrom: data,responseURL: response.url,original: candidate).map { resolved in#if DEBUGprint("[DreamioSubtitles] resolved candidate from=\(URLRedactor.redactedURLString(candidate.url.absoluteString)) to=\(URLRedactor.redactedURLString(resolved.url.absoluteString))")#endifreturn resolved} ?? Self.logRejected(candidate, responseURL: response.url, data: data)} catch {#if DEBUGprint("[DreamioSubtitles] resolve failure=\(URLRedactor.redactedURLString(error.localizedDescription)) url=\(URLRedactor.redactedURLString(candidate.url.absoluteString))")54 unmodified lines|| lowercased.contains("/subtitle")|| lowercased.contains("subtitle")}private static func logRejected(_ candidate: SubtitleCandidate, responseURL: URL?, data: Data) -> SubtitleCandidate? {#if DEBUGlet responseDescription = responseURL.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none"let bodyKind: Stringif data.isEmpty {bodyKind = "empty"} else if (try? JSONSerialization.jsonObject(with: data)) != nil {bodyKind = "json-without-direct-subtitle"} else if String(data: data, encoding: .utf8) != nil {bodyKind = "text-without-direct-subtitle"} else {bodyKind = "unreadable"}print("[DreamioSubtitles] rejected candidate reason=\(bodyKind) url=\(URLRedactor.redactedURLString(candidate.url.absoluteString)) responseURL=\(responseDescription)")#endifreturn nil}}protocol StreamResolving {
Dreamio/VLCNativePlaybackBackend.swift
245 unmodified lines24624724824925025125225325425525625742 unmodified lines300301302303304305306307308309310245 unmodified linesguard attachedCount > 0 else {return attachedCount}DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] inself?.selectInitialSubtitleTrackIfNeeded(reason: "delayed-refresh")#if DEBUGself?.logSubtitleTracks(reason: "delayed-refresh")#endifself?.onSubtitleTracksChange?()}return attachedCount}42 unmodified lines}let selectedTrackID = mediaPlayer.currentVideoSubTitleIndexguard selectedTrackID != trackID || shouldLogNoop else {return}mediaPlayer.currentVideoSubTitleIndex = trackID#if DEBUGlet action = selectedTrackID == trackID ? "confirm" : "recover"print("[DreamioVLC] reapply subtitle id=\(trackID) reason=\(reason) action=\(action) selected=\(mediaPlayer.currentVideoSubTitleIndex)")245 unmodified lines24624724824925025125225325425525625725825942 unmodified lines302303304305306307308309310311312313314245 unmodified linesguard attachedCount > 0 else {return attachedCount}[0.2, 0.6, 1.0, 2.0, 4.0].forEach { delay inDispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] inself?.selectInitialSubtitleTrackIfNeeded(reason: "delayed-refresh-\(String(format: "%.1f", delay))")#if DEBUGself?.logSubtitleTracks(reason: "delayed-refresh-\(String(format: "%.1f", delay))")#endifself?.onSubtitleTracksChange?()}}return attachedCount}42 unmodified lines}let selectedTrackID = mediaPlayer.currentVideoSubTitleIndexguard selectedTrackID < 0 || (selectedTrackID == trackID && shouldLogNoop) else {return}if selectedTrackID < 0 {mediaPlayer.currentVideoSubTitleIndex = trackID}#if DEBUGlet action = selectedTrackID == trackID ? "confirm" : "recover"print("[DreamioVLC] reapply subtitle id=\(trackID) reason=\(reason) action=\(action) selected=\(mediaPlayer.currentVideoSubTitleIndex)")
Tests/StreamResolverTests.swift
1 unmodified line23456782 unmodified lines11121314151617125 unmodified lines14314414514614714824 unmodified lines17317417517617717818 unmodified lines1971981992002012027 unmodified lines2102112122131 unmodified line@mainstruct StreamResolverTests {static func main() {testClassifierPrefersObservedDirectFile()testResolverSelectsUnsupportedDirectURLAndHeaders()testResolverRejectsHLSOnlyResponse()2 unmodified linestestSubtitleCandidateParsing()testOpenSubtitlesV3CandidateParsing()testOpenSubtitlesV3DownloadResponseResolution()testSubtitleCandidateDeduplicationPreservesLabels()testSubtitleOptionMappingIncludesNone()print("StreamResolverTests passed")}125 unmodified linesassertEqual(candidates[0].label, "English")assertEqual(candidates[0].language, "English")assertEqual(candidates[1].url.absoluteString, "https://dl.opensubtitles.org/en/subtitle.vtt?download=1")assertEqual(candidates[2].label, "spa")assertEqual(candidates[2].language, "spa")assertEqual(candidates[3].url.absoluteString, "https://cdn.example.test/from-string.ass?source=opensubtitles")24 unmodified linesassertEqual(candidate?.language, "eng")}private static func testSubtitleCandidateDeduplicationPreservesLabels() {let payload: [String: Any] = ["subtitles": [18 unmodified linesassertEqual(candidates[0].language, "eng")}private static func testSubtitleOptionMappingIncludesNone() {let options = SubtitleOptionMapper.options(from: [SubtitleTrack(id: 2, name: "English"),7 unmodified linesprivate static func assertEqual<T: Equatable>(_ actual: T?, _ expected: T, file: StaticString = #file, line: UInt = #line) {assert(actual == expected, "Expected \(String(describing: expected)), got \(String(describing: actual))", file: file, line: line)}}1 unmodified line23456782 unmodified lines1112131415161718192021125 unmodified lines14714814915015115215315424 unmodified lines17918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924018 unmodified lines2592602612622632642652662672682692702712722732742752762772782792802812822837 unmodified lines2912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323331 unmodified line@mainstruct StreamResolverTests {static func main() async {testClassifierPrefersObservedDirectFile()testResolverSelectsUnsupportedDirectURLAndHeaders()testResolverRejectsHLSOnlyResponse()2 unmodified linestestSubtitleCandidateParsing()testOpenSubtitlesV3CandidateParsing()testOpenSubtitlesV3DownloadResponseResolution()await testSubtitleResolverDownloadJSONReturningLink()await testSubtitleResolverRedirectToDirectSubtitle()await testSubtitleResolverRejectsNonSubtitleAPIResponse()testSubtitleCandidateDeduplicationPreservesLabels()testSubtitleCandidateDeduplicationUpgradesLabels()testSubtitleOptionMappingIncludesNone()print("StreamResolverTests passed")}125 unmodified linesassertEqual(candidates[0].label, "English")assertEqual(candidates[0].language, "English")assertEqual(candidates[1].url.absoluteString, "https://dl.opensubtitles.org/en/subtitle.vtt?download=1")assertEqual(candidates[1].label, "English")assertEqual(candidates[1].language, "English")assertEqual(candidates[2].label, "spa")assertEqual(candidates[2].language, "spa")assertEqual(candidates[3].url.absoluteString, "https://cdn.example.test/from-string.ass?source=opensubtitles")24 unmodified linesassertEqual(candidate?.language, "eng")}private static func testSubtitleResolverDownloadJSONReturningLink() async {MockURLProtocol.handlers = ["https://api.opensubtitles.com/api/v1/download/123": (200,URL(string: "https://api.opensubtitles.com/api/v1/download/123")!,#"{"link":"https://dl.opensubtitles.org/en/download/movie.srt?token=secret"}"#.data(using: .utf8)!)]let resolver = SubtitleResolver(session: mockSession())let candidate = await resolver.resolve(SubtitleCandidate(url: URL(string: "https://api.opensubtitles.com/api/v1/download/123")!,label: "English",language: "eng"))assertEqual(candidate?.url.absoluteString, "https://dl.opensubtitles.org/en/download/movie.srt?token=secret")assertEqual(candidate?.label, "English")assertEqual(candidate?.language, "eng")}private static func testSubtitleResolverRedirectToDirectSubtitle() async {MockURLProtocol.handlers = ["https://api.opensubtitles.com/api/v1/download/redirect": (200,URL(string: "https://dl.opensubtitles.org/en/redirected.vtt?download=1")!,Data())]let resolver = SubtitleResolver(session: mockSession())let candidate = await resolver.resolve(SubtitleCandidate(url: URL(string: "https://api.opensubtitles.com/api/v1/download/redirect")!,label: "English",language: "eng"))assertEqual(candidate?.url.absoluteString, "https://dl.opensubtitles.org/en/redirected.vtt?download=1")}private static func testSubtitleResolverRejectsNonSubtitleAPIResponse() async {MockURLProtocol.handlers = ["https://api.opensubtitles.com/api/v1/download/not-found": (200,URL(string: "https://api.opensubtitles.com/api/v1/download/not-found")!,#"{"message":"not found"}"#.data(using: .utf8)!)]let resolver = SubtitleResolver(session: mockSession())let candidate = await resolver.resolve(SubtitleCandidate(url: URL(string: "https://api.opensubtitles.com/api/v1/download/not-found")!,label: "English",language: "eng"))assert(candidate == nil, "Expected non-subtitle API response to be rejected")}private static func testSubtitleCandidateDeduplicationPreservesLabels() {let payload: [String: Any] = ["subtitles": [18 unmodified linesassertEqual(candidates[0].language, "eng")}private static func testSubtitleCandidateDeduplicationUpgradesLabels() {let payload: [String: Any] = ["subtitles": ["https://opensubtitles.example.test/download/duplicate.srt",["label": "English SDH","lang": "eng","url": "https://opensubtitles.example.test/download/duplicate.srt"]]]let candidates = SubtitleCandidateParser.candidates(in: payload)assertEqual(candidates.count, 1)assertEqual(candidates[0].label, "English SDH")assertEqual(candidates[0].language, "eng")}private static func testSubtitleOptionMappingIncludesNone() {let options = SubtitleOptionMapper.options(from: [SubtitleTrack(id: 2, name: "English"),7 unmodified linesprivate static func assertEqual<T: Equatable>(_ actual: T?, _ expected: T, file: StaticString = #file, line: UInt = #line) {assert(actual == expected, "Expected \(String(describing: expected)), got \(String(describing: actual))", file: file, line: line)}private static func mockSession() -> URLSession {let configuration = URLSessionConfiguration.ephemeralconfiguration.protocolClasses = [MockURLProtocol.self]return URLSession(configuration: configuration)}}private final class MockURLProtocol: URLProtocol {static var handlers: [String: (status: Int, url: URL, data: Data)] = [:]override class func canInit(with request: URLRequest) -> Bool {true}override class func canonicalRequest(for request: URLRequest) -> URLRequest {request}override func startLoading() {guard let url = request.url,let handler = Self.handlers[url.absoluteString],let response = HTTPURLResponse(url: handler.url,statusCode: handler.status,httpVersion: "HTTP/1.1",headerFields: nil)else {client?.urlProtocol(self, didFailWithError: URLError(.badURL))return}client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)client?.urlProtocol(self, didLoad: handler.data)client?.urlProtocolDidFinishLoading(self)}override func stopLoading() {}}
Expected Impact for End-Users
Users starting native playback from a Stremio stream with OpenSubtitlesV3 external subtitles should see the captions button become available once VLC exposes the subtitle track. Selecting “None” should turn captions off, selecting the external track should turn them back on, and opening a later stream should not inherit subtitle candidates from the previous playback session.
Validation
- Passed:
swiftc Dreamio/StreamCandidate.swift Dreamio/StreamResolver.swift Tests/StreamResolverTests.swift -o /tmp/dreamio-stream-tests && /tmp/dreamio-stream-tests. - Passed:
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -configuration Debug -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build. - Not run manually: the full Stremio/OpenSubtitles/VLC device scenario still needs a real playback stream to confirm the exact runtime logs and captions menu behavior end to end.
Issues, Limitations, and Mitigations
MobileVLCKit exposes external subtitle tracks asynchronously, so the backend now schedules several refreshes after each attachment. This mitigates the common timing gap but does not replace real-device validation against the exact OpenSubtitles stream flow.
The Xcode build still reports the existing CocoaPods script warning that the MobileVLCKit prepare phase has no declared outputs. The build succeeds, and this change does not alter that script phase.
Follow-up Work
- Run the manual validation scenario against a known OpenSubtitlesV3 stream on a device or simulator with working playback.
- Consider a small injectable captions-menu or backend state test seam if future work needs direct unit coverage for UIKit menu refresh behavior.
- Watch debug logs for API payloads rejected as
json-without-direct-subtitle; those may reveal another OpenSubtitles response shape worth supporting.