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-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-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-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-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-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-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}
|
{"_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 progressObservation: NSKeyValueObservation?
|
||||||
private var userAgent: String?
|
private var userAgent: String?
|
||||||
private var lastNativePlaybackURL: URL?
|
private var lastNativePlaybackURL: URL?
|
||||||
|
private var pendingSubtitleCandidatesByStreamKey: [URL: [SubtitleCandidate]] = [:]
|
||||||
|
private var currentNativePlaybackKey: URL?
|
||||||
private weak var currentNativePlayer: NativePlayerViewController?
|
private weak var currentNativePlayer: NativePlayerViewController?
|
||||||
private let streamResolver: StreamResolving = StremioStreamResolver()
|
private let streamResolver: StreamResolving = StremioStreamResolver()
|
||||||
|
|
||||||
|
|
@ -587,17 +589,33 @@ final class DreamioWebViewController: UIViewController {
|
||||||
|
|
||||||
let duplicateKey = request.resolverURL ?? request.playbackURL
|
let duplicateKey = request.resolverURL ?? request.playbackURL
|
||||||
if lastNativePlaybackURL == duplicateKey {
|
if lastNativePlaybackURL == duplicateKey {
|
||||||
|
mergeSubtitleCandidates(candidate.subtitleCandidates, for: duplicateKey)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lastNativePlaybackURL = duplicateKey
|
lastNativePlaybackURL = duplicateKey
|
||||||
|
currentNativePlaybackKey = duplicateKey
|
||||||
|
mergeSubtitleCandidates(request.subtitleCandidates, for: duplicateKey)
|
||||||
|
let mergedSubtitleCandidates = subtitleCandidates(for: duplicateKey)
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
let classification = request.classification
|
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
|
#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
|
Task { [weak self] in
|
||||||
await self?.resolveAndPresentNativePlayback(request)
|
await self?.resolveAndPresentNativePlayback(playbackRequest, streamKey: duplicateKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -606,12 +624,17 @@ final class DreamioWebViewController: UIViewController {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let streamKey = currentNativePlaybackKey ?? lastNativePlaybackURL
|
||||||
|
if let streamKey {
|
||||||
|
mergeSubtitleCandidates(candidates, for: streamKey)
|
||||||
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#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
|
#endif
|
||||||
guard let currentNativePlayer else {
|
guard let currentNativePlayer else {
|
||||||
#if DEBUG
|
#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
|
#endif
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -623,9 +646,10 @@ final class DreamioWebViewController: UIViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func resolveAndPresentNativePlayback(_ request: NativePlaybackRequest) async {
|
private func resolveAndPresentNativePlayback(_ request: NativePlaybackRequest, streamKey: URL) async {
|
||||||
guard VLCNativePlaybackBackend.isAvailable else {
|
guard VLCNativePlaybackBackend.isAvailable else {
|
||||||
lastNativePlaybackURL = nil
|
lastNativePlaybackURL = nil
|
||||||
|
currentNativePlaybackKey = nil
|
||||||
showNativePlaybackUnavailableAlert()
|
showNativePlaybackUnavailableAlert()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -644,25 +668,72 @@ final class DreamioWebViewController: UIViewController {
|
||||||
referer: request.referer,
|
referer: request.referer,
|
||||||
headers: resolved.headers,
|
headers: resolved.headers,
|
||||||
classification: request.classification,
|
classification: request.classification,
|
||||||
subtitleCandidates: request.subtitleCandidates
|
subtitleCandidates: subtitleCandidates(for: streamKey)
|
||||||
)
|
)
|
||||||
let player = NativePlayerViewController(request: resolvedRequest)
|
let player = NativePlayerViewController(request: resolvedRequest)
|
||||||
currentNativePlayer = player
|
|
||||||
player.onDismiss = { [weak self] in
|
player.onDismiss = { [weak self] in
|
||||||
self?.lastNativePlaybackURL = nil
|
self?.lastNativePlaybackURL = nil
|
||||||
|
self?.currentNativePlaybackKey = nil
|
||||||
self?.currentNativePlayer = nil
|
self?.currentNativePlayer = nil
|
||||||
|
self?.pendingSubtitleCandidatesByStreamKey.removeValue(forKey: streamKey)
|
||||||
self?.cleanUpStremioPlayerAfterNativeDismiss()
|
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 {
|
} catch {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("[DreamioStreamResolver] failure=\(URLRedactor.redactedURLString(error.localizedDescription)) resolver=\(request.resolverURL.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none")")
|
print("[DreamioStreamResolver] failure=\(URLRedactor.redactedURLString(error.localizedDescription)) resolver=\(request.resolverURL.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none")")
|
||||||
#endif
|
#endif
|
||||||
lastNativePlaybackURL = nil
|
lastNativePlaybackURL = nil
|
||||||
|
currentNativePlaybackKey = nil
|
||||||
|
pendingSubtitleCandidatesByStreamKey.removeValue(forKey: streamKey)
|
||||||
showNativePlaybackResolutionFailure(error)
|
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) {
|
private func showNativePlaybackResolutionFailure(_ error: Error) {
|
||||||
let alert = UIAlertController(
|
let alert = UIAlertController(
|
||||||
title: "Could not open stream",
|
title: "Could not open stream",
|
||||||
|
|
|
||||||
|
|
@ -30,10 +30,6 @@ protocol NativePlaybackBackend: AnyObject {
|
||||||
func stop()
|
func stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol SubtitleResolving {
|
|
||||||
func resolve(_ candidate: SubtitleCandidate) async -> SubtitleCandidate?
|
|
||||||
}
|
|
||||||
|
|
||||||
enum NativePlaybackError: LocalizedError {
|
enum NativePlaybackError: LocalizedError {
|
||||||
case backendUnavailable
|
case backendUnavailable
|
||||||
case startupTimedOut
|
case startupTimedOut
|
||||||
|
|
|
||||||
|
|
@ -134,37 +134,58 @@ enum SubtitleCandidateParser {
|
||||||
private static let supportedExtensions = ["srt", "vtt", "ass", "ssa", "sub"]
|
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 urlFields = ["url", "href", "src", "link", "subtitles", "subtitle", "subtitleUrl", "subtitleURL", "file", "download"]
|
||||||
private static let labelFields = ["label", "name", "title", "file_name", "lang", "language", "id"]
|
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] {
|
static func candidates(in payload: Any?) -> [SubtitleCandidate] {
|
||||||
var results: [SubtitleCandidate] = []
|
var results: [SubtitleCandidate] = []
|
||||||
collect(from: payload, into: &results)
|
collect(from: payload, context: CandidateContext(label: nil, language: nil), into: &results)
|
||||||
|
|
||||||
var seen = Set<String>()
|
var orderedKeys: [String] = []
|
||||||
return results.filter { candidate in
|
var bestByURL: [String: SubtitleCandidate] = [:]
|
||||||
|
results.forEach { candidate in
|
||||||
let key = candidate.url.absoluteString
|
let key = candidate.url.absoluteString
|
||||||
guard !seen.contains(key) else {
|
if bestByURL[key] == nil {
|
||||||
return false
|
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 {
|
switch value {
|
||||||
case let dictionary as [String: Any]:
|
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)
|
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]:
|
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:
|
case let string as String:
|
||||||
if let url = subtitleURL(from: 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 {
|
} else {
|
||||||
extractSubtitleURLs(from: string).forEach { url in
|
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:
|
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 {
|
guard let url = urlFields.lazy.compactMap({ subtitleURL(from: dictionary[$0] as? String) }).first else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -181,11 +202,17 @@ enum SubtitleCandidateParser {
|
||||||
let language = (dictionary["lang"] as? String) ?? (dictionary["language"] as? String)
|
let language = (dictionary["lang"] as? String) ?? (dictionary["language"] as? String)
|
||||||
return SubtitleCandidate(
|
return SubtitleCandidate(
|
||||||
url: url,
|
url: url,
|
||||||
label: label?.isEmpty == false ? label! : defaultLabel(for: url),
|
label: label?.isEmpty == false ? label! : (context.label ?? defaultLabel(for: url)),
|
||||||
language: language
|
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] {
|
private static func orderedNestedValues(in dictionary: [String: Any]) -> [Any] {
|
||||||
let preferredKeys = ["subtitles", "subtitle", "files", "downloads", "download"]
|
let preferredKeys = ["subtitles", "subtitle", "files", "downloads", "download"]
|
||||||
var visitedKeys = Set<String>()
|
var visitedKeys = Set<String>()
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,10 @@ struct ResolvedNativeStream {
|
||||||
let source: String
|
let source: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protocol SubtitleResolving {
|
||||||
|
func resolve(_ candidate: SubtitleCandidate) async -> SubtitleCandidate?
|
||||||
|
}
|
||||||
|
|
||||||
enum StreamResolverError: LocalizedError {
|
enum StreamResolverError: LocalizedError {
|
||||||
case noResolverURL
|
case noResolverURL
|
||||||
case httpStatus(Int)
|
case httpStatus(Int)
|
||||||
|
|
@ -47,6 +51,9 @@ final class SubtitleResolver: SubtitleResolving {
|
||||||
|
|
||||||
var request = URLRequest(url: candidate.url)
|
var request = URLRequest(url: candidate.url)
|
||||||
request.setValue("application/json, text/plain, text/vtt, application/x-subrip, */*", forHTTPHeaderField: "Accept")
|
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 {
|
do {
|
||||||
let (data, response) = try await session.data(for: request)
|
let (data, response) = try await session.data(for: request)
|
||||||
|
|
@ -66,7 +73,12 @@ final class SubtitleResolver: SubtitleResolving {
|
||||||
from: data,
|
from: data,
|
||||||
responseURL: response.url,
|
responseURL: response.url,
|
||||||
original: candidate
|
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 {
|
} catch {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("[DreamioSubtitles] resolve failure=\(URLRedactor.redactedURLString(error.localizedDescription)) url=\(URLRedactor.redactedURLString(candidate.url.absoluteString))")
|
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")
|
||||||
|| 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 {
|
protocol StreamResolving {
|
||||||
|
|
|
||||||
|
|
@ -246,13 +246,15 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
||||||
guard attachedCount > 0 else {
|
guard attachedCount > 0 else {
|
||||||
return attachedCount
|
return attachedCount
|
||||||
}
|
}
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
|
[0.2, 0.6, 1.0, 2.0, 4.0].forEach { delay in
|
||||||
self?.selectInitialSubtitleTrackIfNeeded(reason: "delayed-refresh")
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||||
#if DEBUG
|
self?.selectInitialSubtitleTrackIfNeeded(reason: "delayed-refresh-\(String(format: "%.1f", delay))")
|
||||||
self?.logSubtitleTracks(reason: "delayed-refresh")
|
#if DEBUG
|
||||||
#endif
|
self?.logSubtitleTracks(reason: "delayed-refresh-\(String(format: "%.1f", delay))")
|
||||||
|
#endif
|
||||||
self?.onSubtitleTracksChange?()
|
self?.onSubtitleTracksChange?()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return attachedCount
|
return attachedCount
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -300,11 +302,13 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
||||||
}
|
}
|
||||||
|
|
||||||
let selectedTrackID = mediaPlayer.currentVideoSubTitleIndex
|
let selectedTrackID = mediaPlayer.currentVideoSubTitleIndex
|
||||||
guard selectedTrackID != trackID || shouldLogNoop else {
|
guard selectedTrackID < 0 || (selectedTrackID == trackID && shouldLogNoop) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if selectedTrackID < 0 {
|
||||||
mediaPlayer.currentVideoSubTitleIndex = trackID
|
mediaPlayer.currentVideoSubTitleIndex = trackID
|
||||||
|
}
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
let action = selectedTrackID == trackID ? "confirm" : "recover"
|
let action = selectedTrackID == trackID ? "confirm" : "recover"
|
||||||
print("[DreamioVLC] reapply subtitle id=\(trackID) reason=\(reason) action=\(action) selected=\(mediaPlayer.currentVideoSubTitleIndex)")
|
print("[DreamioVLC] reapply subtitle id=\(trackID) reason=\(reason) action=\(action) selected=\(mediaPlayer.currentVideoSubTitleIndex)")
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import Foundation
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct StreamResolverTests {
|
struct StreamResolverTests {
|
||||||
static func main() {
|
static func main() async {
|
||||||
testClassifierPrefersObservedDirectFile()
|
testClassifierPrefersObservedDirectFile()
|
||||||
testResolverSelectsUnsupportedDirectURLAndHeaders()
|
testResolverSelectsUnsupportedDirectURLAndHeaders()
|
||||||
testResolverRejectsHLSOnlyResponse()
|
testResolverRejectsHLSOnlyResponse()
|
||||||
|
|
@ -11,7 +11,11 @@ struct StreamResolverTests {
|
||||||
testSubtitleCandidateParsing()
|
testSubtitleCandidateParsing()
|
||||||
testOpenSubtitlesV3CandidateParsing()
|
testOpenSubtitlesV3CandidateParsing()
|
||||||
testOpenSubtitlesV3DownloadResponseResolution()
|
testOpenSubtitlesV3DownloadResponseResolution()
|
||||||
|
await testSubtitleResolverDownloadJSONReturningLink()
|
||||||
|
await testSubtitleResolverRedirectToDirectSubtitle()
|
||||||
|
await testSubtitleResolverRejectsNonSubtitleAPIResponse()
|
||||||
testSubtitleCandidateDeduplicationPreservesLabels()
|
testSubtitleCandidateDeduplicationPreservesLabels()
|
||||||
|
testSubtitleCandidateDeduplicationUpgradesLabels()
|
||||||
testSubtitleOptionMappingIncludesNone()
|
testSubtitleOptionMappingIncludesNone()
|
||||||
print("StreamResolverTests passed")
|
print("StreamResolverTests passed")
|
||||||
}
|
}
|
||||||
|
|
@ -143,6 +147,8 @@ struct StreamResolverTests {
|
||||||
assertEqual(candidates[0].label, "English")
|
assertEqual(candidates[0].label, "English")
|
||||||
assertEqual(candidates[0].language, "English")
|
assertEqual(candidates[0].language, "English")
|
||||||
assertEqual(candidates[1].url.absoluteString, "https://dl.opensubtitles.org/en/subtitle.vtt?download=1")
|
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].label, "spa")
|
||||||
assertEqual(candidates[2].language, "spa")
|
assertEqual(candidates[2].language, "spa")
|
||||||
assertEqual(candidates[3].url.absoluteString, "https://cdn.example.test/from-string.ass?source=opensubtitles")
|
assertEqual(candidates[3].url.absoluteString, "https://cdn.example.test/from-string.ass?source=opensubtitles")
|
||||||
|
|
@ -173,6 +179,62 @@ struct StreamResolverTests {
|
||||||
assertEqual(candidate?.language, "eng")
|
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() {
|
private static func testSubtitleCandidateDeduplicationPreservesLabels() {
|
||||||
let payload: [String: Any] = [
|
let payload: [String: Any] = [
|
||||||
"subtitles": [
|
"subtitles": [
|
||||||
|
|
@ -197,6 +259,25 @@ struct StreamResolverTests {
|
||||||
assertEqual(candidates[0].language, "eng")
|
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() {
|
private static func testSubtitleOptionMappingIncludesNone() {
|
||||||
let options = SubtitleOptionMapper.options(from: [
|
let options = SubtitleOptionMapper.options(from: [
|
||||||
SubtitleTrack(id: 2, name: "English"),
|
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) {
|
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)
|
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