mirror of
https://github.com/dirtydishes/dreamio.git
synced 2026-06-06 13:37:24 +00:00
fix stremio subtitle handoff to vlc
This commit is contained in:
parent
c59b318d9b
commit
d3c5507763
9 changed files with 951 additions and 42 deletions
|
|
@ -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."}}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -30,10 +30,6 @@ protocol NativePlaybackBackend: AnyObject {
|
|||
func stop()
|
||||
}
|
||||
|
||||
protocol SubtitleResolving {
|
||||
func resolve(_ candidate: SubtitleCandidate) async -> SubtitleCandidate?
|
||||
}
|
||||
|
||||
enum NativePlaybackError: LocalizedError {
|
||||
case backendUnavailable
|
||||
case startupTimedOut
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
||||
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, into: &results)
|
||||
collect(from: payload, context: CandidateContext(label: nil, language: nil), into: &results)
|
||||
|
||||
var seen = Set<String>()
|
||||
return results.filter { candidate in
|
||||
var orderedKeys: [String] = []
|
||||
var bestByURL: [String: SubtitleCandidate] = [:]
|
||||
results.forEach { candidate in
|
||||
let key = candidate.url.absoluteString
|
||||
guard !seen.contains(key) else {
|
||||
return false
|
||||
if bestByURL[key] == nil {
|
||||
orderedKeys.append(key)
|
||||
bestByURL[key] = candidate
|
||||
} else if let current = bestByURL[key],
|
||||
candidateScore(candidate) > candidateScore(current) {
|
||||
bestByURL[key] = candidate
|
||||
}
|
||||
seen.insert(key)
|
||||
return true
|
||||
}
|
||||
return orderedKeys.compactMap { bestByURL[$0] }
|
||||
}
|
||||
|
||||
private static func collect(from value: Any?, into results: inout [SubtitleCandidate]) {
|
||||
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<String>()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -246,13 +246,15 @@ 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")
|
||||
[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")
|
||||
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
|
||||
}
|
||||
|
||||
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)")
|
||||
|
|
|
|||
|
|
@ -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<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() {}
|
||||
}
|
||||
|
|
|
|||
659
docs/turns/2026-05-25-fix-stremio-external-subtitle-handoff.html
Normal file
659
docs/turns/2026-05-25-fix-stremio-external-subtitle-handoff.html
Normal file
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue