fix opensubtitles native captions

This commit is contained in:
dirtydishes 2026-05-25 11:54:14 -04:00
parent d3c5507763
commit f34d60af1b
7 changed files with 666 additions and 16 deletions

View file

@ -25,3 +25,4 @@
{"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."}}
{"id":"int-320e7321","kind":"field_change","created_at":"2026-05-25T15:53:52.866657Z","actor":"dirtydishes","issue_id":"dreamio-hzj","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Hardened OpenSubtitles candidate discovery, nested payload resolution, VLC external subtitle visibility selection, diagnostics, tests, and turn documentation."}}

View file

@ -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-hzj","title":"OpenSubtitles tracks missing from native captions menu","description":"OpenSubtitles subtitle candidates can be discovered or resolved inconsistently, and external VLC subtitle slaves may not become visible quickly enough to show as selectable native caption tracks. Harden discovery, resolution, attachment, diagnostics, tests, and turn documentation for the native captions path.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T15:51:07Z","created_by":"dirtydishes","updated_at":"2026-05-25T15:53:53Z","started_at":"2026-05-25T15:51:13Z","closed_at":"2026-05-25T15:53:53Z","close_reason":"Hardened OpenSubtitles candidate discovery, nested payload resolution, VLC external subtitle visibility selection, diagnostics, tests, and turn documentation.","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}

View file

@ -84,6 +84,19 @@ final class DreamioWebViewController: UIViewController {
const postedSubtitleURLs = new Set();
const subtitleURLPattern = /https?:\/\/[^\s"'<>]+(?:\.srt|\.vtt|\.ass|\.ssa|\.sub|opensubtitles|subtitle)[^\s"'<>]*/ig;
const subtitleSignalPattern = /subtitle|subtitles|opensubtitles|vtt|srt|ass|ssa/i;
const subtitleObjectKeys = [
"attributes",
"files",
"file_id",
"url",
"download",
"link",
"file",
"file_name",
"filename",
"language",
"lang"
];
const looksNative = (url) => {
if (!url || typeof url !== "string") {
@ -132,10 +145,14 @@ final class DreamioWebViewController: UIViewController {
const postSubtitleCandidates = (candidates, debug = {}) => {
const discoveredCount = candidates.length;
const fresh = candidates.filter((candidate) => {
if (postedSubtitleURLs.has(candidate.url)) {
const key = candidate && (candidate.url || candidate.link || candidate.download || candidate.file || candidate.file_id);
if (!key) {
return false;
}
postedSubtitleURLs.add(candidate.url);
if (postedSubtitleURLs.has(String(key))) {
return false;
}
postedSubtitleURLs.add(String(key));
return true;
});
if (fresh.length === 0) {
@ -182,9 +199,12 @@ final class DreamioWebViewController: UIViewController {
entry.fileUrl ||
entry.fileURL
);
const url = absoluteURL(rawURL);
let url = absoluteURL(rawURL);
if (!url && entry && entry.file_id) {
url = `https://api.opensubtitles.com/api/v1/download/${encodeURIComponent(String(entry.file_id))}`;
}
subtitleURLPattern.lastIndex = 0;
if (!url || !subtitleURLPattern.test(url)) {
if (!url || (!subtitleURLPattern.test(url) && !/api\.opensubtitles\.com\/api\/v1\/download/i.test(url))) {
subtitleURLPattern.lastIndex = 0;
return;
}
@ -198,7 +218,10 @@ final class DreamioWebViewController: UIViewController {
language: entry && (entry.lang || entry.language) || ""
};
subtitleCandidates.push(candidate);
postSubtitleCandidates([candidate]);
postSubtitleCandidates([candidate], {
discovered: 1,
totalKnown: subtitleCandidates.length
});
};
const inspectTrack = (track) => {
@ -264,6 +287,13 @@ final class DreamioWebViewController: UIViewController {
}
if (typeof payload === "object") {
addSubtitleCandidate(payload);
const likelySubtitlePayload = subtitleObjectKeys.some((key) => Object.prototype.hasOwnProperty.call(payload, key));
if (likelySubtitlePayload) {
postSubtitleCandidates([payload], {
source: "payload-object",
totalKnown: subtitleCandidates.length
});
}
Object.values(payload).forEach(inspectSubtitlePayload);
}
};

View file

@ -132,8 +132,8 @@ struct StreamCandidate {
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 static let urlFields = ["url", "href", "src", "link", "subtitles", "subtitle", "subtitleUrl", "subtitleURL", "file", "download", "fileUrl", "fileURL"]
private static let labelFields = ["label", "name", "title", "file_name", "filename", "lang", "language", "id"]
private struct CandidateContext {
let label: String?
let language: String?
@ -194,7 +194,9 @@ enum SubtitleCandidateParser {
}
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
?? openSubtitlesDownloadURL(from: dictionary["file_id"])
else {
return nil
}
@ -214,7 +216,7 @@ enum SubtitleCandidateParser {
}
private static func orderedNestedValues(in dictionary: [String: Any]) -> [Any] {
let preferredKeys = ["subtitles", "subtitle", "files", "downloads", "download"]
let preferredKeys = ["attributes", "subtitles", "subtitle", "files", "downloads", "download", "data", "results"]
var visitedKeys = Set<String>()
var values: [Any] = []
@ -254,6 +256,22 @@ enum SubtitleCandidateParser {
return url
}
private static func openSubtitlesDownloadURL(from value: Any?) -> URL? {
let id: String?
if let string = value as? String, !string.isEmpty {
id = string
} else if let number = value as? NSNumber {
id = number.stringValue
} else {
id = nil
}
guard let id else {
return nil
}
return URL(string: "https://api.opensubtitles.com/api/v1/download/\(id)")
}
private static func defaultLabel(for url: URL) -> String {
let lastPathComponent = url.deletingPathExtension().lastPathComponent
return lastPathComponent.isEmpty ? "External Subtitle" : lastPathComponent

View file

@ -26,6 +26,8 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
private var didAutoSelectSubtitleTrack = false
private var didUserSelectSubtitleTrack = false
private var autoSelectedSubtitleTrackID: Int32?
private var externalSubtitleBaselineTrackIDs = Set<Int32>()
private var hasPendingExternalSubtitleSelection = false
override init() {
super.init()
@ -47,6 +49,8 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
didAutoSelectSubtitleTrack = false
didUserSelectSubtitleTrack = false
autoSelectedSubtitleTrackID = nil
externalSubtitleBaselineTrackIDs.removeAll()
hasPendingExternalSubtitleSelection = false
let media = VLCMedia(url: request.playbackURL)
let headerValue = request.headers
.map { "\($0.key): \($0.value)" }
@ -225,22 +229,25 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
private func attachSubtitles(_ candidates: [SubtitleCandidate]) -> Int {
var attachedCount = 0
var duplicateCount = 0
let baselineTrackIDs = Set(subtitleTracks.filter { $0.id >= 0 }.map(\.id))
candidates.forEach { candidate in
guard !attachedSubtitleURLs.contains(candidate.url) else {
duplicateCount += 1
return
}
attachedSubtitleURLs.insert(candidate.url)
externalSubtitleBaselineTrackIDs.formUnion(baselineTrackIDs)
hasPendingExternalSubtitleSelection = true
mediaPlayer.addPlaybackSlave(candidate.url, type: .subtitle, enforce: false)
attachedCount += 1
#if DEBUG
print("[DreamioVLC] addPlaybackSlave subtitle=\(URLRedactor.redactedURLString(candidate.url.absoluteString)) label=\(candidate.label) language=\(candidate.language ?? "unknown") ext=\(candidate.url.pathExtension.lowercased())")
print("[DreamioVLC] attach accepted subtitle=\(URLRedactor.redactedURLString(candidate.url.absoluteString)) label=\(candidate.label) language=\(candidate.language ?? "unknown") ext=\(candidate.url.pathExtension.lowercased()) visibleBefore=\(baselineTrackIDs.count)")
logSubtitleTracks(reason: "after-addPlaybackSlave")
#endif
}
#if DEBUG
if !candidates.isEmpty {
print("[DreamioVLC] subtitle candidates=\(candidates.count) attached=\(attachedCount) duplicates=\(duplicateCount)")
print("[DreamioVLC] subtitle candidates=\(candidates.count) attached=\(attachedCount) duplicates=\(duplicateCount) visible=\(subtitleTracks.filter { $0.id >= 0 }.count)")
}
#endif
guard attachedCount > 0 else {
@ -248,9 +255,12 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
}
[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))")
self?.selectPreferredSubtitleTrackIfNeeded(reason: "delayed-refresh-\(String(format: "%.1f", delay))")
#if DEBUG
self?.logSubtitleTracks(reason: "delayed-refresh-\(String(format: "%.1f", delay))")
if delay == 4.0 {
self?.logMissingExternalSubtitleTrackIfNeeded()
}
#endif
self?.onSubtitleTracksChange?()
}
@ -266,14 +276,27 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
}
#endif
private func selectInitialSubtitleTrackIfNeeded(reason: String) {
guard !didUserSelectSubtitleTrack,
!didAutoSelectSubtitleTrack,
private func selectPreferredSubtitleTrackIfNeeded(reason: String) {
guard !didUserSelectSubtitleTrack else {
return
}
if hasPendingExternalSubtitleSelection,
let externalTrack = subtitleTracks.first(where: { $0.id >= 0 && !externalSubtitleBaselineTrackIDs.contains($0.id) }) {
selectAutoSubtitleTrack(externalTrack, reason: "\(reason)-external")
hasPendingExternalSubtitleSelection = false
return
}
guard !didAutoSelectSubtitleTrack,
mediaPlayer.currentVideoSubTitleIndex < 0,
let track = subtitleTracks.first(where: { $0.id >= 0 }) else {
return
}
selectAutoSubtitleTrack(track, reason: reason)
}
private func selectAutoSubtitleTrack(_ track: SubtitleTrack, reason: String) {
didAutoSelectSubtitleTrack = true
autoSelectedSubtitleTrackID = track.id
#if DEBUG
@ -283,6 +306,15 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
scheduleAutoSubtitleSelectionReapply(trackID: track.id)
}
#if DEBUG
private func logMissingExternalSubtitleTrackIfNeeded() {
guard hasPendingExternalSubtitleSelection else {
return
}
print("[DreamioVLC] attach accepted but no new external subtitle track visible baseline=\(externalSubtitleBaselineTrackIDs.sorted()) visible=\(subtitleTracks.filter { $0.id >= 0 }.map(\.id))")
}
#endif
private func scheduleAutoSubtitleSelectionReapply(trackID: Int32) {
[0.3, 1.0, 2.0, 4.0].forEach { delay in
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
@ -333,7 +365,7 @@ extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate {
case .paused, .stopped, .ended:
onStateChange?()
case .esAdded:
selectInitialSubtitleTrackIfNeeded(reason: "esAdded")
selectPreferredSubtitleTrackIfNeeded(reason: "esAdded")
#if DEBUG
logSubtitleTracks(reason: "esAdded")
#endif

View file

@ -10,7 +10,9 @@ struct StreamResolverTests {
testPlaybackTimeFormatting()
testSubtitleCandidateParsing()
testOpenSubtitlesV3CandidateParsing()
testOpenSubtitlesNestedAttributesFilesParsing()
testOpenSubtitlesV3DownloadResponseResolution()
testOpenSubtitlesNestedDownloadResponseResolution()
await testSubtitleResolverDownloadJSONReturningLink()
await testSubtitleResolverRedirectToDirectSubtitle()
await testSubtitleResolverRejectsNonSubtitleAPIResponse()
@ -154,6 +156,39 @@ struct StreamResolverTests {
assertEqual(candidates[3].url.absoluteString, "https://cdn.example.test/from-string.ass?source=opensubtitles")
}
private static func testOpenSubtitlesNestedAttributesFilesParsing() {
let payload: [String: Any] = [
"data": [
[
"attributes": [
"language": "English",
"file_name": "episode.en.srt",
"files": [
[
"file_id": 12345,
"file_name": "nested.en.srt"
],
[
"link": "https://dl.opensubtitles.org/en/download/nested.vtt?token=secret",
"language": "eng"
]
]
]
]
]
]
let candidates = SubtitleCandidateParser.candidates(in: payload)
assertEqual(candidates.count, 2)
assertEqual(candidates[0].url.absoluteString, "https://api.opensubtitles.com/api/v1/download/12345")
assertEqual(candidates[0].label, "nested.en.srt")
assertEqual(candidates[0].language, "English")
assertEqual(candidates[1].url.absoluteString, "https://dl.opensubtitles.org/en/download/nested.vtt?token=secret")
assertEqual(candidates[1].label, "eng")
assertEqual(candidates[1].language, "eng")
}
private static func testOpenSubtitlesV3DownloadResponseResolution() {
let payload = """
{
@ -179,6 +214,44 @@ struct StreamResolverTests {
assertEqual(candidate?.language, "eng")
}
private static func testOpenSubtitlesNestedDownloadResponseResolution() {
let payload = """
{
"data": {
"attributes": {
"files": [
{
"file_name": "ignored.txt",
"link": "https://cdn.example.test/ignored.txt"
},
{
"file_name": "episode.en.ass",
"download": {
"link": "https://dl.opensubtitles.org/en/download/episode.en.ass?token=secret"
}
}
]
}
}
}
""".data(using: .utf8)!
let original = SubtitleCandidate(
url: URL(string: "https://api.opensubtitles.com/api/v1/download/987")!,
label: "English SDH",
language: "eng"
)
let candidate = SubtitleResolver.bestPlayableCandidate(
from: payload,
responseURL: original.url,
original: original
)
assertEqual(candidate?.url.absoluteString, "https://dl.opensubtitles.org/en/download/episode.en.ass?token=secret")
assertEqual(candidate?.label, "English SDH")
assertEqual(candidate?.language, "eng")
}
private static func testSubtitleResolverDownloadJSONReturningLink() async {
MockURLProtocol.handlers = [
"https://api.opensubtitles.com/api/v1/download/123": (

File diff suppressed because one or more lines are too long