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 @@
34 unmodified lines35363738394041424325 unmodified lines69707172737412 unmodified lines87888990919238 unmodified lines13113213313413513613716 unmodified lines15415515615715815934 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 linesreturn 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 lineslet 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 DEBUGlet responseDescription = responseURL.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none"34 unmodified lines353637383940414243444546474825 unmodified lines7475767778798081828312 unmodified lines9697989910010110210310410510610710810911011111211311411511611711811912012112212312412512638 unmodified lines16516616716816917016 unmodified lines18718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523634 unmodified lines+final class SubtitleResolver: SubtitleResolving {private let session: URLSessionprivate let cacheDirectory: URL+init(session: URLSession = .shared,cacheDirectory: URL = FileManager.default.temporaryDirectory.appendingPathComponent("DreamioSubtitles", isDirectory: true)) {self.session = sessionself.cacheDirectory = cacheDirectory}+func resolve(_ candidate: SubtitleCandidate) async -> SubtitleCandidate? {25 unmodified linesreturn 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 DEBUGprint("[DreamioSubtitles] cached subtitle url=\(URLRedactor.redactedURLString(original.url.absoluteString)) file=\(fileURL.lastPathComponent)")#endifreturn SubtitleCandidate(url: fileURL, label: original.label, language: original.language)} catch {#if DEBUGprint("[DreamioSubtitles] cache failure=\(error.localizedDescription) url=\(URLRedactor.redactedURLString(original.url.absoluteString))")#endifreturn nil}}+static func bestPlayableCandidate(from data: Data,responseURL: URL?,38 unmodified lineslet 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 srtcase vttcase 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.isEmptyelse {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 DEBUGlet responseDescription = responseURL.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none"
+15 unmodified lines161718192021248 unmodified lines270271272273274275276160 unmodified lines43743843944044144244314 unmodified lines45845946046146246312 unmodified lines47647747847948048115 unmodified linestestStremioSubtitleDownloadURLParsing()testOpenSubtitlesV3DownloadResponseResolution()testOpenSubtitlesNestedDownloadResponseResolution()await testSubtitleResolverDownloadJSONReturningLink()await testSubtitleResolverRedirectToDirectSubtitle()await testSubtitleResolverRejectsNonSubtitleAPIResponse()248 unmodified linesassertEqual(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 linesassertEqual(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 lines16171819202122248 unmodified lines271272273274275276277160 unmodified lines43843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248314 unmodified lines49849950050150250350412 unmodified lines51751851952052152252315 unmodified linestestStremioSubtitleDownloadURLParsing()testOpenSubtitlesV3DownloadResponseResolution()testOpenSubtitlesNestedDownloadResponseResolution()await testSubtitleResolverCachesStremioDownloadBody()await testSubtitleResolverDownloadJSONReturningLink()await testSubtitleResolverRedirectToDirectSubtitle()await testSubtitleResolverRejectsNonSubtitleAPIResponse()248 unmodified linesassertEqual(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 linesassertEqual(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 = """100:00:01,000 --> 00:00:02,000Hello from Stremio+"""MockURLProtocol.handler = nilMockURLProtocol.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 = nilMockURLProtocol.handlers = ["https://api.opensubtitles.com/api/v1/download/123": (200,14 unmodified lines}+private static func testSubtitleResolverRedirectToDirectSubtitle() async {MockURLProtocol.handler = nilMockURLProtocol.handlers = ["https://api.opensubtitles.com/api/v1/download/redirect": (200,12 unmodified lines}+private static func testSubtitleResolverRejectsNonSubtitleAPIResponse() async {MockURLProtocol.handler = nilMockURLProtocol.handlers = ["https://api.opensubtitles.com/api/v1/download/not-found": (200,
Related Beads issue: dreamio-771.
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.
git diff --check: passed.pod install to restore missing local CocoaPods support files in this worktree.xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -destination 'generic/platform=iOS Simulator' -quiet build: passed.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.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.
flushing queued subtitles after opening mode=local-cache.[DreamioSubtitles] cached subtitle followed by VLC attachment logs with ext=srt, ext=vtt, or ext=ass.