mirror of
https://github.com/dirtydishes/dreamio.git
synced 2026-06-06 13:37:24 +00:00
fix opensubtitles native captions
This commit is contained in:
parent
d3c5507763
commit
f34d60af1b
7 changed files with 666 additions and 16 deletions
|
|
@ -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."}}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": (
|
||||
|
|
|
|||
495
docs/turns/2026-05-25-fix-opensubtitles-native-captions.html
Normal file
495
docs/turns/2026-05-25-fix-opensubtitles-native-captions.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