mirror of
https://github.com/dirtydishes/dreamio.git
synced 2026-06-06 13:37:24 +00:00
resolve opensubtitles subtitle downloads
This commit is contained in:
parent
6a29dde857
commit
fdc4444f6a
9 changed files with 829 additions and 19 deletions
|
|
@ -15,3 +15,4 @@
|
|||
{"id":"int-2a84633f","kind":"field_change","created_at":"2026-05-25T10:25:22.649574Z","actor":"dirtydishes","issue_id":"dreamio-88m","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented captions as a single-choice menu with None and selected loaded tracks, updated tests and turn documentation."}}
|
||||
{"id":"int-38a97132","kind":"field_change","created_at":"2026-05-25T10:43:21.805452Z","actor":"dirtydishes","issue_id":"dreamio-lw6","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented late subtitle forwarding into active native playback, added VLC append path and parser tests."}}
|
||||
{"id":"int-ddab585f","kind":"field_change","created_at":"2026-05-25T11:07:34.849628Z","actor":"dirtydishes","issue_id":"dreamio-8cz","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Hardened subtitle bridge network observers so non-text Stremio subtitle loads are not touched, and made parser traversal deterministic for metadata preservation."}}
|
||||
{"id":"int-e07aeefe","kind":"field_change","created_at":"2026-05-25T13:50:43.373777Z","actor":"dirtydishes","issue_id":"dreamio-h5q","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Resolved OpenSubtitles V3 API-style subtitle download URLs to direct subtitle files before VLC attachment; added parser/resolver coverage and simulator build 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-h5q","title":"Resolve OpenSubtitles API subtitle URLs before VLC attachment","description":"OpenSubtitles V3 can surface API/download endpoints that are not subtitle files themselves. Dreamio should resolve those endpoints to playable subtitle file URLs before handing them to VLC so Stremio does not show failed subtitle loads after native playback opens.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T13:47:17Z","created_by":"dirtydishes","updated_at":"2026-05-25T13:50:43Z","started_at":"2026-05-25T13:47:21Z","closed_at":"2026-05-25T13:50:43Z","close_reason":"Resolved OpenSubtitles V3 API-style subtitle download URLs to direct subtitle files before VLC attachment; added parser/resolver coverage and simulator build validation.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"dreamio-lw6","title":"forward late opensubtitles subtitles to native player","description":"Native playback only receives subtitle candidates discovered before the stream candidate is posted. OpenSubtitles V3 candidates can arrive later through addon/network responses, so the active native player needs an append path for newly discovered external subtitles.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T10:40:28Z","created_by":"dirtydishes","updated_at":"2026-05-25T10:43:22Z","started_at":"2026-05-25T10:40:36Z","closed_at":"2026-05-25T10:43:22Z","close_reason":"Implemented late subtitle forwarding into active native playback, added VLC append path and parser tests.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"dreamio-poo","title":"Native player controls captions and close flow","description":"Add and validate VLC-backed native playback transport controls, subtitle track controls, external subtitle discovery, and Stremio Web close cleanup after native playback dismisses.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T09:47:56Z","created_by":"dirtydishes","updated_at":"2026-05-25T09:49:40Z","started_at":"2026-05-25T09:48:00Z","closed_at":"2026-05-25T09:49:40Z","close_reason":"Implemented and validated native player controls, subtitle handling refinements, and close-flow cleanup.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"dreamio-wgk","title":"Fix native player controls tap-to-show","description":"Native player controls can be hidden by tapping, but subsequent taps on the player do not bring them back. Investigate the overlay gesture handling and restore reliable tap-to-show/tap-to-hide behavior.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T09:27:58Z","created_by":"dirtydishes","updated_at":"2026-05-25T09:51:17Z","started_at":"2026-05-25T09:28:11Z","closed_at":"2026-05-25T09:51:17Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,10 @@ protocol NativePlaybackBackend: AnyObject {
|
|||
func stop()
|
||||
}
|
||||
|
||||
protocol SubtitleResolving {
|
||||
func resolve(_ candidate: SubtitleCandidate) async -> SubtitleCandidate?
|
||||
}
|
||||
|
||||
enum NativePlaybackError: LocalizedError {
|
||||
case backendUnavailable
|
||||
case startupTimedOut
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import UIKit
|
|||
final class NativePlayerViewController: UIViewController {
|
||||
private let request: NativePlaybackRequest
|
||||
private var backend: NativePlaybackBackend
|
||||
private let subtitleResolver: SubtitleResolving
|
||||
private var startupTimer: Timer?
|
||||
private var controlsTimer: Timer?
|
||||
private var progressTimer: Timer?
|
||||
|
|
@ -92,10 +93,15 @@ final class NativePlayerViewController: UIViewController {
|
|||
return label
|
||||
}()
|
||||
|
||||
init(request: NativePlaybackRequest, backend: NativePlaybackBackend = VLCNativePlaybackBackend()) {
|
||||
init(
|
||||
request: NativePlaybackRequest,
|
||||
backend: NativePlaybackBackend = VLCNativePlaybackBackend(),
|
||||
subtitleResolver: SubtitleResolving = SubtitleResolver()
|
||||
) {
|
||||
self.request = request
|
||||
self.backend = backend
|
||||
self.attachedSubtitleURLs = Set(request.subtitleCandidates.map(\.url))
|
||||
self.subtitleResolver = subtitleResolver
|
||||
self.attachedSubtitleURLs = []
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
modalPresentationStyle = .fullScreen
|
||||
modalTransitionStyle = .crossDissolve
|
||||
|
|
@ -126,26 +132,52 @@ final class NativePlayerViewController: UIViewController {
|
|||
configureLayout()
|
||||
startStartupTimer()
|
||||
backend.play(request: request)
|
||||
addSubtitleCandidates(request.subtitleCandidates)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func addSubtitleCandidates(_ candidates: [SubtitleCandidate]) -> Int {
|
||||
let newCandidates = candidates.filter { candidate in
|
||||
guard !attachedSubtitleURLs.contains(candidate.url) else {
|
||||
let pendingCandidates = candidates.filter { !attachedSubtitleURLs.contains($0.url) }
|
||||
guard !pendingCandidates.isEmpty else {
|
||||
#if DEBUG
|
||||
print("[DreamioNativePlayer] subtitle candidates=\(candidates.count) pending=0 duplicates=\(candidates.count)")
|
||||
#endif
|
||||
return 0
|
||||
}
|
||||
|
||||
pendingCandidates.forEach { attachedSubtitleURLs.insert($0.url) }
|
||||
|
||||
Task { [weak self] in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
let resolvedCandidates = await self.resolveSubtitleCandidates(pendingCandidates)
|
||||
await MainActor.run {
|
||||
guard !resolvedCandidates.isEmpty else {
|
||||
#if DEBUG
|
||||
print("[DreamioNativePlayer] subtitle candidates=\(candidates.count) pending=\(pendingCandidates.count) resolved=0 attached=0")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
let attachableCandidates = resolvedCandidates.filter { candidate in
|
||||
guard !self.attachedSubtitleURLs.contains(candidate.url) || pendingCandidates.contains(where: { $0.url == candidate.url }) else {
|
||||
return false
|
||||
}
|
||||
attachedSubtitleURLs.insert(candidate.url)
|
||||
self.attachedSubtitleURLs.insert(candidate.url)
|
||||
return true
|
||||
}
|
||||
let attachedCount = backend.addSubtitleCandidates(newCandidates)
|
||||
let attachedCount = self.backend.addSubtitleCandidates(attachableCandidates)
|
||||
if attachedCount > 0 {
|
||||
refreshControls()
|
||||
self.refreshControls()
|
||||
}
|
||||
#if DEBUG
|
||||
let duplicateCount = candidates.count - newCandidates.count
|
||||
print("[DreamioNativePlayer] subtitle candidates=\(candidates.count) forwarded=\(newCandidates.count) attached=\(attachedCount) duplicates=\(duplicateCount)")
|
||||
let duplicateCount = candidates.count - pendingCandidates.count + resolvedCandidates.count - attachableCandidates.count
|
||||
print("[DreamioNativePlayer] subtitle candidates=\(candidates.count) pending=\(pendingCandidates.count) resolved=\(resolvedCandidates.count) attached=\(attachedCount) duplicates=\(duplicateCount)")
|
||||
#endif
|
||||
return attachedCount
|
||||
}
|
||||
}
|
||||
|
||||
return pendingCandidates.count
|
||||
}
|
||||
|
||||
override func viewDidDisappear(_ animated: Bool) {
|
||||
|
|
@ -157,6 +189,16 @@ final class NativePlayerViewController: UIViewController {
|
|||
onDismiss?()
|
||||
}
|
||||
|
||||
private func resolveSubtitleCandidates(_ candidates: [SubtitleCandidate]) async -> [SubtitleCandidate] {
|
||||
var resolved: [SubtitleCandidate] = []
|
||||
for candidate in candidates {
|
||||
if let playableCandidate = await subtitleResolver.resolve(candidate) {
|
||||
resolved.append(playableCandidate)
|
||||
}
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
private func configureBackend() {
|
||||
backend.prepare(in: self)
|
||||
backend.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
|
|
|||
|
|
@ -105,8 +105,8 @@ struct StreamCandidate {
|
|||
|
||||
enum SubtitleCandidateParser {
|
||||
private static let supportedExtensions = ["srt", "vtt", "ass", "ssa", "sub"]
|
||||
private static let urlFields = ["url", "href", "src", "subtitles", "subtitle", "subtitleUrl", "subtitleURL", "file", "download"]
|
||||
private static let labelFields = ["label", "name", "title", "lang", "language", "id"]
|
||||
private static let urlFields = ["url", "href", "src", "link", "subtitles", "subtitle", "subtitleUrl", "subtitleURL", "file", "download"]
|
||||
private static let labelFields = ["label", "name", "title", "file_name", "lang", "language", "id"]
|
||||
|
||||
static func candidates(in payload: Any?) -> [SubtitleCandidate] {
|
||||
var results: [SubtitleCandidate] = []
|
||||
|
|
|
|||
|
|
@ -29,6 +29,106 @@ enum StreamResolverError: LocalizedError {
|
|||
}
|
||||
}
|
||||
|
||||
final class SubtitleResolver: SubtitleResolving {
|
||||
private let session: URLSession
|
||||
|
||||
init(session: URLSession = .shared) {
|
||||
self.session = session
|
||||
}
|
||||
|
||||
func resolve(_ candidate: SubtitleCandidate) async -> SubtitleCandidate? {
|
||||
if Self.isDirectSubtitleFile(candidate.url) {
|
||||
return candidate
|
||||
}
|
||||
|
||||
guard Self.shouldResolve(candidate.url) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var request = URLRequest(url: candidate.url)
|
||||
request.setValue("application/json, text/plain, text/vtt, application/x-subrip, */*", forHTTPHeaderField: "Accept")
|
||||
|
||||
do {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
if let httpResponse = response as? HTTPURLResponse,
|
||||
!(200...299).contains(httpResponse.statusCode) {
|
||||
#if DEBUG
|
||||
print("[DreamioSubtitles] resolve status=\(httpResponse.statusCode) url=\(URLRedactor.redactedURLString(candidate.url.absoluteString))")
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
|
||||
if let finalURL = response.url, Self.isDirectSubtitleFile(finalURL) {
|
||||
return SubtitleCandidate(url: finalURL, label: candidate.label, language: candidate.language)
|
||||
}
|
||||
|
||||
return Self.bestPlayableCandidate(
|
||||
from: data,
|
||||
responseURL: response.url,
|
||||
original: candidate
|
||||
)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("[DreamioSubtitles] resolve failure=\(URLRedactor.redactedURLString(error.localizedDescription)) url=\(URLRedactor.redactedURLString(candidate.url.absoluteString))")
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
static func bestPlayableCandidate(
|
||||
from data: Data,
|
||||
responseURL: URL?,
|
||||
original: SubtitleCandidate
|
||||
) -> SubtitleCandidate? {
|
||||
if let responseURL, isDirectSubtitleFile(responseURL) {
|
||||
return SubtitleCandidate(url: responseURL, label: original.label, language: original.language)
|
||||
}
|
||||
|
||||
guard !data.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let payload = try? JSONSerialization.jsonObject(with: data) {
|
||||
return SubtitleCandidateParser.candidates(in: payload)
|
||||
.first(where: { isDirectSubtitleFile($0.url) })
|
||||
.map { playable in
|
||||
SubtitleCandidate(
|
||||
url: playable.url,
|
||||
label: original.label.isEmpty ? playable.label : original.label,
|
||||
language: playable.language ?? original.language
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if let text = String(data: data, encoding: .utf8) {
|
||||
return SubtitleCandidateParser.candidates(in: text)
|
||||
.first(where: { isDirectSubtitleFile($0.url) })
|
||||
.map { playable in
|
||||
SubtitleCandidate(
|
||||
url: playable.url,
|
||||
label: original.label.isEmpty ? playable.label : original.label,
|
||||
language: playable.language ?? original.language
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
static func isDirectSubtitleFile(_ url: URL) -> Bool {
|
||||
let lowercased = url.absoluteString.lowercased()
|
||||
return ["srt", "vtt", "ass", "ssa", "sub"].contains(url.pathExtension.lowercased())
|
||||
|| [".srt?", ".vtt?", ".ass?", ".ssa?", ".sub?", ".srt&", ".vtt&", ".ass&", ".ssa&", ".sub&"].contains(where: lowercased.contains)
|
||||
}
|
||||
|
||||
private static func shouldResolve(_ url: URL) -> Bool {
|
||||
let lowercased = url.absoluteString.lowercased()
|
||||
return lowercased.contains("opensubtitles")
|
||||
|| lowercased.contains("/subtitle")
|
||||
|| lowercased.contains("subtitle")
|
||||
}
|
||||
}
|
||||
|
||||
protocol StreamResolving {
|
||||
func resolve(request: NativePlaybackRequest) async throws -> ResolvedNativeStream
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,7 +58,6 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
|||
print("[DreamioVLC] opening url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))")
|
||||
#endif
|
||||
mediaPlayer.play()
|
||||
addSubtitleCandidates(request.subtitleCandidates)
|
||||
#else
|
||||
onFailure?(NativePlaybackError.backendUnavailable)
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ struct StreamResolverTests {
|
|||
testPlaybackTimeFormatting()
|
||||
testSubtitleCandidateParsing()
|
||||
testOpenSubtitlesV3CandidateParsing()
|
||||
testOpenSubtitlesV3DownloadResponseResolution()
|
||||
testSubtitleCandidateDeduplicationPreservesLabels()
|
||||
testSubtitleOptionMappingIncludesNone()
|
||||
print("StreamResolverTests passed")
|
||||
|
|
@ -147,6 +148,31 @@ struct StreamResolverTests {
|
|||
assertEqual(candidates[3].url.absoluteString, "https://cdn.example.test/from-string.ass?source=opensubtitles")
|
||||
}
|
||||
|
||||
private static func testOpenSubtitlesV3DownloadResponseResolution() {
|
||||
let payload = """
|
||||
{
|
||||
"link": "https://dl.opensubtitles.org/en/download/subtitle.srt?token=secret",
|
||||
"file_name": "episode.srt",
|
||||
"requests": 1
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
let original = SubtitleCandidate(
|
||||
url: URL(string: "https://api.opensubtitles.com/api/v1/download")!,
|
||||
label: "English",
|
||||
language: "eng"
|
||||
)
|
||||
|
||||
let candidate = SubtitleResolver.bestPlayableCandidate(
|
||||
from: payload,
|
||||
responseURL: original.url,
|
||||
original: original
|
||||
)
|
||||
|
||||
assertEqual(candidate?.url.absoluteString, "https://dl.opensubtitles.org/en/download/subtitle.srt?token=secret")
|
||||
assertEqual(candidate?.label, "English")
|
||||
assertEqual(candidate?.language, "eng")
|
||||
}
|
||||
|
||||
private static func testSubtitleCandidateDeduplicationPreservesLabels() {
|
||||
let payload: [String: Any] = [
|
||||
"subtitles": [
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue