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.

Pipeline Buffered subtitle candidates by active stream key and forwarded them after native player presentation.
Parser Preserved OpenSubtitles labels, languages, nested direct files, and stronger duplicate metadata.
Validation Swift parser/resolver tests and iOS simulator build passed.

## 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 `viewDidLoad` before late additions are resolved.
- Moved `SubtitleResolving` beside the Foundation-based resolver so parser and resolver tests compile without UIKit.
- Updated `SubtitleCandidateParser` so 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

Dreamio/DreamioWebViewController.swift
-8+79

    56 unmodified lines575859606162524 unmodified lines5875885895905915925935945955965975985996006016026032 unmodified lines6066076086096106116126136146156166175 unmodified lines62362462562662762862963063112 unmodified lines64464564664764864965065165265365465565665765865966066166266366466566666766856 unmodified lines    private var progressObservation: NSKeyValueObservation?    private var userAgent: String?    private var lastNativePlaybackURL: URL?    private weak var currentNativePlayer: NativePlayerViewController?    private let streamResolver: StreamResolving = StremioStreamResolver()
    524 unmodified lines
            let duplicateKey = request.resolverURL ?? request.playbackURL        if lastNativePlaybackURL == duplicateKey {            return        }        lastNativePlaybackURL = duplicateKey
    #if DEBUG        let classification = request.classification        print("[DreamioStream] class=\(classification.sourceKind.rawValue) container=\(classification.containerGuess.rawValue) reason=\(classification.reason) subtitles=\(request.subtitleCandidates.count) observed=\(classification.sanitizedObservedURL) resolver=\(classification.sanitizedResolverURL ?? "none")")#endif
            Task { [weak self] in            await self?.resolveAndPresentNativePlayback(request)        }    }
    2 unmodified lines            return        }
    #if DEBUG        print("[DreamioSubtitles] native discovered=\(candidates.count) playerActive=\(currentNativePlayer != nil) candidates=\(SubtitleDebugFormatter.candidateSummary(candidates))")#endif        guard let currentNativePlayer else {#if DEBUG            print("[DreamioSubtitles] discovered=\(candidates.count) forwarded=0 reason=no-active-native-player")#endif            return        }5 unmodified lines    }
        @MainActor    private func resolveAndPresentNativePlayback(_ request: NativePlaybackRequest) async {        guard VLCNativePlaybackBackend.isAvailable else {            lastNativePlaybackURL = nil            showNativePlaybackUnavailableAlert()            return        }12 unmodified lines                referer: request.referer,                headers: resolved.headers,                classification: request.classification,                subtitleCandidates: request.subtitleCandidates            )            let player = NativePlayerViewController(request: resolvedRequest)            currentNativePlayer = player            player.onDismiss = { [weak self] in                self?.lastNativePlaybackURL = nil                self?.currentNativePlayer = nil                self?.cleanUpStremioPlayerAfterNativeDismiss()            }            present(player, animated: true)        } catch {#if DEBUG            print("[DreamioStreamResolver] failure=\(URLRedactor.redactedURLString(error.localizedDescription)) resolver=\(request.resolverURL.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none")")#endif            lastNativePlaybackURL = nil            showNativePlaybackResolutionFailure(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 lines    private 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 lines
            let duplicateKey = request.resolverURL ?? request.playbackURL        if lastNativePlaybackURL == duplicateKey {            mergeSubtitleCandidates(candidate.subtitleCandidates, for: duplicateKey)            return        }        lastNativePlaybackURL = duplicateKey        currentNativePlaybackKey = duplicateKey        mergeSubtitleCandidates(request.subtitleCandidates, for: duplicateKey)        let mergedSubtitleCandidates = subtitleCandidates(for: duplicateKey)
    #if DEBUG        let classification = request.classification        print("[DreamioStream] class=\(classification.sourceKind.rawValue) container=\(classification.containerGuess.rawValue) reason=\(classification.reason) subtitles=\(mergedSubtitleCandidates.count) observed=\(classification.sanitizedObservedURL) resolver=\(classification.sanitizedResolverURL ?? "none")")#endif
            let 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] in            await self?.resolveAndPresentNativePlayback(playbackRequest, streamKey: duplicateKey)        }    }
    2 unmodified lines            return        }
            let streamKey = currentNativePlaybackKey ?? lastNativePlaybackURL        if let streamKey {            mergeSubtitleCandidates(candidates, for: streamKey)        }
    #if DEBUG        print("[DreamioSubtitles] native discovered=\(candidates.count) playerActive=\(currentNativePlayer != nil) streamKey=\(streamKey.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none") candidates=\(SubtitleDebugFormatter.candidateSummary(candidates))")#endif        guard let currentNativePlayer else {#if DEBUG            print("[DreamioSubtitles] discovered=\(candidates.count) forwarded=0 reason=no-active-native-player buffered=\(streamKey != nil)")#endif            return        }5 unmodified lines    }
        @MainActor    private func resolveAndPresentNativePlayback(_ request: NativePlaybackRequest, streamKey: URL) async {        guard VLCNativePlaybackBackend.isAvailable else {            lastNativePlaybackURL = nil            currentNativePlaybackKey = nil            showNativePlaybackUnavailableAlert()            return        }12 unmodified lines                referer: request.referer,                headers: resolved.headers,                classification: request.classification,                subtitleCandidates: subtitleCandidates(for: streamKey)            )            let player = NativePlayerViewController(request: resolvedRequest)            player.onDismiss = { [weak self] in                self?.lastNativePlaybackURL = nil                self?.currentNativePlaybackKey = nil                self?.currentNativePlayer = nil                self?.pendingSubtitleCandidatesByStreamKey.removeValue(forKey: streamKey)                self?.cleanUpStremioPlayerAfterNativeDismiss()            }            present(player, animated: true) { [weak self, weak player] in                guard let self, let player else {                    return                }                self.currentNativePlayer = player                let lateBufferedCandidates = self.subtitleCandidates(for: streamKey)                let forwarded = player.addSubtitleCandidates(lateBufferedCandidates)#if DEBUG                print("[DreamioSubtitles] presented buffered=\(lateBufferedCandidates.count) forwarded=\(forwarded) streamKey=\(URLRedactor.redactedURLString(streamKey.absoluteString))")#endif            }        } catch {#if DEBUG            print("[DreamioStreamResolver] failure=\(URLRedactor.redactedURLString(error.localizedDescription)) resolver=\(request.resolverURL.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none")")#endif            lastNativePlaybackURL = nil            currentNativePlaybackKey = nil            pendingSubtitleCandidatesByStreamKey.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 in            let key = candidate.url.absoluteString            if 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().lastPathComponent        return (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

Dreamio/NativePlaybackBackend.swift
-4

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

### Dreamio/StreamCandidate.swift

Dreamio/StreamCandidate.swift
-16+43

    133 unmodified lines1341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701 unmodified line1721731741751761771782 unmodified lines181182183184185186187188189190191133 unmodified lines    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] = []        collect(from: payload, into: &results)
            var seen = Set<String>()        return results.filter { candidate in            let key = candidate.url.absoluteString            guard !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 in                    results.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 lines        let 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 lines    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"]    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.label            let language = (dictionary["lang"] as? String)                ?? (dictionary["language"] as? String)                ?? self.language            return 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 in            let key = candidate.url.absoluteString            if 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 in                    results.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 lines        let 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 != defaultLabel        return (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

Dreamio/StreamResolver.swift
-1+31

    5 unmodified lines6789101135 unmodified lines47484950515213 unmodified lines6667686970717254 unmodified lines1271281291301311325 unmodified lines    let source: String}
    enum StreamResolverError: LocalizedError {    case noResolverURL    case httpStatus(Int)35 unmodified lines
            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)13 unmodified lines                from: data,                responseURL: response.url,                original: candidate            )        } catch {#if DEBUG            print("[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 lines    let source: String}
    protocol SubtitleResolving {    func resolve(_ candidate: SubtitleCandidate) async -> SubtitleCandidate?}
    enum StreamResolverError: LocalizedError {    case noResolverURL    case httpStatus(Int)35 unmodified lines
            var 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 in            request.setValue(value, forHTTPHeaderField: key)        }
            do {            let (data, response) = try await session.data(for: request)13 unmodified lines                from: data,                responseURL: response.url,                original: candidate            ).map { resolved in#if DEBUG                print("[DreamioSubtitles] resolved candidate from=\(URLRedactor.redactedURLString(candidate.url.absoluteString)) to=\(URLRedactor.redactedURLString(resolved.url.absoluteString))")#endif                return resolved            } ?? Self.logRejected(candidate, responseURL: response.url, data: data)        } catch {#if DEBUG            print("[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 DEBUG        let responseDescription = responseURL.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none"        let bodyKind: String        if 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)")#endif        return nil    }}
    protocol StreamResolving {

### Dreamio/VLCNativePlaybackBackend.swift

Dreamio/VLCNativePlaybackBackend.swift
-8+12

    245 unmodified lines24624724824925025125225325425525625742 unmodified lines300301302303304305306307308309310245 unmodified lines        guard attachedCount > 0 else {            return attachedCount        }        DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in            self?.selectInitialSubtitleTrackIfNeeded(reason: "delayed-refresh")            #if DEBUG            self?.logSubtitleTracks(reason: "delayed-refresh")            #endif            self?.onSubtitleTracksChange?()        }        return attachedCount    }42 unmodified lines        }
            let selectedTrackID = mediaPlayer.currentVideoSubTitleIndex        guard selectedTrackID != trackID || shouldLogNoop else {            return        }
            mediaPlayer.currentVideoSubTitleIndex = trackID#if DEBUG        let action = selectedTrackID == trackID ? "confirm" : "recover"        print("[DreamioVLC] reapply subtitle id=\(trackID) reason=\(reason) action=\(action) selected=\(mediaPlayer.currentVideoSubTitleIndex)")245 unmodified lines24624724824925025125225325425525625725825942 unmodified lines302303304305306307308309310311312313314245 unmodified lines        guard attachedCount > 0 else {            return attachedCount        }        [0.2, 0.6, 1.0, 2.0, 4.0].forEach { delay in            DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in                self?.selectInitialSubtitleTrackIfNeeded(reason: "delayed-refresh-\(String(format: "%.1f", delay))")#if DEBUG                self?.logSubtitleTracks(reason: "delayed-refresh-\(String(format: "%.1f", delay))")#endif                self?.onSubtitleTracksChange?()            }        }        return attachedCount    }42 unmodified lines        }
            let selectedTrackID = mediaPlayer.currentVideoSubTitleIndex        guard selectedTrackID < 0 || (selectedTrackID == trackID && shouldLogNoop) else {            return        }
            if selectedTrackID < 0 {            mediaPlayer.currentVideoSubTitleIndex = trackID        }#if DEBUG        let action = selectedTrackID == trackID ? "confirm" : "recover"        print("[DreamioVLC] reapply subtitle id=\(trackID) reason=\(reason) action=\(action) selected=\(mediaPlayer.currentVideoSubTitleIndex)")

### Tests/StreamResolverTests.swift

Tests/StreamResolverTests.swift
-1+121

    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 lines        testSubtitleCandidateParsing()        testOpenSubtitlesV3CandidateParsing()        testOpenSubtitlesV3DownloadResponseResolution()        testSubtitleCandidateDeduplicationPreservesLabels()        testSubtitleOptionMappingIncludesNone()        print("StreamResolverTests passed")    }125 unmodified lines        assertEqual(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 lines        assertEqual(candidate?.language, "eng")    }
        private static func testSubtitleCandidateDeduplicationPreservesLabels() {        let payload: [String: Any] = [            "subtitles": [18 unmodified lines        assertEqual(candidates[0].language, "eng")    }
        private static func testSubtitleOptionMappingIncludesNone() {        let options = SubtitleOptionMapper.options(from: [            SubtitleTrack(id: 2, name: "English"),7 unmodified lines    private 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 lines        testSubtitleCandidateParsing()        testOpenSubtitlesV3CandidateParsing()        testOpenSubtitlesV3DownloadResponseResolution()        await testSubtitleResolverDownloadJSONReturningLink()        await testSubtitleResolverRedirectToDirectSubtitle()        await testSubtitleResolverRejectsNonSubtitleAPIResponse()        testSubtitleCandidateDeduplicationPreservesLabels()        testSubtitleCandidateDeduplicationUpgradesLabels()        testSubtitleOptionMappingIncludesNone()        print("StreamResolverTests passed")    }125 unmodified lines        assertEqual(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 lines        assertEqual(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 lines        assertEqual(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 lines    private 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.ephemeral        configuration.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.