cache stremio subtitles before vlc attach

This commit is contained in:
dirtydishes 2026-05-25 18:55:48 -04:00
parent c7bb408812
commit 128b9518a5
3 changed files with 301 additions and 8 deletions

View file

@ -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"

View file

@ -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,

File diff suppressed because one or more lines are too long