From 128b9518a5362ccdd61d7e02a3bf69fc6bcf0bcf Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Mon, 25 May 2026 18:55:48 -0400 Subject: [PATCH] cache stremio subtitles before vlc attach --- Dreamio/StreamResolver.swift | 81 +++++++- Tests/StreamResolverTests.swift | 44 ++++- ...queue-vlc-subtitles-until-media-start.html | 184 +++++++++++++++++- 3 files changed, 301 insertions(+), 8 deletions(-) diff --git a/Dreamio/StreamResolver.swift b/Dreamio/StreamResolver.swift index ffba9fd..55e790e 100644 --- a/Dreamio/StreamResolver.swift +++ b/Dreamio/StreamResolver.swift @@ -35,9 +35,14 @@ enum StreamResolverError: LocalizedError { final class SubtitleResolver: SubtitleResolving { private let session: URLSession + private let cacheDirectory: URL - init(session: URLSession = .shared) { + init( + session: URLSession = .shared, + cacheDirectory: URL = FileManager.default.temporaryDirectory.appendingPathComponent("DreamioSubtitles", isDirectory: true) + ) { self.session = session + self.cacheDirectory = cacheDirectory } func resolve(_ candidate: SubtitleCandidate) async -> SubtitleCandidate? { @@ -69,6 +74,10 @@ final class SubtitleResolver: SubtitleResolving { return SubtitleCandidate(url: finalURL, label: candidate.label, language: candidate.language) } + if let cachedCandidate = cacheSubtitleDataIfNeeded(data, original: candidate) { + return cachedCandidate + } + return Self.bestPlayableCandidate( from: data, responseURL: response.url, @@ -87,6 +96,31 @@ final class SubtitleResolver: SubtitleResolving { } } + private func cacheSubtitleDataIfNeeded(_ data: Data, original: SubtitleCandidate) -> SubtitleCandidate? { + guard let subtitleType = Self.subtitleType(in: data) else { + return nil + } + + do { + try FileManager.default.createDirectory( + at: cacheDirectory, + withIntermediateDirectories: true + ) + let filename = "\(UUID().uuidString).\(subtitleType.fileExtension)" + let fileURL = cacheDirectory.appendingPathComponent(filename) + try data.write(to: fileURL, options: .atomic) +#if DEBUG + print("[DreamioSubtitles] cached subtitle url=\(URLRedactor.redactedURLString(original.url.absoluteString)) file=\(fileURL.lastPathComponent)") +#endif + return SubtitleCandidate(url: fileURL, label: original.label, language: original.language) + } catch { +#if DEBUG + print("[DreamioSubtitles] cache failure=\(error.localizedDescription) url=\(URLRedactor.redactedURLString(original.url.absoluteString))") +#endif + return nil + } + } + static func bestPlayableCandidate( from data: Data, responseURL: URL?, @@ -131,7 +165,6 @@ final class SubtitleResolver: SubtitleResolving { 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) - || isStremioSubtitleDownloadURL(url) } private static func shouldResolve(_ url: URL) -> Bool { @@ -154,6 +187,50 @@ final class SubtitleResolver: SubtitleResolving { || path.range(of: #"(^|/)download(/|$)"#, options: .regularExpression) != nil } + private enum SubtitlePayloadType { + case srt + case vtt + case ass + + var fileExtension: String { + switch self { + case .srt: + return "srt" + case .vtt: + return "vtt" + case .ass: + return "ass" + } + } + } + + private static func subtitleType(in data: Data) -> SubtitlePayloadType? { + guard !data.isEmpty, + let text = String(data: data.prefix(4096), encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !text.isEmpty + else { + return nil + } + + let lowercased = text.lowercased() + if lowercased.hasPrefix("webvtt") { + return .vtt + } + if lowercased.hasPrefix("[script info]") + || lowercased.contains("\n[events]") + || lowercased.contains("\r\n[events]") { + return .ass + } + if lowercased.range( + of: #"(?m)^\d+\s*[\r\n]+(?:\d{1,2}:)?\d{2}:\d{2}[,.]\d{3}\s*-->\s*(?:\d{1,2}:)?\d{2}:\d{2}[,.]\d{3}"#, + options: .regularExpression + ) != nil { + return .srt + } + return nil + } + private static func logRejected(_ candidate: SubtitleCandidate, responseURL: URL?, data: Data) -> SubtitleCandidate? { #if DEBUG let responseDescription = responseURL.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none" diff --git a/Tests/StreamResolverTests.swift b/Tests/StreamResolverTests.swift index dfb0e70..a513abb 100644 --- a/Tests/StreamResolverTests.swift +++ b/Tests/StreamResolverTests.swift @@ -16,6 +16,7 @@ struct StreamResolverTests { testStremioSubtitleDownloadURLParsing() testOpenSubtitlesV3DownloadResponseResolution() testOpenSubtitlesNestedDownloadResponseResolution() + await testSubtitleResolverCachesStremioDownloadBody() await testSubtitleResolverDownloadJSONReturningLink() await testSubtitleResolverRedirectToDirectSubtitle() await testSubtitleResolverRejectsNonSubtitleAPIResponse() @@ -270,7 +271,7 @@ struct StreamResolverTests { assertEqual(candidates[0].url.absoluteString, "https://subs5.strem.io/en/download/subencoding-stremio-utf8/src-api/file/1952341941") assertEqual(candidates[0].label, "English") assertEqual(candidates[0].language, "eng") - assert(SubtitleResolver.isDirectSubtitleFile(candidates[0].url), "Expected Stremio subtitle downloads to be attachable without another resolver hop") + assert(!SubtitleResolver.isDirectSubtitleFile(candidates[0].url), "Expected Stremio subtitle downloads to be resolved before VLC attachment") } private static func testContentRangeParsing() { @@ -437,7 +438,46 @@ struct StreamResolverTests { assertEqual(candidate?.language, "eng") } + private static func testSubtitleResolverCachesStremioDownloadBody() async { + let sourceURL = "https://subs5.strem.io/en/download/subencoding-stremio-utf8/src-api/file/1952341941" + let subtitleBody = """ + 1 + 00:00:01,000 --> 00:00:02,000 + Hello from Stremio + + """ + MockURLProtocol.handler = nil + MockURLProtocol.handlers = [ + sourceURL: ( + 200, + URL(string: sourceURL)!, + subtitleBody.data(using: .utf8)! + ) + ] + + let cacheDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("DreamioSubtitleResolverTests-\(UUID().uuidString)", isDirectory: true) + defer { + try? FileManager.default.removeItem(at: cacheDirectory) + } + + let resolver = SubtitleResolver(session: mockSession(), cacheDirectory: cacheDirectory) + let candidate = await resolver.resolve(SubtitleCandidate( + url: URL(string: sourceURL)!, + label: "English", + language: "eng" + )) + + assertEqual(candidate?.url.isFileURL, true) + assertEqual(candidate?.url.pathExtension, "srt") + assertEqual(candidate?.label, "English") + assertEqual(candidate?.language, "eng") + let cachedBody = try? String(contentsOf: candidate!.url, encoding: .utf8) + assertEqual(cachedBody, subtitleBody) + } + private static func testSubtitleResolverDownloadJSONReturningLink() async { + MockURLProtocol.handler = nil MockURLProtocol.handlers = [ "https://api.opensubtitles.com/api/v1/download/123": ( 200, @@ -458,6 +498,7 @@ struct StreamResolverTests { } private static func testSubtitleResolverRedirectToDirectSubtitle() async { + MockURLProtocol.handler = nil MockURLProtocol.handlers = [ "https://api.opensubtitles.com/api/v1/download/redirect": ( 200, @@ -476,6 +517,7 @@ struct StreamResolverTests { } private static func testSubtitleResolverRejectsNonSubtitleAPIResponse() async { + MockURLProtocol.handler = nil MockURLProtocol.handlers = [ "https://api.opensubtitles.com/api/v1/download/not-found": ( 200, diff --git a/docs/turns/2026-05-25-queue-vlc-subtitles-until-media-start.html b/docs/turns/2026-05-25-queue-vlc-subtitles-until-media-start.html index 024f87c..9d94d10 100644 --- a/docs/turns/2026-05-25-queue-vlc-subtitles-until-media-start.html +++ b/docs/turns/2026-05-25-queue-vlc-subtitles-until-media-start.html @@ -153,7 +153,7 @@
Dreamio/StreamResolver.swift
-2+79
34 unmodified lines
35
36
37
38
39
40
41
42
43
25 unmodified lines
69
70
71
72
73
74
12 unmodified lines
87
88
89
90
91
92
38 unmodified lines
131
132
133
134
135
136
137
16 unmodified lines
154
155
156
157
158
159
34 unmodified lines
+
final class SubtitleResolver: SubtitleResolving {
private let session: URLSession
+
init(session: URLSession = .shared) {
self.session = session
}
+
func resolve(_ candidate: SubtitleCandidate) async -> SubtitleCandidate? {
25 unmodified lines
return SubtitleCandidate(url: finalURL, label: candidate.label, language: candidate.language)
}
+
return Self.bestPlayableCandidate(
from: data,
responseURL: response.url,
12 unmodified lines
}
}
+
static func bestPlayableCandidate(
from data: Data,
responseURL: URL?,
38 unmodified lines
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)
|| isStremioSubtitleDownloadURL(url)
}
+
private static func shouldResolve(_ url: URL) -> Bool {
16 unmodified lines
|| path.range(of: #"(^|/)download(/|$)"#, options: .regularExpression) != nil
}
+
private static func logRejected(_ candidate: SubtitleCandidate, responseURL: URL?, data: Data) -> SubtitleCandidate? {
#if DEBUG
let responseDescription = responseURL.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none"
34 unmodified lines
35
36
37
38
39
40
41
42
43
44
45
46
47
48
25 unmodified lines
74
75
76
77
78
79
80
81
82
83
12 unmodified lines
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
38 unmodified lines
165
166
167
168
169
170
16 unmodified lines
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
34 unmodified lines
+
final class SubtitleResolver: SubtitleResolving {
private let session: URLSession
private let cacheDirectory: URL
+
init(
session: URLSession = .shared,
cacheDirectory: URL = FileManager.default.temporaryDirectory.appendingPathComponent("DreamioSubtitles", isDirectory: true)
) {
self.session = session
self.cacheDirectory = cacheDirectory
}
+
func resolve(_ candidate: SubtitleCandidate) async -> SubtitleCandidate? {
25 unmodified lines
return SubtitleCandidate(url: finalURL, label: candidate.label, language: candidate.language)
}
+
if let cachedCandidate = cacheSubtitleDataIfNeeded(data, original: candidate) {
return cachedCandidate
}
+
return Self.bestPlayableCandidate(
from: data,
responseURL: response.url,
12 unmodified lines
}
}
+
private func cacheSubtitleDataIfNeeded(_ data: Data, original: SubtitleCandidate) -> SubtitleCandidate? {
guard let subtitleType = Self.subtitleType(in: data) else {
return nil
}
+
do {
try FileManager.default.createDirectory(
at: cacheDirectory,
withIntermediateDirectories: true
)
let filename = "\(UUID().uuidString).\(subtitleType.fileExtension)"
let fileURL = cacheDirectory.appendingPathComponent(filename)
try data.write(to: fileURL, options: .atomic)
#if DEBUG
print("[DreamioSubtitles] cached subtitle url=\(URLRedactor.redactedURLString(original.url.absoluteString)) file=\(fileURL.lastPathComponent)")
#endif
return SubtitleCandidate(url: fileURL, label: original.label, language: original.language)
} catch {
#if DEBUG
print("[DreamioSubtitles] cache failure=\(error.localizedDescription) url=\(URLRedactor.redactedURLString(original.url.absoluteString))")
#endif
return nil
}
}
+
static func bestPlayableCandidate(
from data: Data,
responseURL: URL?,
38 unmodified lines
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 {
16 unmodified lines
|| path.range(of: #"(^|/)download(/|$)"#, options: .regularExpression) != nil
}
+
private enum SubtitlePayloadType {
case srt
case vtt
case ass
+
var fileExtension: String {
switch self {
case .srt:
return "srt"
case .vtt:
return "vtt"
case .ass:
return "ass"
}
}
}
+
private static func subtitleType(in data: Data) -> SubtitlePayloadType? {
guard !data.isEmpty,
let text = String(data: data.prefix(4096), encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty
else {
return nil
}
+
let lowercased = text.lowercased()
if lowercased.hasPrefix("webvtt") {
return .vtt
}
if lowercased.hasPrefix("[script info]")
|| lowercased.contains("\n[events]")
|| lowercased.contains("\r\n[events]") {
return .ass
}
if lowercased.range(
of: #"(?m)^\d+\s*[\r\n]+(?:\d{1,2}:)?\d{2}:\d{2}[,.]\d{3}\s*-->\s*(?:\d{1,2}:)?\d{2}:\d{2}[,.]\d{3}"#,
options: .regularExpression
) != nil {
return .srt
}
return nil
}
+
private static func logRejected(_ candidate: SubtitleCandidate, responseURL: URL?, data: Data) -> SubtitleCandidate? {
#if DEBUG
let responseDescription = responseURL.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none"
Tests/StreamResolverTests.swift
-1+43
15 unmodified lines
16
17
18
19
20
21
248 unmodified lines
270
271
272
273
274
275
276
160 unmodified lines
437
438
439
440
441
442
443
14 unmodified lines
458
459
460
461
462
463
12 unmodified lines
476
477
478
479
480
481
15 unmodified lines
testStremioSubtitleDownloadURLParsing()
testOpenSubtitlesV3DownloadResponseResolution()
testOpenSubtitlesNestedDownloadResponseResolution()
await testSubtitleResolverDownloadJSONReturningLink()
await testSubtitleResolverRedirectToDirectSubtitle()
await testSubtitleResolverRejectsNonSubtitleAPIResponse()
248 unmodified lines
assertEqual(candidates[0].url.absoluteString, "https://subs5.strem.io/en/download/subencoding-stremio-utf8/src-api/file/1952341941")
assertEqual(candidates[0].label, "English")
assertEqual(candidates[0].language, "eng")
assert(SubtitleResolver.isDirectSubtitleFile(candidates[0].url), "Expected Stremio subtitle downloads to be attachable without another resolver hop")
}
+
private static func testContentRangeParsing() {
160 unmodified lines
assertEqual(candidate?.language, "eng")
}
+
private static func testSubtitleResolverDownloadJSONReturningLink() async {
MockURLProtocol.handlers = [
"https://api.opensubtitles.com/api/v1/download/123": (
200,
14 unmodified lines
}
+
private static func testSubtitleResolverRedirectToDirectSubtitle() async {
MockURLProtocol.handlers = [
"https://api.opensubtitles.com/api/v1/download/redirect": (
200,
12 unmodified lines
}
+
private static func testSubtitleResolverRejectsNonSubtitleAPIResponse() async {
MockURLProtocol.handlers = [
"https://api.opensubtitles.com/api/v1/download/not-found": (
200,
15 unmodified lines
16
17
18
19
20
21
22
248 unmodified lines
271
272
273
274
275
276
277
160 unmodified lines
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
14 unmodified lines
498
499
500
501
502
503
504
12 unmodified lines
517
518
519
520
521
522
523
15 unmodified lines
testStremioSubtitleDownloadURLParsing()
testOpenSubtitlesV3DownloadResponseResolution()
testOpenSubtitlesNestedDownloadResponseResolution()
await testSubtitleResolverCachesStremioDownloadBody()
await testSubtitleResolverDownloadJSONReturningLink()
await testSubtitleResolverRedirectToDirectSubtitle()
await testSubtitleResolverRejectsNonSubtitleAPIResponse()
248 unmodified lines
assertEqual(candidates[0].url.absoluteString, "https://subs5.strem.io/en/download/subencoding-stremio-utf8/src-api/file/1952341941")
assertEqual(candidates[0].label, "English")
assertEqual(candidates[0].language, "eng")
assert(!SubtitleResolver.isDirectSubtitleFile(candidates[0].url), "Expected Stremio subtitle downloads to be resolved before VLC attachment")
}
+
private static func testContentRangeParsing() {
160 unmodified lines
assertEqual(candidate?.language, "eng")
}
+
private static func testSubtitleResolverCachesStremioDownloadBody() async {
let sourceURL = "https://subs5.strem.io/en/download/subencoding-stremio-utf8/src-api/file/1952341941"
let subtitleBody = """
1
00:00:01,000 --> 00:00:02,000
Hello from Stremio
+
"""
MockURLProtocol.handler = nil
MockURLProtocol.handlers = [
sourceURL: (
200,
URL(string: sourceURL)!,
subtitleBody.data(using: .utf8)!
)
]
+
let cacheDirectory = FileManager.default.temporaryDirectory
.appendingPathComponent("DreamioSubtitleResolverTests-\(UUID().uuidString)", isDirectory: true)
defer {
try? FileManager.default.removeItem(at: cacheDirectory)
}
+
let resolver = SubtitleResolver(session: mockSession(), cacheDirectory: cacheDirectory)
let candidate = await resolver.resolve(SubtitleCandidate(
url: URL(string: sourceURL)!,
label: "English",
language: "eng"
))
+
assertEqual(candidate?.url.isFileURL, true)
assertEqual(candidate?.url.pathExtension, "srt")
assertEqual(candidate?.label, "English")
assertEqual(candidate?.language, "eng")
let cachedBody = try? String(contentsOf: candidate!.url, encoding: .utf8)
assertEqual(cachedBody, subtitleBody)
}
+
private static func testSubtitleResolverDownloadJSONReturningLink() async {
MockURLProtocol.handler = nil
MockURLProtocol.handlers = [
"https://api.opensubtitles.com/api/v1/download/123": (
200,
14 unmodified lines
}
+
private static func testSubtitleResolverRedirectToDirectSubtitle() async {
MockURLProtocol.handler = nil
MockURLProtocol.handlers = [
"https://api.opensubtitles.com/api/v1/download/redirect": (
200,
12 unmodified lines
}
+
private static func testSubtitleResolverRejectsNonSubtitleAPIResponse() async {
MockURLProtocol.handler = nil
MockURLProtocol.handlers = [
"https://api.opensubtitles.com/api/v1/download/not-found": (
200,
+

Related issues or PRs

+

Related Beads issue: dreamio-771.

+ +

Expected Impact for End-Users

-

When an MKV stream opens through the real local range buffer, subtitles discovered before playback should still appear in the captions menu and be eligible for auto-selection once VLC exposes the track list.

+

When an MKV stream opens through the real local range buffer, subtitles discovered before playback should still appear in the captions menu and be eligible for auto-selection once VLC exposes the track list. Stremio subtitle downloads should now reach VLC as local subtitle files rather than extensionless provider URLs.

@@ -185,20 +357,22 @@
  • Ran git diff --check: passed.
  • Ran pod install to restore missing local CocoaPods support files in this worktree.
  • Ran xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -destination 'generic/platform=iOS Simulator' -quiet build: passed.
  • -
  • The build still reports the existing MobileVLCKit run-script output warning, but compilation succeeded.
  • +
  • Ran swiftc -parse-as-library -D DEBUG Dreamio/StreamCandidate.swift Dreamio/StreamResolver.swift Dreamio/ProgressiveHTTPRangeCache.swift Tests/StreamResolverTests.swift -o /tmp/StreamResolverTests && /tmp/StreamResolverTests: passed, including the new Stremio subtitle cache test.
  • +
  • The build still reports existing MobileVLCKit warnings about simulator deployment target and a run-script phase, but compilation succeeded.
  • Issues, Limitations, and Mitigations

    -

    This addresses the pre-media attachment race shown in the logs. It does not prove every remote subtitle download URL will be accepted by VLC after attachment; if a provider returns an unsupported payload or requires extra headers, that would need a separate resolver-level fix.

    +

    This addresses the pre-media attachment race shown in the logs and the follow-on issue where extensionless Stremio subtitle download URLs were accepted by VLC without visible tracks. If a provider returns a compressed archive, a non-UTF-8 payload, or a format outside SRT, VTT, ASS, SSA, and SUB, that would still need a separate resolver enhancement.

    Follow-up Work