mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-23 20:31:22 +08:00
feat: open-source 小浣熊阅读 for iOS
This commit is contained in:
@@ -0,0 +1,407 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
public enum HttpTTSServiceError: Error, LocalizedError, Sendable {
|
||||
case emptyText
|
||||
case invalidContentType(expected: String, actual: String, body: String)
|
||||
case textResponse(contentType: String, body: String)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .emptyText:
|
||||
"朗读文本为空"
|
||||
case .invalidContentType(let expected, let actual, let body):
|
||||
"TTS 返回类型不匹配(期望 \(expected),实际 \(actual)):\(body)"
|
||||
case .textResponse(_, let body):
|
||||
body
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct HttpTTSAudio: Sendable {
|
||||
public var data: Data
|
||||
public var contentType: String?
|
||||
public var finalURL: URL
|
||||
|
||||
public init(data: Data, contentType: String?, finalURL: URL) {
|
||||
self.data = data
|
||||
self.contentType = contentType
|
||||
self.finalURL = finalURL
|
||||
}
|
||||
}
|
||||
|
||||
public struct HttpTTSAudioStream: Sendable {
|
||||
public var contentType: String?
|
||||
public var finalURL: URL
|
||||
public var expectedContentLength: Int64?
|
||||
public var chunks: AsyncThrowingStream<Data, Error>
|
||||
public var isIncremental: Bool
|
||||
|
||||
public init(
|
||||
contentType: String?,
|
||||
finalURL: URL,
|
||||
expectedContentLength: Int64?,
|
||||
chunks: AsyncThrowingStream<Data, Error>,
|
||||
isIncremental: Bool
|
||||
) {
|
||||
self.contentType = contentType
|
||||
self.finalURL = finalURL
|
||||
self.expectedContentLength = expectedContentLength
|
||||
self.chunks = chunks
|
||||
self.isIncremental = isIncremental
|
||||
}
|
||||
}
|
||||
|
||||
public actor HttpTTSAudioCache {
|
||||
public static let shared = HttpTTSAudioCache()
|
||||
private let directory: URL
|
||||
private let maximumBytes: Int64
|
||||
|
||||
public init(directory: URL? = nil, maximumBytes: Int64 = 128 * 1_024 * 1_024) {
|
||||
self.directory = directory ?? FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent("Legado/httpTTS_cache", isDirectory: true)
|
||||
self.maximumBytes = maximumBytes
|
||||
}
|
||||
|
||||
public func data(for key: String) -> Data? {
|
||||
let url = directory.appendingPathComponent(key + ".audio")
|
||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||
try? FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: url.path)
|
||||
return data
|
||||
}
|
||||
|
||||
public func store(_ data: Data, for key: String) {
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
try data.write(to: directory.appendingPathComponent(key + ".audio"), options: .atomic)
|
||||
trimIfNeeded()
|
||||
} catch { }
|
||||
}
|
||||
|
||||
public func clear() { try? FileManager.default.removeItem(at: directory) }
|
||||
|
||||
private func trimIfNeeded() {
|
||||
let keys: Set<URLResourceKey> = [.fileSizeKey, .contentModificationDateKey, .isRegularFileKey]
|
||||
guard let files = try? FileManager.default.contentsOfDirectory(
|
||||
at: directory,
|
||||
includingPropertiesForKeys: Array(keys),
|
||||
options: [.skipsHiddenFiles]
|
||||
) else { return }
|
||||
var entries: [(URL, Int64, Date)] = files.compactMap { url in
|
||||
guard let values = try? url.resourceValues(forKeys: keys), values.isRegularFile == true else { return nil }
|
||||
return (url, Int64(values.fileSize ?? 0), values.contentModificationDate ?? .distantPast)
|
||||
}
|
||||
var total = entries.reduce(Int64(0)) { $0 + $1.1 }
|
||||
guard total > maximumBytes else { return }
|
||||
entries.sort { $0.2 < $1.2 }
|
||||
for entry in entries where total > maximumBytes {
|
||||
try? FileManager.default.removeItem(at: entry.0)
|
||||
total -= entry.1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public actor HttpTTSService {
|
||||
private let urlBuilder: LegadoURLBuilder
|
||||
private let networkPolicy: SourceNetworkPolicy
|
||||
private let transport: any HTTPTransport
|
||||
private let cache: HttpTTSAudioCache
|
||||
|
||||
public init(
|
||||
urlBuilder: LegadoURLBuilder = .init(),
|
||||
networkPolicy: SourceNetworkPolicy = .shared,
|
||||
transport: any HTTPTransport = URLSessionHTTPTransport(),
|
||||
cache: HttpTTSAudioCache = .shared
|
||||
) {
|
||||
self.urlBuilder = urlBuilder
|
||||
self.networkPolicy = networkPolicy
|
||||
self.transport = transport
|
||||
self.cache = cache
|
||||
}
|
||||
|
||||
public func audio(engine: HttpTTS, text: String, speechRate: Int) async throws -> HttpTTSAudio {
|
||||
let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { throw HttpTTSServiceError.emptyText }
|
||||
let source = engine.bookSourceAdapter
|
||||
let cacheKey = Self.cacheKey(engine: engine, text: text, speechRate: speechRate)
|
||||
if let data = await cache.data(for: cacheKey) {
|
||||
return .init(data: data, contentType: engine.contentType, finalURL: cacheURL(engine: engine))
|
||||
}
|
||||
let context = URLTemplateContext(
|
||||
key: text,
|
||||
variables: [
|
||||
"speakText": text,
|
||||
"speakSpeed": String(speechRate)
|
||||
],
|
||||
source: source
|
||||
)
|
||||
var lastError: Error = HttpTTSServiceError.emptyText
|
||||
for attempt in 0..<6 {
|
||||
try Task.checkCancellation()
|
||||
do {
|
||||
let prepared = try urlBuilder.prepare(
|
||||
engine.url,
|
||||
context: context,
|
||||
defaultHeaders: try BookSourceHeaderResolver().resolve(source)
|
||||
)
|
||||
var response = try await networkPolicy.execute(
|
||||
prepared,
|
||||
source: source,
|
||||
transport: transport,
|
||||
timeout: 300
|
||||
)
|
||||
if try evaluateLoginCheckIfNeeded(engine: engine, source: source, response: response) {
|
||||
let rebuilt = try urlBuilder.prepare(
|
||||
engine.url,
|
||||
context: context,
|
||||
defaultHeaders: try BookSourceHeaderResolver().resolve(source)
|
||||
)
|
||||
response = try await networkPolicy.execute(
|
||||
rebuilt,
|
||||
source: source,
|
||||
transport: transport,
|
||||
timeout: 300
|
||||
)
|
||||
}
|
||||
let contentType = response.headers.first {
|
||||
$0.key.caseInsensitiveCompare("Content-Type") == .orderedSame
|
||||
}?.value.components(separatedBy: ";").first?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
try validateContentType(engine: engine, response: response, contentType: contentType)
|
||||
await cache.store(response.data, for: cacheKey)
|
||||
return .init(data: response.data, contentType: contentType, finalURL: response.finalURL)
|
||||
} catch is CancellationError {
|
||||
throw CancellationError()
|
||||
} catch HTTPClientError.statusResponse(let response) {
|
||||
if try evaluateLoginCheckIfNeeded(engine: engine, source: source, response: response) {
|
||||
let rebuilt = try urlBuilder.prepare(
|
||||
engine.url,
|
||||
context: context,
|
||||
defaultHeaders: try BookSourceHeaderResolver().resolve(source)
|
||||
)
|
||||
let recovered = try await networkPolicy.execute(
|
||||
rebuilt,
|
||||
source: source,
|
||||
transport: transport,
|
||||
timeout: 300
|
||||
)
|
||||
let contentType = Self.contentType(from: recovered.headers)
|
||||
try validateContentType(engine: engine, response: recovered, contentType: contentType)
|
||||
await cache.store(recovered.data, for: cacheKey)
|
||||
return .init(data: recovered.data, contentType: contentType, finalURL: recovered.finalURL)
|
||||
}
|
||||
lastError = HTTPClientError.statusResponse(response)
|
||||
if attempt == 5 { throw lastError }
|
||||
} catch let error as RuleEngineError {
|
||||
throw error
|
||||
} catch {
|
||||
lastError = error
|
||||
if attempt == 5 { throw error }
|
||||
}
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
|
||||
public func audioStream(engine: HttpTTS, text: String, speechRate: Int) async throws -> HttpTTSAudioStream {
|
||||
let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { throw HttpTTSServiceError.emptyText }
|
||||
let cacheKey = Self.cacheKey(engine: engine, text: text, speechRate: speechRate)
|
||||
if let data = await cache.data(for: cacheKey) {
|
||||
return Self.bufferedStream(
|
||||
data: data,
|
||||
contentType: engine.contentType,
|
||||
finalURL: cacheURL(engine: engine)
|
||||
)
|
||||
}
|
||||
let requiresBufferedValidation = engine.loginCheckJs?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||
guard !requiresBufferedValidation,
|
||||
let streamingTransport = transport as? any StreamingHTTPTransport else {
|
||||
let value = try await audio(engine: engine, text: text, speechRate: speechRate)
|
||||
return Self.bufferedStream(data: value.data, contentType: value.contentType, finalURL: value.finalURL)
|
||||
}
|
||||
let source = engine.bookSourceAdapter
|
||||
let context = URLTemplateContext(
|
||||
key: text,
|
||||
variables: ["speakText": text, "speakSpeed": String(speechRate)],
|
||||
source: source
|
||||
)
|
||||
let prepared = try urlBuilder.prepare(
|
||||
engine.url,
|
||||
context: context,
|
||||
defaultHeaders: try BookSourceHeaderResolver().resolve(source)
|
||||
)
|
||||
if prepared.bodyJavaScript != nil || prepared.responseType != nil || prepared.proxy != nil || prepared.useWebView {
|
||||
let value = try await audio(engine: engine, text: text, speechRate: speechRate)
|
||||
return Self.bufferedStream(data: value.data, contentType: value.contentType, finalURL: value.finalURL)
|
||||
}
|
||||
let response = try await networkPolicy.executeStream(
|
||||
prepared,
|
||||
source: source,
|
||||
transport: streamingTransport,
|
||||
timeout: 300
|
||||
)
|
||||
let contentType = Self.contentType(from: response.headers)
|
||||
try validateStreamContentType(engine: engine, contentType: contentType)
|
||||
let upstream = response.chunks
|
||||
let cache = self.cache
|
||||
let cachedChunks = AsyncThrowingStream<Data, Error> { continuation in
|
||||
let task = Task {
|
||||
var complete = Data()
|
||||
if let length = response.expectedContentLength, length > 0, length <= Int64(Int.max) {
|
||||
complete.reserveCapacity(Int(length))
|
||||
}
|
||||
do {
|
||||
for try await chunk in upstream {
|
||||
try Task.checkCancellation()
|
||||
complete.append(chunk)
|
||||
continuation.yield(chunk)
|
||||
}
|
||||
await cache.store(complete, for: cacheKey)
|
||||
continuation.finish()
|
||||
} catch {
|
||||
continuation.finish(throwing: error)
|
||||
}
|
||||
}
|
||||
continuation.onTermination = { _ in task.cancel() }
|
||||
}
|
||||
return .init(
|
||||
contentType: contentType,
|
||||
finalURL: response.finalURL,
|
||||
expectedContentLength: response.expectedContentLength,
|
||||
chunks: cachedChunks,
|
||||
isIncremental: true
|
||||
)
|
||||
}
|
||||
|
||||
private static func cacheKey(engine: HttpTTS, text: String, speechRate: Int) -> String {
|
||||
let seed = "\(engine.url)-|-\(speechRate)-|-\(text)"
|
||||
let digest = Insecure.MD5.hash(data: Data(seed.utf8)).map { String(format: "%02x", $0) }.joined()
|
||||
return String(digest.dropFirst(8).prefix(16))
|
||||
}
|
||||
|
||||
private static func bufferedStream(data: Data, contentType: String?, finalURL: URL) -> HttpTTSAudioStream {
|
||||
.init(
|
||||
contentType: contentType,
|
||||
finalURL: finalURL,
|
||||
expectedContentLength: Int64(data.count),
|
||||
chunks: AsyncThrowingStream { continuation in
|
||||
continuation.yield(data)
|
||||
continuation.finish()
|
||||
},
|
||||
isIncremental: false
|
||||
)
|
||||
}
|
||||
|
||||
private static func contentType(from headers: [String: String]) -> String? {
|
||||
headers.first { $0.key.caseInsensitiveCompare("Content-Type") == .orderedSame }?
|
||||
.value.components(separatedBy: ";").first?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private func cacheURL(engine: HttpTTS) -> URL {
|
||||
URL(string: engine.url.components(separatedBy: ",").first ?? "")
|
||||
?? URL(fileURLWithPath: "/httpTTS-cache")
|
||||
}
|
||||
|
||||
private func evaluateLoginCheckIfNeeded(engine: HttpTTS, source: BookSource, response: HTTPResponse) throws -> Bool {
|
||||
guard let script = engine.loginCheckJs?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!script.isEmpty else { return false }
|
||||
let headers = response.headers.mapValues(JSONValue.string)
|
||||
let input = JSONValue.object([
|
||||
"code": .number(Double(response.statusCode)),
|
||||
"body": .string(response.decodedText),
|
||||
"headers": .object(headers),
|
||||
"url": .string(response.finalURL.absoluteString)
|
||||
])
|
||||
let wrapped = """
|
||||
(function () {
|
||||
var raw = result;
|
||||
var rebuildRequest = false;
|
||||
function header(name) {
|
||||
var wanted = String(name).toLowerCase();
|
||||
for (var key in raw.headers) if (key.toLowerCase() === wanted) return raw.headers[key];
|
||||
return null;
|
||||
}
|
||||
result = {
|
||||
code: function () { return raw.code; },
|
||||
statusCode: function () { return raw.code; },
|
||||
body: function () { return { string: function () { return raw.body; } }; },
|
||||
header: header,
|
||||
headers: function () { return { get: header }; },
|
||||
url: function () { return { toString: function () { return raw.url; } }; }
|
||||
};
|
||||
java.getHeaderMap = function () {
|
||||
return { putAll: function () {}, put: function () {} };
|
||||
};
|
||||
java.getResponse = function () { rebuildRequest = true; return result; };
|
||||
java.initUrl = function () { rebuildRequest = true; return result; };
|
||||
java.ruleUrl = raw.url;
|
||||
(function () {
|
||||
\(script)
|
||||
})();
|
||||
return { rebuildRequest: rebuildRequest };
|
||||
})()
|
||||
"""
|
||||
let result = try JavaScriptRuleEvaluator().evaluate(
|
||||
script: wrapped,
|
||||
result: input,
|
||||
executionContext: .init(
|
||||
variables: ["speakSpeed": "", "speakText": ""],
|
||||
baseURL: response.finalURL.absoluteString,
|
||||
source: source
|
||||
)
|
||||
)
|
||||
if case .object(let object) = result.value,
|
||||
case .bool(let requested)? = object["rebuildRequest"] {
|
||||
return requested
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func validateContentType(engine: HttpTTS, response: HTTPResponse, contentType: String?) throws {
|
||||
guard let contentType, !contentType.isEmpty else { return }
|
||||
if contentType == "application/json" || contentType.hasPrefix("text/") {
|
||||
throw HttpTTSServiceError.textResponse(contentType: contentType, body: response.decodedText)
|
||||
}
|
||||
guard let expected = engine.contentType?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!expected.isEmpty else { return }
|
||||
let expression = try NSRegularExpression(pattern: expected)
|
||||
let range = NSRange(contentType.startIndex..., in: contentType)
|
||||
guard expression.firstMatch(in: contentType, range: range) != nil else {
|
||||
throw HttpTTSServiceError.invalidContentType(
|
||||
expected: expected,
|
||||
actual: contentType,
|
||||
body: response.decodedText
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func validateStreamContentType(engine: HttpTTS, contentType: String?) throws {
|
||||
guard let contentType, !contentType.isEmpty else { return }
|
||||
if contentType == "application/json" || contentType.hasPrefix("text/") {
|
||||
throw HttpTTSServiceError.textResponse(contentType: contentType, body: "TTS 返回了文本响应")
|
||||
}
|
||||
guard let expected = engine.contentType?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!expected.isEmpty else { return }
|
||||
let expression = try NSRegularExpression(pattern: expected)
|
||||
let range = NSRange(contentType.startIndex..., in: contentType)
|
||||
guard expression.firstMatch(in: contentType, range: range) != nil else {
|
||||
throw HttpTTSServiceError.invalidContentType(expected: expected, actual: contentType, body: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension HttpTTS {
|
||||
var sourceKey: String { "httpTts:\(id)" }
|
||||
|
||||
var bookSourceAdapter: BookSource {
|
||||
var source = BookSource(bookSourceUrl: sourceKey, bookSourceName: name)
|
||||
source.concurrentRate = concurrentRate
|
||||
source.loginUrl = loginUrl
|
||||
source.loginUi = loginUi
|
||||
source.header = header
|
||||
source.jsLib = jsLib
|
||||
source.enabledCookieJar = enabledCookieJar
|
||||
source.loginCheckJs = loginCheckJs
|
||||
source.lastUpdateTime = lastUpdateTime
|
||||
return source
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user