From d3c55077637a5494baf92925056cb8de0b2c2729 Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Mon, 25 May 2026 11:33:15 -0400 Subject: [PATCH] fix stremio subtitle handoff to vlc --- .beads/interactions.jsonl | 1 + .beads/issues.jsonl | 1 + Dreamio/DreamioWebViewController.swift | 87 ++- Dreamio/NativePlaybackBackend.swift | 4 - Dreamio/StreamCandidate.swift | 67 +- Dreamio/StreamResolver.swift | 32 +- Dreamio/VLCNativePlaybackBackend.swift | 20 +- Tests/StreamResolverTests.swift | 122 +++- ...fix-stremio-external-subtitle-handoff.html | 659 ++++++++++++++++++ 9 files changed, 951 insertions(+), 42 deletions(-) create mode 100644 docs/turns/2026-05-25-fix-stremio-external-subtitle-handoff.html diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 97e968d..c5103ea 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -24,3 +24,4 @@ {"id":"int-96629c65","kind":"field_change","created_at":"2026-05-25T14:45:38.521113Z","actor":"dirtydishes","issue_id":"dreamio-ppj","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Re-applied the auto-selected VLC subtitle track after stream discovery and playback state changes to harden rendering timing."}} {"id":"int-027cec57","kind":"field_change","created_at":"2026-05-25T14:51:44.599319Z","actor":"dirtydishes","issue_id":"dreamio-3xi","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Captured OpenSubtitles V3 subtitle URLs from browser track elements and textTracks so they can be forwarded to native playback."}} {"id":"int-3acaadff","kind":"field_change","created_at":"2026-05-25T15:09:02.023077Z","actor":"dirtydishes","issue_id":"dreamio-h5n","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Limited VLC auto-subtitle reapply to real selection recovery while keeping bounded delayed startup confirmations."}} +{"id":"int-c526b5ae","kind":"field_change","created_at":"2026-05-25T15:32:37.748454Z","actor":"dirtydishes","issue_id":"dreamio-dow","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented stream-keyed subtitle buffering, OpenSubtitles parser/resolver hardening, VLC refresh behavior, and focused validation."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 3726b5a..fbc4139 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,4 +1,5 @@ {"_type":"issue","id":"dreamio-8cz","title":"fix stremio external subtitle loading regression","description":"After adding late subtitle forwarding for native playback, Stremio external subtitle loading is failing. Investigate the injected bridge and native subtitle forwarding path, then adjust behavior so Stremio can still load external subtitles while native playback receives late candidates.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T11:05:42Z","created_by":"dirtydishes","updated_at":"2026-05-25T11:07:35Z","started_at":"2026-05-25T11:05:55Z","closed_at":"2026-05-25T11:07:35Z","close_reason":"Hardened subtitle bridge network observers so non-text Stremio subtitle loads are not touched, and made parser traversal deterministic for metadata preservation.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-dow","title":"fix stremio external subtitle handoff to vlc","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T15:17:16Z","created_by":"dirtydishes","updated_at":"2026-05-25T15:32:38Z","started_at":"2026-05-25T15:17:25Z","closed_at":"2026-05-25T15:32:38Z","close_reason":"Implemented stream-keyed subtitle buffering, OpenSubtitles parser/resolver hardening, VLC refresh behavior, and focused validation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-bao","title":"add native player audio track selection","description":"Add audio track discovery and selection to the native VLC-backed player so multi-language files can be filtered from the player controls.","status":"closed","priority":1,"issue_type":"feature","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:57:14Z","created_by":"dirtydishes","updated_at":"2026-05-25T15:01:36Z","closed_at":"2026-05-25T15:01:36Z","close_reason":"Implemented native audio track discovery and selection with a far-left audio menu in the VLC-backed player.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-3xi","title":"Capture browser text tracks for OpenSubtitles V3","description":"OpenSubtitles V3 subtitles can be attached to the Stremio web player as HTML track/textTrack entries rather than appearing in the initial stream candidate. Extend the web bridge to inspect track elements and textTracks so external subtitles can be forwarded to native playback.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:49:50Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:51:45Z","started_at":"2026-05-25T14:49:52Z","closed_at":"2026-05-25T14:51:45Z","close_reason":"Captured OpenSubtitles V3 subtitle URLs from browser track elements and textTracks so they can be forwarded to native playback.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-ppj","title":"Reapply VLC embedded subtitle selection after track discovery","description":"Device logs show VLC eventually exposes and selects the embedded English SDH subtitle track, but subtitles still do not render. Investigate and harden the VLC selection timing so embedded tracks are selected after discovery is stable.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:44:08Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:45:38Z","started_at":"2026-05-25T14:44:18Z","closed_at":"2026-05-25T14:45:38Z","close_reason":"Re-applied the auto-selected VLC subtitle track after stream discovery and playback state changes to harden rendering timing.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/Dreamio/DreamioWebViewController.swift b/Dreamio/DreamioWebViewController.swift index 53d417f..b0c7ade 100644 --- a/Dreamio/DreamioWebViewController.swift +++ b/Dreamio/DreamioWebViewController.swift @@ -57,6 +57,8 @@ final class DreamioWebViewController: UIViewController { 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() @@ -587,17 +589,33 @@ final class DreamioWebViewController: UIViewController { 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=\(request.subtitleCandidates.count) observed=\(classification.sanitizedObservedURL) resolver=\(classification.sanitizedResolverURL ?? "none")") + 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(request) + await self?.resolveAndPresentNativePlayback(playbackRequest, streamKey: duplicateKey) } } @@ -606,12 +624,17 @@ final class DreamioWebViewController: UIViewController { return } + let streamKey = currentNativePlaybackKey ?? lastNativePlaybackURL + if let streamKey { + mergeSubtitleCandidates(candidates, for: streamKey) + } + #if DEBUG - print("[DreamioSubtitles] native discovered=\(candidates.count) playerActive=\(currentNativePlayer != nil) candidates=\(SubtitleDebugFormatter.candidateSummary(candidates))") + 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") + print("[DreamioSubtitles] discovered=\(candidates.count) forwarded=0 reason=no-active-native-player buffered=\(streamKey != nil)") #endif return } @@ -623,9 +646,10 @@ final class DreamioWebViewController: UIViewController { } @MainActor - private func resolveAndPresentNativePlayback(_ request: NativePlaybackRequest) async { + private func resolveAndPresentNativePlayback(_ request: NativePlaybackRequest, streamKey: URL) async { guard VLCNativePlaybackBackend.isAvailable else { lastNativePlaybackURL = nil + currentNativePlaybackKey = nil showNativePlaybackUnavailableAlert() return } @@ -644,25 +668,72 @@ final class DreamioWebViewController: UIViewController { referer: request.referer, headers: resolved.headers, classification: request.classification, - subtitleCandidates: request.subtitleCandidates + subtitleCandidates: subtitleCandidates(for: streamKey) ) let player = NativePlayerViewController(request: resolvedRequest) - currentNativePlayer = player 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) + 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", diff --git a/Dreamio/NativePlaybackBackend.swift b/Dreamio/NativePlaybackBackend.swift index 55a0c06..0648eb0 100644 --- a/Dreamio/NativePlaybackBackend.swift +++ b/Dreamio/NativePlaybackBackend.swift @@ -30,10 +30,6 @@ protocol NativePlaybackBackend: AnyObject { func stop() } -protocol SubtitleResolving { - func resolve(_ candidate: SubtitleCandidate) async -> SubtitleCandidate? -} - enum NativePlaybackError: LocalizedError { case backendUnavailable case startupTimedOut diff --git a/Dreamio/StreamCandidate.swift b/Dreamio/StreamCandidate.swift index 7b2f209..4ddc3c5 100644 --- a/Dreamio/StreamCandidate.swift +++ b/Dreamio/StreamCandidate.swift @@ -134,37 +134,58 @@ 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"] + private struct CandidateContext { + let label: String? + let language: String? - static func candidates(in payload: Any?) -> [SubtitleCandidate] { - var results: [SubtitleCandidate] = [] - collect(from: payload, into: &results) + 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) + } - var seen = Set() - 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 firstString(in dictionary: [String: Any], fields: [String]) -> String? { + fields.lazy.compactMap { dictionary[$0] as? String }.first { !$0.isEmpty } } } - private static func collect(from value: Any?, into results: inout [SubtitleCandidate]) { + 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]: - if let candidate = candidate(from: dictionary) { + let nextContext = context.merged(with: dictionary) + if let candidate = candidate(from: dictionary, context: nextContext) { results.append(candidate) } - orderedNestedValues(in: dictionary).forEach { collect(from: $0, into: &results) } + orderedNestedValues(in: dictionary).forEach { collect(from: $0, context: nextContext, into: &results) } case let array as [Any]: - array.forEach { collect(from: $0, into: &results) } + 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: defaultLabel(for: url), language: nil)) + 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: defaultLabel(for: url), language: nil)) + results.append(SubtitleCandidate(url: url, label: context.label ?? defaultLabel(for: url), language: context.language)) } } default: @@ -172,7 +193,7 @@ enum SubtitleCandidateParser { } } - private static func candidate(from dictionary: [String: Any]) -> SubtitleCandidate? { + 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 } @@ -181,11 +202,17 @@ enum SubtitleCandidateParser { let language = (dictionary["lang"] as? String) ?? (dictionary["language"] as? String) return SubtitleCandidate( url: url, - label: label?.isEmpty == false ? label! : defaultLabel(for: url), - language: language + 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() diff --git a/Dreamio/StreamResolver.swift b/Dreamio/StreamResolver.swift index c342cfd..6aa7359 100644 --- a/Dreamio/StreamResolver.swift +++ b/Dreamio/StreamResolver.swift @@ -6,6 +6,10 @@ struct ResolvedNativeStream { let source: String } +protocol SubtitleResolving { + func resolve(_ candidate: SubtitleCandidate) async -> SubtitleCandidate? +} + enum StreamResolverError: LocalizedError { case noResolverURL case httpStatus(Int) @@ -47,6 +51,9 @@ final class SubtitleResolver: SubtitleResolving { 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) @@ -66,7 +73,12 @@ final class SubtitleResolver: SubtitleResolving { 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))") @@ -127,6 +139,24 @@ final class SubtitleResolver: SubtitleResolving { || 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 { diff --git a/Dreamio/VLCNativePlaybackBackend.swift b/Dreamio/VLCNativePlaybackBackend.swift index 4b77cb7..2e9009f 100644 --- a/Dreamio/VLCNativePlaybackBackend.swift +++ b/Dreamio/VLCNativePlaybackBackend.swift @@ -246,12 +246,14 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { 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?() + [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 } @@ -300,11 +302,13 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { } let selectedTrackID = mediaPlayer.currentVideoSubTitleIndex - guard selectedTrackID != trackID || shouldLogNoop else { + guard selectedTrackID < 0 || (selectedTrackID == trackID && shouldLogNoop) else { return } - mediaPlayer.currentVideoSubTitleIndex = trackID + 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)") diff --git a/Tests/StreamResolverTests.swift b/Tests/StreamResolverTests.swift index c14ddc8..2bab41b 100644 --- a/Tests/StreamResolverTests.swift +++ b/Tests/StreamResolverTests.swift @@ -2,7 +2,7 @@ import Foundation @main struct StreamResolverTests { - static func main() { + static func main() async { testClassifierPrefersObservedDirectFile() testResolverSelectsUnsupportedDirectURLAndHeaders() testResolverRejectsHLSOnlyResponse() @@ -11,7 +11,11 @@ struct StreamResolverTests { testSubtitleCandidateParsing() testOpenSubtitlesV3CandidateParsing() testOpenSubtitlesV3DownloadResponseResolution() + await testSubtitleResolverDownloadJSONReturningLink() + await testSubtitleResolverRedirectToDirectSubtitle() + await testSubtitleResolverRejectsNonSubtitleAPIResponse() testSubtitleCandidateDeduplicationPreservesLabels() + testSubtitleCandidateDeduplicationUpgradesLabels() testSubtitleOptionMappingIncludesNone() print("StreamResolverTests passed") } @@ -143,6 +147,8 @@ struct StreamResolverTests { 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") @@ -173,6 +179,62 @@ struct StreamResolverTests { 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": [ @@ -197,6 +259,25 @@ struct StreamResolverTests { 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"), @@ -210,4 +291,43 @@ struct StreamResolverTests { private static func assertEqual(_ 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() {} } diff --git a/docs/turns/2026-05-25-fix-stremio-external-subtitle-handoff.html b/docs/turns/2026-05-25-fix-stremio-external-subtitle-handoff.html new file mode 100644 index 0000000..a39b2ab --- /dev/null +++ b/docs/turns/2026-05-25-fix-stremio-external-subtitle-handoff.html @@ -0,0 +1,659 @@ + + + + + + Fix Stremio External Subtitle Handoff To VLC + + + +
+
+

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.

+
+
PipelineBuffered subtitle candidates by active stream key and forwarded them after native player presentation.
+
ParserPreserved OpenSubtitles labels, languages, nested direct files, and stronger duplicate metadata.
+
ValidationSwift 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 lines
57
58
59
60
61
62
524 unmodified lines
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
2 unmodified lines
606
607
608
609
610
611
612
613
614
615
616
617
5 unmodified lines
623
624
625
626
627
628
629
630
631
12 unmodified lines
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
56 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 lines
57
58
59
60
61
62
63
64
524 unmodified lines
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
2 unmodified lines
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
5 unmodified lines
646
647
648
649
650
651
652
653
654
655
12 unmodified lines
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
56 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 lines
30
31
32
33
34
35
36
37
38
39
29 unmodified lines
func stop()
}
+
protocol SubtitleResolving {
func resolve(_ candidate: SubtitleCandidate) async -> SubtitleCandidate?
}
+
enum NativePlaybackError: LocalizedError {
case backendUnavailable
case startupTimedOut
29 unmodified lines
30
31
32
33
34
35
29 unmodified lines
func stop()
}
+
enum NativePlaybackError: LocalizedError {
case backendUnavailable
case startupTimedOut

Dreamio/StreamCandidate.swift

Dreamio/StreamCandidate.swift
-16+43
133 unmodified lines
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
1 unmodified line
172
173
174
175
176
177
178
2 unmodified lines
181
182
183
184
185
186
187
188
189
190
191
133 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 lines
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
1 unmodified line
193
194
195
196
197
198
199
2 unmodified lines
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
133 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 lines
6
7
8
9
10
11
35 unmodified lines
47
48
49
50
51
52
13 unmodified lines
66
67
68
69
70
71
72
54 unmodified lines
127
128
129
130
131
132
5 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 lines
6
7
8
9
10
11
12
13
14
15
35 unmodified lines
51
52
53
54
55
56
57
58
59
13 unmodified lines
73
74
75
76
77
78
79
80
81
82
83
84
54 unmodified lines
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
5 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 lines
246
247
248
249
250
251
252
253
254
255
256
257
42 unmodified lines
300
301
302
303
304
305
306
307
308
309
310
245 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 lines
246
247
248
249
250
251
252
253
254
255
256
257
258
259
42 unmodified lines
302
303
304
305
306
307
308
309
310
311
312
313
314
245 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 line
2
3
4
5
6
7
8
2 unmodified lines
11
12
13
14
15
16
17
125 unmodified lines
143
144
145
146
147
148
24 unmodified lines
173
174
175
176
177
178
18 unmodified lines
197
198
199
200
201
202
7 unmodified lines
210
211
212
213
1 unmodified line
+
@main
struct 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 line
2
3
4
5
6
7
8
2 unmodified lines
11
12
13
14
15
16
17
18
19
20
21
125 unmodified lines
147
148
149
150
151
152
153
154
24 unmodified lines
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
18 unmodified lines
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
7 unmodified lines
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
1 unmodified line
+
@main
struct 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.
  • +
+
+
+ +