fix stremio subtitle handoff to vlc

This commit is contained in:
dirtydishes 2026-05-25 11:33:15 -04:00
parent c59b318d9b
commit d3c5507763
9 changed files with 951 additions and 42 deletions

View file

@ -24,3 +24,4 @@
{"id":"int-96629c65","kind":"field_change","created_at":"2026-05-25T14:45:38.521113Z","actor":"dirtydishes","issue_id":"dreamio-ppj","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Re-applied the auto-selected VLC subtitle track after stream discovery and playback state changes to harden rendering timing."}}
{"id":"int-027cec57","kind":"field_change","created_at":"2026-05-25T14:51:44.599319Z","actor":"dirtydishes","issue_id":"dreamio-3xi","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Captured OpenSubtitles V3 subtitle URLs from browser track elements and textTracks so they can be forwarded to native playback."}}
{"id":"int-3acaadff","kind":"field_change","created_at":"2026-05-25T15:09:02.023077Z","actor":"dirtydishes","issue_id":"dreamio-h5n","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Limited VLC auto-subtitle reapply to real selection recovery while keeping bounded delayed startup confirmations."}}
{"id":"int-c526b5ae","kind":"field_change","created_at":"2026-05-25T15:32:37.748454Z","actor":"dirtydishes","issue_id":"dreamio-dow","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented stream-keyed subtitle buffering, OpenSubtitles parser/resolver hardening, VLC refresh behavior, and focused validation."}}

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-dow","title":"fix stremio external subtitle handoff to vlc","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T15:17:16Z","created_by":"dirtydishes","updated_at":"2026-05-25T15:32:38Z","started_at":"2026-05-25T15:17:25Z","closed_at":"2026-05-25T15:32:38Z","close_reason":"Implemented stream-keyed subtitle buffering, OpenSubtitles parser/resolver hardening, VLC refresh behavior, and focused validation.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-bao","title":"add native player audio track selection","description":"Add audio track discovery and selection to the native VLC-backed player so multi-language files can be filtered from the player controls.","status":"closed","priority":1,"issue_type":"feature","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:57:14Z","created_by":"dirtydishes","updated_at":"2026-05-25T15:01:36Z","closed_at":"2026-05-25T15:01:36Z","close_reason":"Implemented native audio track discovery and selection with a far-left audio menu in the VLC-backed player.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-3xi","title":"Capture browser text tracks for OpenSubtitles V3","description":"OpenSubtitles V3 subtitles can be attached to the Stremio web player as HTML track/textTrack entries rather than appearing in the initial stream candidate. Extend the web bridge to inspect track elements and textTracks so external subtitles can be forwarded to native playback.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:49:50Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:51:45Z","started_at":"2026-05-25T14:49:52Z","closed_at":"2026-05-25T14:51:45Z","close_reason":"Captured OpenSubtitles V3 subtitle URLs from browser track elements and textTracks so they can be forwarded to native playback.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-ppj","title":"Reapply VLC embedded subtitle selection after track discovery","description":"Device logs show VLC eventually exposes and selects the embedded English SDH subtitle track, but subtitles still do not render. Investigate and harden the VLC selection timing so embedded tracks are selected after discovery is stable.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:44:08Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:45:38Z","started_at":"2026-05-25T14:44:18Z","closed_at":"2026-05-25T14:45:38Z","close_reason":"Re-applied the auto-selected VLC subtitle track after stream discovery and playback state changes to harden rendering timing.","dependency_count":0,"dependent_count":0,"comment_count":0}

View file

@ -57,6 +57,8 @@ final class DreamioWebViewController: UIViewController {
private var progressObservation: NSKeyValueObservation?
private var userAgent: String?
private var lastNativePlaybackURL: URL?
private var pendingSubtitleCandidatesByStreamKey: [URL: [SubtitleCandidate]] = [:]
private var currentNativePlaybackKey: URL?
private weak var currentNativePlayer: NativePlayerViewController?
private let streamResolver: StreamResolving = StremioStreamResolver()
@ -587,17 +589,33 @@ final class DreamioWebViewController: UIViewController {
let duplicateKey = request.resolverURL ?? request.playbackURL
if lastNativePlaybackURL == duplicateKey {
mergeSubtitleCandidates(candidate.subtitleCandidates, for: duplicateKey)
return
}
lastNativePlaybackURL = duplicateKey
currentNativePlaybackKey = duplicateKey
mergeSubtitleCandidates(request.subtitleCandidates, for: duplicateKey)
let mergedSubtitleCandidates = subtitleCandidates(for: duplicateKey)
#if DEBUG
let classification = request.classification
print("[DreamioStream] class=\(classification.sourceKind.rawValue) container=\(classification.containerGuess.rawValue) reason=\(classification.reason) subtitles=\(request.subtitleCandidates.count) observed=\(classification.sanitizedObservedURL) resolver=\(classification.sanitizedResolverURL ?? "none")")
print("[DreamioStream] class=\(classification.sourceKind.rawValue) container=\(classification.containerGuess.rawValue) reason=\(classification.reason) subtitles=\(mergedSubtitleCandidates.count) observed=\(classification.sanitizedObservedURL) resolver=\(classification.sanitizedResolverURL ?? "none")")
#endif
let playbackRequest = NativePlaybackRequest(
playbackURL: request.playbackURL,
observedURL: request.observedURL,
resolverURL: request.resolverURL,
pageURL: request.pageURL,
userAgent: request.userAgent,
referer: request.referer,
headers: request.headers,
classification: request.classification,
subtitleCandidates: mergedSubtitleCandidates
)
Task { [weak self] in
await self?.resolveAndPresentNativePlayback(request)
await self?.resolveAndPresentNativePlayback(playbackRequest, streamKey: duplicateKey)
}
}
@ -606,12 +624,17 @@ final class DreamioWebViewController: UIViewController {
return
}
let streamKey = currentNativePlaybackKey ?? lastNativePlaybackURL
if let streamKey {
mergeSubtitleCandidates(candidates, for: streamKey)
}
#if DEBUG
print("[DreamioSubtitles] native discovered=\(candidates.count) playerActive=\(currentNativePlayer != nil) candidates=\(SubtitleDebugFormatter.candidateSummary(candidates))")
print("[DreamioSubtitles] native discovered=\(candidates.count) playerActive=\(currentNativePlayer != nil) streamKey=\(streamKey.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none") candidates=\(SubtitleDebugFormatter.candidateSummary(candidates))")
#endif
guard let currentNativePlayer else {
#if DEBUG
print("[DreamioSubtitles] discovered=\(candidates.count) forwarded=0 reason=no-active-native-player")
print("[DreamioSubtitles] discovered=\(candidates.count) forwarded=0 reason=no-active-native-player buffered=\(streamKey != nil)")
#endif
return
}
@ -623,9 +646,10 @@ final class DreamioWebViewController: UIViewController {
}
@MainActor
private func resolveAndPresentNativePlayback(_ request: NativePlaybackRequest) async {
private func resolveAndPresentNativePlayback(_ request: NativePlaybackRequest, streamKey: URL) async {
guard VLCNativePlaybackBackend.isAvailable else {
lastNativePlaybackURL = nil
currentNativePlaybackKey = nil
showNativePlaybackUnavailableAlert()
return
}
@ -644,25 +668,72 @@ final class DreamioWebViewController: UIViewController {
referer: request.referer,
headers: resolved.headers,
classification: request.classification,
subtitleCandidates: request.subtitleCandidates
subtitleCandidates: subtitleCandidates(for: streamKey)
)
let player = NativePlayerViewController(request: resolvedRequest)
currentNativePlayer = player
player.onDismiss = { [weak self] in
self?.lastNativePlaybackURL = nil
self?.currentNativePlaybackKey = nil
self?.currentNativePlayer = nil
self?.pendingSubtitleCandidatesByStreamKey.removeValue(forKey: streamKey)
self?.cleanUpStremioPlayerAfterNativeDismiss()
}
present(player, animated: true)
present(player, animated: true) { [weak self, weak player] in
guard let self, let player else {
return
}
self.currentNativePlayer = player
let lateBufferedCandidates = self.subtitleCandidates(for: streamKey)
let forwarded = player.addSubtitleCandidates(lateBufferedCandidates)
#if DEBUG
print("[DreamioSubtitles] presented buffered=\(lateBufferedCandidates.count) forwarded=\(forwarded) streamKey=\(URLRedactor.redactedURLString(streamKey.absoluteString))")
#endif
}
} catch {
#if DEBUG
print("[DreamioStreamResolver] failure=\(URLRedactor.redactedURLString(error.localizedDescription)) resolver=\(request.resolverURL.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none")")
#endif
lastNativePlaybackURL = nil
currentNativePlaybackKey = nil
pendingSubtitleCandidatesByStreamKey.removeValue(forKey: streamKey)
showNativePlaybackResolutionFailure(error)
}
}
private func mergeSubtitleCandidates(_ candidates: [SubtitleCandidate], for streamKey: URL) {
guard !candidates.isEmpty else {
return
}
let existing = pendingSubtitleCandidatesByStreamKey[streamKey] ?? []
pendingSubtitleCandidatesByStreamKey[streamKey] = Self.mergedSubtitleCandidates(existing + candidates)
}
private func subtitleCandidates(for streamKey: URL) -> [SubtitleCandidate] {
pendingSubtitleCandidatesByStreamKey[streamKey] ?? []
}
private static func mergedSubtitleCandidates(_ candidates: [SubtitleCandidate]) -> [SubtitleCandidate] {
var orderedKeys: [String] = []
var bestByURL: [String: SubtitleCandidate] = [:]
candidates.forEach { candidate in
let key = candidate.url.absoluteString
if bestByURL[key] == nil {
orderedKeys.append(key)
bestByURL[key] = candidate
} else if let current = bestByURL[key],
subtitleCandidateScore(candidate) > subtitleCandidateScore(current) {
bestByURL[key] = candidate
}
}
return orderedKeys.compactMap { bestByURL[$0] }
}
private static func subtitleCandidateScore(_ candidate: SubtitleCandidate) -> Int {
let hasUsefulLabel = !candidate.label.isEmpty && candidate.label != candidate.url.deletingPathExtension().lastPathComponent
return (hasUsefulLabel ? 2 : 0) + ((candidate.language?.isEmpty == false) ? 1 : 0)
}
private func showNativePlaybackResolutionFailure(_ error: Error) {
let alert = UIAlertController(
title: "Could not open stream",

View file

@ -30,10 +30,6 @@ protocol NativePlaybackBackend: AnyObject {
func stop()
}
protocol SubtitleResolving {
func resolve(_ candidate: SubtitleCandidate) async -> SubtitleCandidate?
}
enum NativePlaybackError: LocalizedError {
case backendUnavailable
case startupTimedOut

View file

@ -134,37 +134,58 @@ enum SubtitleCandidateParser {
private static let supportedExtensions = ["srt", "vtt", "ass", "ssa", "sub"]
private static let urlFields = ["url", "href", "src", "link", "subtitles", "subtitle", "subtitleUrl", "subtitleURL", "file", "download"]
private static let labelFields = ["label", "name", "title", "file_name", "lang", "language", "id"]
private struct CandidateContext {
let label: String?
let language: String?
func merged(with dictionary: [String: Any]) -> CandidateContext {
let label = Self.firstString(in: dictionary, fields: labelFields) ?? self.label
let language = (dictionary["lang"] as? String)
?? (dictionary["language"] as? String)
?? self.language
return CandidateContext(label: label, language: language)
}
private static func firstString(in dictionary: [String: Any], fields: [String]) -> String? {
fields.lazy.compactMap { dictionary[$0] as? String }.first { !$0.isEmpty }
}
}
static func candidates(in payload: Any?) -> [SubtitleCandidate] {
var results: [SubtitleCandidate] = []
collect(from: payload, into: &results)
collect(from: payload, context: CandidateContext(label: nil, language: nil), into: &results)
var seen = Set<String>()
return results.filter { candidate in
var orderedKeys: [String] = []
var bestByURL: [String: SubtitleCandidate] = [:]
results.forEach { candidate in
let key = candidate.url.absoluteString
guard !seen.contains(key) else {
return false
if bestByURL[key] == nil {
orderedKeys.append(key)
bestByURL[key] = candidate
} else if let current = bestByURL[key],
candidateScore(candidate) > candidateScore(current) {
bestByURL[key] = candidate
}
seen.insert(key)
return true
}
return orderedKeys.compactMap { bestByURL[$0] }
}
private static func collect(from value: Any?, into results: inout [SubtitleCandidate]) {
private static func collect(from value: Any?, context: CandidateContext, into results: inout [SubtitleCandidate]) {
switch value {
case let dictionary as [String: Any]:
if let candidate = candidate(from: dictionary) {
let nextContext = context.merged(with: dictionary)
if let candidate = candidate(from: dictionary, context: nextContext) {
results.append(candidate)
}
orderedNestedValues(in: dictionary).forEach { collect(from: $0, into: &results) }
orderedNestedValues(in: dictionary).forEach { collect(from: $0, context: nextContext, into: &results) }
case let array as [Any]:
array.forEach { collect(from: $0, into: &results) }
array.forEach { collect(from: $0, context: context, into: &results) }
case let string as String:
if let url = subtitleURL(from: string) {
results.append(SubtitleCandidate(url: url, label: defaultLabel(for: url), language: nil))
results.append(SubtitleCandidate(url: url, label: context.label ?? defaultLabel(for: url), language: context.language))
} else {
extractSubtitleURLs(from: string).forEach { url in
results.append(SubtitleCandidate(url: url, label: defaultLabel(for: url), language: nil))
results.append(SubtitleCandidate(url: url, label: context.label ?? defaultLabel(for: url), language: context.language))
}
}
default:
@ -172,7 +193,7 @@ enum SubtitleCandidateParser {
}
}
private static func candidate(from dictionary: [String: Any]) -> SubtitleCandidate? {
private static func candidate(from dictionary: [String: Any], context: CandidateContext) -> SubtitleCandidate? {
guard let url = urlFields.lazy.compactMap({ subtitleURL(from: dictionary[$0] as? String) }).first else {
return nil
}
@ -181,11 +202,17 @@ enum SubtitleCandidateParser {
let language = (dictionary["lang"] as? String) ?? (dictionary["language"] as? String)
return SubtitleCandidate(
url: url,
label: label?.isEmpty == false ? label! : defaultLabel(for: url),
language: language
label: label?.isEmpty == false ? label! : (context.label ?? defaultLabel(for: url)),
language: language ?? context.language
)
}
private static func candidateScore(_ candidate: SubtitleCandidate) -> Int {
let defaultLabel = defaultLabel(for: candidate.url)
let hasUsefulLabel = !candidate.label.isEmpty && candidate.label != defaultLabel
return (hasUsefulLabel ? 2 : 0) + ((candidate.language?.isEmpty == false) ? 1 : 0)
}
private static func orderedNestedValues(in dictionary: [String: Any]) -> [Any] {
let preferredKeys = ["subtitles", "subtitle", "files", "downloads", "download"]
var visitedKeys = Set<String>()

View file

@ -6,6 +6,10 @@ struct ResolvedNativeStream {
let source: String
}
protocol SubtitleResolving {
func resolve(_ candidate: SubtitleCandidate) async -> SubtitleCandidate?
}
enum StreamResolverError: LocalizedError {
case noResolverURL
case httpStatus(Int)
@ -47,6 +51,9 @@ final class SubtitleResolver: SubtitleResolving {
var request = URLRequest(url: candidate.url)
request.setValue("application/json, text/plain, text/vtt, application/x-subrip, */*", forHTTPHeaderField: "Accept")
StreamClassifier.defaultHeaders(userAgent: nil).forEach { key, value in
request.setValue(value, forHTTPHeaderField: key)
}
do {
let (data, response) = try await session.data(for: request)
@ -66,7 +73,12 @@ final class SubtitleResolver: SubtitleResolving {
from: data,
responseURL: response.url,
original: candidate
)
).map { resolved in
#if DEBUG
print("[DreamioSubtitles] resolved candidate from=\(URLRedactor.redactedURLString(candidate.url.absoluteString)) to=\(URLRedactor.redactedURLString(resolved.url.absoluteString))")
#endif
return resolved
} ?? Self.logRejected(candidate, responseURL: response.url, data: data)
} catch {
#if DEBUG
print("[DreamioSubtitles] resolve failure=\(URLRedactor.redactedURLString(error.localizedDescription)) url=\(URLRedactor.redactedURLString(candidate.url.absoluteString))")
@ -127,6 +139,24 @@ final class SubtitleResolver: SubtitleResolving {
|| lowercased.contains("/subtitle")
|| lowercased.contains("subtitle")
}
private static func logRejected(_ candidate: SubtitleCandidate, responseURL: URL?, data: Data) -> SubtitleCandidate? {
#if DEBUG
let responseDescription = responseURL.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none"
let bodyKind: String
if data.isEmpty {
bodyKind = "empty"
} else if (try? JSONSerialization.jsonObject(with: data)) != nil {
bodyKind = "json-without-direct-subtitle"
} else if String(data: data, encoding: .utf8) != nil {
bodyKind = "text-without-direct-subtitle"
} else {
bodyKind = "unreadable"
}
print("[DreamioSubtitles] rejected candidate reason=\(bodyKind) url=\(URLRedactor.redactedURLString(candidate.url.absoluteString)) responseURL=\(responseDescription)")
#endif
return nil
}
}
protocol StreamResolving {

View file

@ -246,13 +246,15 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
guard attachedCount > 0 else {
return attachedCount
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
self?.selectInitialSubtitleTrackIfNeeded(reason: "delayed-refresh")
[0.2, 0.6, 1.0, 2.0, 4.0].forEach { delay in
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.selectInitialSubtitleTrackIfNeeded(reason: "delayed-refresh-\(String(format: "%.1f", delay))")
#if DEBUG
self?.logSubtitleTracks(reason: "delayed-refresh")
self?.logSubtitleTracks(reason: "delayed-refresh-\(String(format: "%.1f", delay))")
#endif
self?.onSubtitleTracksChange?()
}
}
return attachedCount
}
@ -300,11 +302,13 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
}
let selectedTrackID = mediaPlayer.currentVideoSubTitleIndex
guard selectedTrackID != trackID || shouldLogNoop else {
guard selectedTrackID < 0 || (selectedTrackID == trackID && shouldLogNoop) else {
return
}
if selectedTrackID < 0 {
mediaPlayer.currentVideoSubTitleIndex = trackID
}
#if DEBUG
let action = selectedTrackID == trackID ? "confirm" : "recover"
print("[DreamioVLC] reapply subtitle id=\(trackID) reason=\(reason) action=\(action) selected=\(mediaPlayer.currentVideoSubTitleIndex)")

View file

@ -2,7 +2,7 @@ import Foundation
@main
struct StreamResolverTests {
static func main() {
static func main() async {
testClassifierPrefersObservedDirectFile()
testResolverSelectsUnsupportedDirectURLAndHeaders()
testResolverRejectsHLSOnlyResponse()
@ -11,7 +11,11 @@ struct StreamResolverTests {
testSubtitleCandidateParsing()
testOpenSubtitlesV3CandidateParsing()
testOpenSubtitlesV3DownloadResponseResolution()
await testSubtitleResolverDownloadJSONReturningLink()
await testSubtitleResolverRedirectToDirectSubtitle()
await testSubtitleResolverRejectsNonSubtitleAPIResponse()
testSubtitleCandidateDeduplicationPreservesLabels()
testSubtitleCandidateDeduplicationUpgradesLabels()
testSubtitleOptionMappingIncludesNone()
print("StreamResolverTests passed")
}
@ -143,6 +147,8 @@ struct StreamResolverTests {
assertEqual(candidates[0].label, "English")
assertEqual(candidates[0].language, "English")
assertEqual(candidates[1].url.absoluteString, "https://dl.opensubtitles.org/en/subtitle.vtt?download=1")
assertEqual(candidates[1].label, "English")
assertEqual(candidates[1].language, "English")
assertEqual(candidates[2].label, "spa")
assertEqual(candidates[2].language, "spa")
assertEqual(candidates[3].url.absoluteString, "https://cdn.example.test/from-string.ass?source=opensubtitles")
@ -173,6 +179,62 @@ struct StreamResolverTests {
assertEqual(candidate?.language, "eng")
}
private static func testSubtitleResolverDownloadJSONReturningLink() async {
MockURLProtocol.handlers = [
"https://api.opensubtitles.com/api/v1/download/123": (
200,
URL(string: "https://api.opensubtitles.com/api/v1/download/123")!,
#"{"link":"https://dl.opensubtitles.org/en/download/movie.srt?token=secret"}"#.data(using: .utf8)!
)
]
let resolver = SubtitleResolver(session: mockSession())
let candidate = await resolver.resolve(SubtitleCandidate(
url: URL(string: "https://api.opensubtitles.com/api/v1/download/123")!,
label: "English",
language: "eng"
))
assertEqual(candidate?.url.absoluteString, "https://dl.opensubtitles.org/en/download/movie.srt?token=secret")
assertEqual(candidate?.label, "English")
assertEqual(candidate?.language, "eng")
}
private static func testSubtitleResolverRedirectToDirectSubtitle() async {
MockURLProtocol.handlers = [
"https://api.opensubtitles.com/api/v1/download/redirect": (
200,
URL(string: "https://dl.opensubtitles.org/en/redirected.vtt?download=1")!,
Data()
)
]
let resolver = SubtitleResolver(session: mockSession())
let candidate = await resolver.resolve(SubtitleCandidate(
url: URL(string: "https://api.opensubtitles.com/api/v1/download/redirect")!,
label: "English",
language: "eng"
))
assertEqual(candidate?.url.absoluteString, "https://dl.opensubtitles.org/en/redirected.vtt?download=1")
}
private static func testSubtitleResolverRejectsNonSubtitleAPIResponse() async {
MockURLProtocol.handlers = [
"https://api.opensubtitles.com/api/v1/download/not-found": (
200,
URL(string: "https://api.opensubtitles.com/api/v1/download/not-found")!,
#"{"message":"not found"}"#.data(using: .utf8)!
)
]
let resolver = SubtitleResolver(session: mockSession())
let candidate = await resolver.resolve(SubtitleCandidate(
url: URL(string: "https://api.opensubtitles.com/api/v1/download/not-found")!,
label: "English",
language: "eng"
))
assert(candidate == nil, "Expected non-subtitle API response to be rejected")
}
private static func testSubtitleCandidateDeduplicationPreservesLabels() {
let payload: [String: Any] = [
"subtitles": [
@ -197,6 +259,25 @@ struct StreamResolverTests {
assertEqual(candidates[0].language, "eng")
}
private static func testSubtitleCandidateDeduplicationUpgradesLabels() {
let payload: [String: Any] = [
"subtitles": [
"https://opensubtitles.example.test/download/duplicate.srt",
[
"label": "English SDH",
"lang": "eng",
"url": "https://opensubtitles.example.test/download/duplicate.srt"
]
]
]
let candidates = SubtitleCandidateParser.candidates(in: payload)
assertEqual(candidates.count, 1)
assertEqual(candidates[0].label, "English SDH")
assertEqual(candidates[0].language, "eng")
}
private static func testSubtitleOptionMappingIncludesNone() {
let options = SubtitleOptionMapper.options(from: [
SubtitleTrack(id: 2, name: "English"),
@ -210,4 +291,43 @@ struct StreamResolverTests {
private static func assertEqual<T: Equatable>(_ actual: T?, _ expected: T, file: StaticString = #file, line: UInt = #line) {
assert(actual == expected, "Expected \(String(describing: expected)), got \(String(describing: actual))", file: file, line: line)
}
private static func mockSession() -> URLSession {
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [MockURLProtocol.self]
return URLSession(configuration: configuration)
}
}
private final class MockURLProtocol: URLProtocol {
static var handlers: [String: (status: Int, url: URL, data: Data)] = [:]
override class func canInit(with request: URLRequest) -> Bool {
true
}
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
request
}
override func startLoading() {
guard let url = request.url,
let handler = Self.handlers[url.absoluteString],
let response = HTTPURLResponse(
url: handler.url,
statusCode: handler.status,
httpVersion: "HTTP/1.1",
headerFields: nil
)
else {
client?.urlProtocol(self, didFailWithError: URLError(.badURL))
return
}
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: handler.data)
client?.urlProtocolDidFinishLoading(self)
}
override func stopLoading() {}
}

File diff suppressed because one or more lines are too long