mirror of
https://github.com/dirtydishes/dreamio.git
synced 2026-06-06 13:37:24 +00:00
cache stremio subtitles before vlc attach
This commit is contained in:
parent
c7bb408812
commit
128b9518a5
3 changed files with 301 additions and 8 deletions
|
|
@ -35,9 +35,14 @@ enum StreamResolverError: LocalizedError {
|
||||||
|
|
||||||
final class SubtitleResolver: SubtitleResolving {
|
final class SubtitleResolver: SubtitleResolving {
|
||||||
private let session: URLSession
|
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.session = session
|
||||||
|
self.cacheDirectory = cacheDirectory
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolve(_ candidate: SubtitleCandidate) async -> SubtitleCandidate? {
|
func resolve(_ candidate: SubtitleCandidate) async -> SubtitleCandidate? {
|
||||||
|
|
@ -69,6 +74,10 @@ final class SubtitleResolver: SubtitleResolving {
|
||||||
return SubtitleCandidate(url: finalURL, label: candidate.label, language: candidate.language)
|
return SubtitleCandidate(url: finalURL, label: candidate.label, language: candidate.language)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let cachedCandidate = cacheSubtitleDataIfNeeded(data, original: candidate) {
|
||||||
|
return cachedCandidate
|
||||||
|
}
|
||||||
|
|
||||||
return Self.bestPlayableCandidate(
|
return Self.bestPlayableCandidate(
|
||||||
from: data,
|
from: data,
|
||||||
responseURL: response.url,
|
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(
|
static func bestPlayableCandidate(
|
||||||
from data: Data,
|
from data: Data,
|
||||||
responseURL: URL?,
|
responseURL: URL?,
|
||||||
|
|
@ -131,7 +165,6 @@ final class SubtitleResolver: SubtitleResolving {
|
||||||
let lowercased = url.absoluteString.lowercased()
|
let lowercased = url.absoluteString.lowercased()
|
||||||
return ["srt", "vtt", "ass", "ssa", "sub"].contains(url.pathExtension.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)
|
|| [".srt?", ".vtt?", ".ass?", ".ssa?", ".sub?", ".srt&", ".vtt&", ".ass&", ".ssa&", ".sub&"].contains(where: lowercased.contains)
|
||||||
|| isStremioSubtitleDownloadURL(url)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func shouldResolve(_ url: URL) -> Bool {
|
private static func shouldResolve(_ url: URL) -> Bool {
|
||||||
|
|
@ -154,6 +187,50 @@ final class SubtitleResolver: SubtitleResolving {
|
||||||
|| path.range(of: #"(^|/)download(/|$)"#, options: .regularExpression) != nil
|
|| 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? {
|
private static func logRejected(_ candidate: SubtitleCandidate, responseURL: URL?, data: Data) -> SubtitleCandidate? {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
let responseDescription = responseURL.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none"
|
let responseDescription = responseURL.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none"
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ struct StreamResolverTests {
|
||||||
testStremioSubtitleDownloadURLParsing()
|
testStremioSubtitleDownloadURLParsing()
|
||||||
testOpenSubtitlesV3DownloadResponseResolution()
|
testOpenSubtitlesV3DownloadResponseResolution()
|
||||||
testOpenSubtitlesNestedDownloadResponseResolution()
|
testOpenSubtitlesNestedDownloadResponseResolution()
|
||||||
|
await testSubtitleResolverCachesStremioDownloadBody()
|
||||||
await testSubtitleResolverDownloadJSONReturningLink()
|
await testSubtitleResolverDownloadJSONReturningLink()
|
||||||
await testSubtitleResolverRedirectToDirectSubtitle()
|
await testSubtitleResolverRedirectToDirectSubtitle()
|
||||||
await testSubtitleResolverRejectsNonSubtitleAPIResponse()
|
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].url.absoluteString, "https://subs5.strem.io/en/download/subencoding-stremio-utf8/src-api/file/1952341941")
|
||||||
assertEqual(candidates[0].label, "English")
|
assertEqual(candidates[0].label, "English")
|
||||||
assertEqual(candidates[0].language, "eng")
|
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() {
|
private static func testContentRangeParsing() {
|
||||||
|
|
@ -437,7 +438,46 @@ struct StreamResolverTests {
|
||||||
assertEqual(candidate?.language, "eng")
|
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 {
|
private static func testSubtitleResolverDownloadJSONReturningLink() async {
|
||||||
|
MockURLProtocol.handler = nil
|
||||||
MockURLProtocol.handlers = [
|
MockURLProtocol.handlers = [
|
||||||
"https://api.opensubtitles.com/api/v1/download/123": (
|
"https://api.opensubtitles.com/api/v1/download/123": (
|
||||||
200,
|
200,
|
||||||
|
|
@ -458,6 +498,7 @@ struct StreamResolverTests {
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func testSubtitleResolverRedirectToDirectSubtitle() async {
|
private static func testSubtitleResolverRedirectToDirectSubtitle() async {
|
||||||
|
MockURLProtocol.handler = nil
|
||||||
MockURLProtocol.handlers = [
|
MockURLProtocol.handlers = [
|
||||||
"https://api.opensubtitles.com/api/v1/download/redirect": (
|
"https://api.opensubtitles.com/api/v1/download/redirect": (
|
||||||
200,
|
200,
|
||||||
|
|
@ -476,6 +517,7 @@ struct StreamResolverTests {
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func testSubtitleResolverRejectsNonSubtitleAPIResponse() async {
|
private static func testSubtitleResolverRejectsNonSubtitleAPIResponse() async {
|
||||||
|
MockURLProtocol.handler = nil
|
||||||
MockURLProtocol.handlers = [
|
MockURLProtocol.handlers = [
|
||||||
"https://api.opensubtitles.com/api/v1/download/not-found": (
|
"https://api.opensubtitles.com/api/v1/download/not-found": (
|
||||||
200,
|
200,
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue