diff --git a/Dreamio/StreamResolver.swift b/Dreamio/StreamResolver.swift index 55e790e..1ee8cbc 100644 --- a/Dreamio/StreamResolver.swift +++ b/Dreamio/StreamResolver.swift @@ -97,7 +97,7 @@ final class SubtitleResolver: SubtitleResolving { } private func cacheSubtitleDataIfNeeded(_ data: Data, original: SubtitleCandidate) -> SubtitleCandidate? { - guard let subtitleType = Self.subtitleType(in: data) else { + guard let subtitleType = Self.subtitleType(in: data, sourceURL: original.url) else { return nil } @@ -204,7 +204,7 @@ final class SubtitleResolver: SubtitleResolving { } } - private static func subtitleType(in data: Data) -> SubtitlePayloadType? { + private static func subtitleType(in data: Data, sourceURL: URL? = nil) -> SubtitlePayloadType? { guard !data.isEmpty, let text = String(data: data.prefix(4096), encoding: .utf8)? .trimmingCharacters(in: .whitespacesAndNewlines), @@ -228,9 +228,32 @@ final class SubtitleResolver: SubtitleResolving { ) != nil { return .srt } + if let sourceURL, + isStremioSubtitleDownloadURL(sourceURL), + isPlausiblePlainSubtitleText(text) { + return .srt + } return nil } + private static func isPlausiblePlainSubtitleText(_ text: String) -> Bool { + let lowercased = text.lowercased() + guard !lowercased.hasPrefix("{"), + !lowercased.hasPrefix("["), + !lowercased.hasPrefix("") + || lowercased.contains(" 1 + } + private static func logRejected(_ candidate: SubtitleCandidate, responseURL: URL?, data: Data) -> SubtitleCandidate? { #if DEBUG let responseDescription = responseURL.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none" @@ -244,10 +267,21 @@ final class SubtitleResolver: SubtitleResolving { } else { bodyKind = "unreadable" } - print("[DreamioSubtitles] rejected candidate reason=\(bodyKind) url=\(URLRedactor.redactedURLString(candidate.url.absoluteString)) responseURL=\(responseDescription)") + print("[DreamioSubtitles] rejected candidate reason=\(bodyKind) url=\(URLRedactor.redactedURLString(candidate.url.absoluteString)) responseURL=\(responseDescription) preview=\(rejectionPreview(data))") #endif return nil } + +#if DEBUG + private static func rejectionPreview(_ data: Data) -> String { + guard let text = String(data: data.prefix(180), encoding: .utf8) else { + return "unreadable" + } + return text + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + } +#endif } protocol StreamResolving { diff --git a/Tests/StreamResolverTests.swift b/Tests/StreamResolverTests.swift index a513abb..09986e0 100644 --- a/Tests/StreamResolverTests.swift +++ b/Tests/StreamResolverTests.swift @@ -17,6 +17,7 @@ struct StreamResolverTests { testOpenSubtitlesV3DownloadResponseResolution() testOpenSubtitlesNestedDownloadResponseResolution() await testSubtitleResolverCachesStremioDownloadBody() + await testSubtitleResolverCachesPlainStremioDownloadBody() await testSubtitleResolverDownloadJSONReturningLink() await testSubtitleResolverRedirectToDirectSubtitle() await testSubtitleResolverRejectsNonSubtitleAPIResponse() @@ -476,6 +477,41 @@ struct StreamResolverTests { assertEqual(cachedBody, subtitleBody) } + private static func testSubtitleResolverCachesPlainStremioDownloadBody() async { + let sourceURL = "https://subs5.strem.io/en/download/subencoding-stremio-utf8/src-api/file/1952341942" + let subtitleBody = """ + 00:01.000 --> 00:02.000 + Plain cue text without an index + + """ + 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") + let cachedBody = try? String(contentsOf: candidate!.url, encoding: .utf8) + assertEqual(cachedBody, subtitleBody) + } + private static func testSubtitleResolverDownloadJSONReturningLink() async { MockURLProtocol.handler = nil MockURLProtocol.handlers = [ 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 9d94d10..9132542 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 @@ -346,9 +346,166 @@

Related Beads issue: dreamio-771.

+ +
+

New Changes as of May 25, 2026 at 7:00 PM EDT

+

Summary of changes

+

Device logs showed Stremio subtitle downloads returning text, but the strict resolver detector rejected every body as text-without-direct-subtitle. The resolver now accepts plausible plain subtitle text from Stremio download endpoints and caches it as a local .srt file.

+

Why this change was made

+

The previous cache step only recognized classic indexed SRT, WebVTT, and ASS signatures. The live Stremio payloads appear to be subtitle-like text without matching that narrow signature, so they never reached VLC. This update keeps strict handling for general URLs while applying a provider-specific fallback for Stremio subtitle downloads.

+

Code diffs

+
Dreamio/StreamResolver.swift
-3+37
96 unmodified lines
97
98
99
100
101
102
103
100 unmodified lines
204
205
206
207
208
209
210
17 unmodified lines
228
229
230
231
232
233
234
235
236
7 unmodified lines
244
245
246
247
248
249
250
251
252
253
96 unmodified lines
}
+
private func cacheSubtitleDataIfNeeded(_ data: Data, original: SubtitleCandidate) -> SubtitleCandidate? {
guard let subtitleType = Self.subtitleType(in: data) else {
return nil
}
+
100 unmodified lines
}
}
+
private static func subtitleType(in data: Data) -> SubtitlePayloadType? {
guard !data.isEmpty,
let text = String(data: data.prefix(4096), encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines),
17 unmodified lines
) != 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"
7 unmodified lines
} else {
bodyKind = "unreadable"
}
print("[DreamioSubtitles] rejected candidate reason=\(bodyKind) url=\(URLRedactor.redactedURLString(candidate.url.absoluteString)) responseURL=\(responseDescription)")
#endif
return nil
}
}
+
protocol StreamResolving {
96 unmodified lines
97
98
99
100
101
102
103
100 unmodified lines
204
205
206
207
208
209
210
17 unmodified lines
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
7 unmodified lines
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
96 unmodified lines
}
+
private func cacheSubtitleDataIfNeeded(_ data: Data, original: SubtitleCandidate) -> SubtitleCandidate? {
guard let subtitleType = Self.subtitleType(in: data, sourceURL: original.url) else {
return nil
}
+
100 unmodified lines
}
}
+
private static func subtitleType(in data: Data, sourceURL: URL? = nil) -> SubtitlePayloadType? {
guard !data.isEmpty,
let text = String(data: data.prefix(4096), encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines),
17 unmodified lines
) != nil {
return .srt
}
if let sourceURL,
isStremioSubtitleDownloadURL(sourceURL),
isPlausiblePlainSubtitleText(text) {
return .srt
}
return nil
}
+
private static func isPlausiblePlainSubtitleText(_ text: String) -> Bool {
let lowercased = text.lowercased()
guard !lowercased.hasPrefix("{"),
!lowercased.hasPrefix("["),
!lowercased.hasPrefix("<!doctype"),
!lowercased.hasPrefix("<html"),
!lowercased.hasPrefix("<?xml")
else {
return false
}
+
return lowercased.contains("-->")
|| lowercased.contains("<font")
|| lowercased.contains("{\\")
|| lowercased.contains("\\n")
|| lowercased.split(whereSeparator: \.isNewline).count > 1
}
+
private static func logRejected(_ candidate: SubtitleCandidate, responseURL: URL?, data: Data) -> SubtitleCandidate? {
#if DEBUG
let responseDescription = responseURL.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none"
7 unmodified lines
} else {
bodyKind = "unreadable"
}
print("[DreamioSubtitles] rejected candidate reason=\(bodyKind) url=\(URLRedactor.redactedURLString(candidate.url.absoluteString)) responseURL=\(responseDescription) preview=\(rejectionPreview(data))")
#endif
return nil
}
+
#if DEBUG
private static func rejectionPreview(_ data: Data) -> String {
guard let text = String(data: data.prefix(180), encoding: .utf8) else {
return "unreadable"
}
return text
.replacingOccurrences(of: "\n", with: "\\n")
.replacingOccurrences(of: "\r", with: "\\r")
}
#endif
}
+
protocol StreamResolving {
Tests/StreamResolverTests.swift
+36
16 unmodified lines
17
18
19
20
21
22
453 unmodified lines
476
477
478
479
480
481
16 unmodified lines
testOpenSubtitlesV3DownloadResponseResolution()
testOpenSubtitlesNestedDownloadResponseResolution()
await testSubtitleResolverCachesStremioDownloadBody()
await testSubtitleResolverDownloadJSONReturningLink()
await testSubtitleResolverRedirectToDirectSubtitle()
await testSubtitleResolverRejectsNonSubtitleAPIResponse()
453 unmodified lines
assertEqual(cachedBody, subtitleBody)
}
+
private static func testSubtitleResolverDownloadJSONReturningLink() async {
MockURLProtocol.handler = nil
MockURLProtocol.handlers = [
16 unmodified lines
17
18
19
20
21
22
23
453 unmodified lines
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
16 unmodified lines
testOpenSubtitlesV3DownloadResponseResolution()
testOpenSubtitlesNestedDownloadResponseResolution()
await testSubtitleResolverCachesStremioDownloadBody()
await testSubtitleResolverCachesPlainStremioDownloadBody()
await testSubtitleResolverDownloadJSONReturningLink()
await testSubtitleResolverRedirectToDirectSubtitle()
await testSubtitleResolverRejectsNonSubtitleAPIResponse()
453 unmodified lines
assertEqual(cachedBody, subtitleBody)
}
+
private static func testSubtitleResolverCachesPlainStremioDownloadBody() async {
let sourceURL = "https://subs5.strem.io/en/download/subencoding-stremio-utf8/src-api/file/1952341942"
let subtitleBody = """
00:01.000 --> 00:02.000
Plain cue text without an index
+
"""
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")
let cachedBody = try? String(contentsOf: candidate!.url, encoding: .utf8)
assertEqual(cachedBody, subtitleBody)
}
+
private static func testSubtitleResolverDownloadJSONReturningLink() async {
MockURLProtocol.handler = nil
MockURLProtocol.handlers = [
+

Related issues or PRs

+

Related Beads issue: dreamio-8oe.

+
+

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. Stremio subtitle downloads should now reach VLC as local subtitle files rather than extensionless provider URLs.

+

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, including plain cue-text payloads that do not match classic indexed SRT.

@@ -357,21 +514,21 @@
  • 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.
  • -
  • 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.
  • +
  • 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 Stremio subtitle cache tests for strict SRT and plain cue text.
  • 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 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.

    +

    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, HTML/XML, JSON without a direct subtitle link, or a format VLC cannot parse after local caching, that would still need a separate resolver enhancement.

    Follow-up Work