Files
SpotiFLAC-Mobile/ios/Runner/CoreBackend.swift
T
Bartolo 14ce4bc868 Add Hi-Res authenticity check
Introduce a per-file Hi-Res authenticity checker and optional auto re-download for flagged fake Hi-Res downloads. Adds Rust checker modules (fft, evidence, FLAC/WAV decoding, tests) and a mobile API (check_hires_authenticity). Wire platform bindings for Android/iOS and PlatformBridge. Add Dart HiResCheckService, UI HiResCheckCard, settings toggle (redownloadFakeHiRes) and integration in the download pipeline to quarantine and replace suspicious files with LOSSLESS when safe. Adds claxon FLAC dependency.
2026-09-23 22:29:15 +02:00

817 lines
44 KiB
Swift

import Foundation
import CryptoKit
import Darwin
import ffmpegkit
import SpotiFLACBackend
struct CoreFFmpegCommand: Decodable {
let command_id: String
let arguments: [String]
var output_path: String? = nil
}
final class CoreFFmpegExecution {
let wait: (Int64) throws -> [CoreFFmpegCommand]
let active: (String) throws -> Bool
let complete: (String, Bool, String, String) throws -> Void
let close: () -> Void
private let lock = NSLock()
private var running = true
init(wait: @escaping (Int64) throws -> [CoreFFmpegCommand], active: @escaping (String) throws -> Bool,
complete: @escaping (String, Bool, String, String) throws -> Void, close: @escaping () -> Void = {}) {
self.wait = wait
self.active = active
self.complete = complete
self.close = close
}
func run(execute: @escaping ([String], @escaping () -> Bool) -> (Bool, String) = executeCoreFFmpeg,
block: () throws -> String) rethrows -> String {
DispatchQueue.global(qos: .userInitiated).async {
defer { self.close() }
while self.isRunning() {
guard let commands = try? self.wait(1_000) else { return }
// A different operation may own any command in this claimed batch.
for command in commands {
let cancelled = { (try? self.active(command.command_id)) != true }
let result: (Bool, String)
if cancelled() { result = (false, "cancelled") }
else if command.arguments.isEmpty { result = (false, "FFmpeg arguments are empty") }
else { result = executeCoreFFmpegCommand(command: command, cancelled: cancelled, execute: execute) }
try? self.complete(command.command_id, result.0, result.1, result.0 ? "" : result.1)
}
}
}
defer {
lock.lock()
running = false
lock.unlock()
}
return try block()
}
private func isRunning() -> Bool {
lock.lock()
defer { lock.unlock() }
return running
}
}
private enum CoreFFmpegStaging {
static let lock = NSLock()
static var directories = Set<String>()
static let prefix = ".spotiflac-ffmpeg-\(Bundle.main.bundleIdentifier ?? "spotiflac")-"
static func create(for target: URL) throws -> URL {
let directory = target.deletingLastPathComponent().resolvingSymlinksInPath()
lock.lock()
defer { lock.unlock() }
// One app process owns these stages. Sweep before its first command
// in this directory; subsequent/concurrent commands may own live files.
if !directories.contains(directory.path) {
for file in try FileManager.default.contentsOfDirectory(
at: directory, includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey]
) where file.lastPathComponent.hasPrefix(prefix) {
let suffix = file.lastPathComponent.dropFirst(prefix.count)
let token = String(suffix.prefix(36))
let values = try file.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey])
if UUID(uuidString: token) != nil, suffix.dropFirst(36).hasPrefix("."),
values.isRegularFile == true, values.isSymbolicLink != true {
try FileManager.default.removeItem(at: file)
}
}
directories.insert(directory.path)
}
let staged = directory.appendingPathComponent("\(prefix)\(UUID().uuidString).\(target.pathExtension)")
try Data().write(to: staged, options: .withoutOverwriting)
return staged
}
}
func executeCoreFFmpegCommand(command: CoreFFmpegCommand, cancelled: @escaping () -> Bool,
execute: ([String], @escaping () -> Bool) -> (Bool, String)) -> (Bool, String) {
if cancelled() { return (false, "cancelled") }
guard let output = command.output_path, !output.isEmpty else {
return execute(command.arguments, cancelled)
}
guard command.arguments.last == output else { return (false, "FFmpeg output does not match command") }
let target = URL(fileURLWithPath: output)
do {
let staged = try CoreFFmpegStaging.create(for: target)
defer { try? FileManager.default.removeItem(at: staged) }
var arguments = command.arguments
arguments[arguments.count - 1] = staged.path
let result = execute(arguments, cancelled)
if cancelled() { return (false, "cancelled") }
if !result.0 { return result }
// POSIX rename replaces the destination atomically on the same filesystem.
guard rename(staged.path, target.path) == 0 else {
throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno))
}
return result
} catch {
return (false, error.localizedDescription)
}
}
private func executeCoreFFmpeg(arguments: [String], cancelled: @escaping () -> Bool) -> (Bool, String) {
if cancelled() { return (false, "cancelled") }
let finished = DispatchSemaphore(value: 0)
guard let session = FFmpegKit.execute(withArgumentsAsync: arguments, withCompleteCallback: { _ in finished.signal() }) else {
return (false, "FFmpeg session could not start")
}
while finished.wait(timeout: .now() + 0.2) == .timedOut {
if cancelled() {
session.cancel()
_ = finished.wait(timeout: .now() + 5)
return (false, "cancelled")
}
}
if cancelled() { return (false, "cancelled") }
return (ReturnCode.isSuccess(session.getReturnCode()), session.getOutput() ?? "")
}
/// Owns one operation's directory access through finalization.
final class CoreDirectoryScope {
private let lock = NSLock()
private var release: (() -> Void)?
init(_ release: @escaping () -> Void) { self.release = release }
func close() {
lock.lock()
defer { lock.unlock() }
let action = release
release = nil
action?()
}
deinit { close() }
}
/// Native migration boundary; each process selects one stateful backend.
protocol CoreBackend {
var implementation: String { get }
var routesApplication: Bool { get }
func invokeApplication(method: String, arguments: Any?) throws -> Any?
func completeAuthCallback(state: String, code: String, sessionGrant: Bool, onResolved: (String) -> Void) throws
func openDownloadProgress() throws -> CoreDownloadProgress
func cancelActiveDownloads() throws -> String
func downloadByStrategy(requestJson: String) throws -> String
func runPostProcessing(inputJson: String, metadataJson: String) throws -> String
func buildFilename(template: String, metadataJson: String) throws -> String
func sanitizeFilename(filename: String) -> String
func fileMetadataImplementation(path: String) -> String
func readFileMetadata(path: String, hint: String) throws -> String
func checkHiResAuthenticity(path: String, optionsJson: String) throws -> String
func editFileMetadata(path: String, metadataJson: String) throws -> String
func reEnrichFile(requestJson: String) throws -> String
func rewriteSplitArtistTags(path: String, artist: String, albumArtist: String) throws -> String
func extractCoverToFile(audioPath: String, outputPath: String) throws
func writeM4aFreeformTags(path: String, metadataJson: String) throws -> String
func ensureAc4Config(path: String, reference: String) throws -> String
func writeAc4Metadata(path: String, metadataJson: String, coverPath: String) throws -> String
func setLibraryCoverCacheDirectory(path: String) throws
func scanLibraryFolder(folder: String) throws -> String
func scanLibraryFolderToNdjsonFile(folder: String, output: String) throws -> Int
func scanLibraryFolderIncremental(folder: String, existing: String) throws -> String
func getLibraryScanProgress() throws -> String
func cancelLibraryScan() throws
func parseCueSheet(path: String, audioDirectory: String) throws -> String
func openDownloadDirectory(path: String) throws -> CoreDirectoryScope
func createTemporaryMediaFile(prefix: String, suffix: String) throws -> URL
func downloadCoverToFileSized(url: String, outputPath: String, maxDimension: Int64) throws
func releaseIdleResources() throws
func releaseMemoryUnderPressure() throws
}
extension CoreBackend {
var routesApplication: Bool { false }
func invokeApplication(method: String, arguments: Any?) throws -> Any? {
throw NSError(domain: "CoreBackend", code: 1, userInfo: [NSLocalizedDescriptionKey: "Application routing is unavailable for \(method)"])
}
}
final class RustCoreBackend: CoreBackend {
static let shared = RustCoreBackend()
let implementation = "rust"
let routesApplication = true
private let root = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0].appendingPathComponent("rust-core-pilot", isDirectory: true)
private let ownerLock = NSLock()
private var manager: ExtensionManager?
private var repository: ExtensionRepository?
private var requests: CancellationRegistry?
private var identity: [String]?
private var loggingEnabled = false
private var allowPrivateNetwork = false
private var allowHttpFallback = false
private var fallbackProviders: [String]?
private var directoryScopes: [String: CoreDirectoryScope] = [:]
private var libraryCoverScope: CoreDirectoryScope?
private init() {}
private func withFFmpegCommands(_ current: ExtensionManager, block: () throws -> String) throws -> String {
let commands = try current.environment().ffmpegCommands()
let execution = CoreFFmpegExecution(wait: { timeout in
try JSONDecoder().decode([CoreFFmpegCommand].self, from: Data(commands.waitPending(timeoutMs: timeout).utf8))
}, active: { id in
try !commands.getCommand(commandId: id).isEmpty
}, complete: { id, success, output, error in
_ = try commands.complete(commandId: id, success: success, output: output, error: error)
})
return try execution.run(block: block)
}
func downloadByStrategy(requestJson: String) throws -> String {
let current = try owner()
return try withFFmpegCommands(current) { try current.downloadByStrategy(requestJson: requestJson) }
}
func runPostProcessing(inputJson: String, metadataJson: String) throws -> String {
let current = try owner()
return try withFFmpegCommands(current) {
try current.runPostProcessing(inputJson: inputJson, metadataJson: metadataJson, timeoutMs: 120_000)
}
}
private func owner() throws -> ExtensionManager {
ownerLock.lock()
defer { ownerLock.unlock() }
guard let manager = manager else { throw failure("Rust backend is not initialized") }
return manager
}
private func requestRegistryLocked() -> CancellationRegistry {
if let requests = requests { return requests }
let created = CancellationRegistry(domain: .extensionRequest)
requests = created
return created
}
private func acquireRequest(_ id: String) throws -> (ExtensionManager, RequestLease) {
ownerLock.lock()
defer { ownerLock.unlock() }
guard let manager = manager else { throw failure("Rust backend is not initialized") }
return (manager, try requestRegistryLocked().acquire(id: id))
}
private func repositoryOwner() throws -> ExtensionRepository {
ownerLock.lock()
defer { ownerLock.unlock() }
guard let repository = repository else { throw failure("Extension repository is not initialized") }
return repository
}
private func initializeRepository(_ cachePath: String) throws {
ownerLock.lock()
defer { ownerLock.unlock() }
guard let manager = manager else { throw failure("Rust backend is not initialized") }
if repository != nil { return }
guard cachePath.hasPrefix("/") else { throw failure("Repository cache directory must be absolute") }
let cache = URL(fileURLWithPath: cachePath).resolvingSymlinksInPath().standardizedFileURL.path
repository = try ExtensionRepository(manager: manager, cacheDirectory: cache)
}
private func failure(_ message: String) -> NSError {
NSError(domain: "RustCoreBackend", code: 1, userInfo: [NSLocalizedDescriptionKey: message])
}
private func directoryAliases(_ value: String, sources: String, data: String) throws -> [String] {
guard value.hasPrefix("/") else { throw failure("Output directories must be absolute") }
let url = URL(fileURLWithPath: value)
let path = url.resolvingSymlinksInPath().standardizedFileURL.path
guard path != "/", ![sources, data].contains(where: {
path == $0 || path.hasPrefix($0 + "/") || $0.hasPrefix(path + "/")
}) else { throw failure("Output directory overlaps extension storage") }
let original = url.standardizedFileURL.path
return path == original ? [path] : [path, original]
}
private func openDownloadDirectoryLocked(path: String) throws -> CoreDirectoryScope {
guard let manager = manager, let storage = identity else { throw failure("Rust backend is not initialized") }
let paths = try directoryAliases(path, sources: storage[0], data: storage[1])
let scope = try manager.environment().grantDownloadDirectories(directories: paths)
return CoreDirectoryScope { scope.release() }
}
func openDownloadDirectory(path: String) throws -> CoreDirectoryScope {
ownerLock.lock()
defer { ownerLock.unlock() }
return try openDownloadDirectoryLocked(path: path)
}
private func withLibraryDirectories<T>(_ paths: [String], block: (ExtensionManager) throws -> T) throws -> T {
let (current, scope): (ExtensionManager, CoreDirectoryScope) = try {
ownerLock.lock()
defer { ownerLock.unlock() }
guard let manager = manager, let storage = identity else { throw failure("Rust backend is not initialized") }
let aliases = try paths.flatMap { try directoryAliases($0, sources: storage[0], data: storage[1]) }
let grant = try manager.environment().grantDownloadDirectories(directories: Array(Set(aliases)))
return (manager, CoreDirectoryScope { grant.release() })
}()
defer { scope.close() }
// Progress and cancellation must remain available while the scan runs.
return try block(current)
}
func setLibraryCoverCacheDirectory(path: String) throws {
ownerLock.lock()
defer { ownerLock.unlock() }
guard let manager = manager else { throw failure("Rust backend is not initialized") }
guard path.isEmpty || path.hasPrefix("/") else { throw failure("Library cover directory must be absolute") }
let directory = path.isEmpty ? nil : URL(fileURLWithPath: path).resolvingSymlinksInPath().standardizedFileURL
if let directory = directory { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) }
let next = try directory.map { try openDownloadDirectoryLocked(path: $0.path) }
do { try manager.setLibraryCoverCacheDirectory(directory: directory?.path ?? "") }
catch { next?.close(); throw error }
libraryCoverScope?.close()
libraryCoverScope = next
}
func scanLibraryFolder(folder: String) throws -> String {
try withLibraryDirectories([folder]) {
try $0.scanLibraryFolder(folder: URL(fileURLWithPath: folder).resolvingSymlinksInPath().standardizedFileURL.path, lease: nil)
}
}
func scanLibraryFolderToNdjsonFile(folder: String, output: String) throws -> Int {
guard output.hasPrefix("/"), URL(fileURLWithPath: output).pathExtension.lowercased() == "ndjson" else {
throw failure("Library scan output must be an absolute NDJSON path")
}
return try withLibraryDirectories([folder]) { current in
// Application support also contains extension storage. Stage inside
// the existing private root instead of granting that whole directory.
let staged = root.appendingPathComponent("files", isDirectory: true)
.appendingPathComponent("library_scan_\(UUID().uuidString).ndjson")
defer { try? FileManager.default.removeItem(at: staged) }
let count = try current.scanLibraryFolderToNdjsonFile(
folder: URL(fileURLWithPath: folder).resolvingSymlinksInPath().standardizedFileURL.path,
output: staged.resolvingSymlinksInPath().path, lease: nil)
guard rename(staged.path, output) == 0 else { throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) }
return Int(count)
}
}
func scanLibraryFolderIncremental(folder: String, existing: String) throws -> String {
try withLibraryDirectories([folder]) {
try $0.scanLibraryFolderIncremental(folder: URL(fileURLWithPath: folder).resolvingSymlinksInPath().standardizedFileURL.path,
existingJson: existing, lease: nil)
}
}
func getLibraryScanProgress() throws -> String {
ownerLock.lock()
let current = manager
ownerLock.unlock()
return try current?.getLibraryScanProgress() ?? "{}"
}
func cancelLibraryScan() throws {
ownerLock.lock()
let current = manager
ownerLock.unlock()
try current?.cancelLibraryScan()
}
func parseCueSheet(path: String, audioDirectory: String) throws -> String {
let cue = URL(fileURLWithPath: path).resolvingSymlinksInPath().standardizedFileURL
let audio = audioDirectory.isEmpty ? cue.deletingLastPathComponent() : URL(fileURLWithPath: audioDirectory).resolvingSymlinksInPath().standardizedFileURL
return try withLibraryDirectories([cue.deletingLastPathComponent().path, audio.path]) {
try $0.parseCueFileJson(path: cue.path, audioDirectory: audio.path, lease: nil)
}
}
func createTemporaryMediaFile(prefix: String, suffix: String) throws -> URL {
_ = try owner()
let file = root.appendingPathComponent("files", isDirectory: true)
.appendingPathComponent("\(prefix)\(UUID().uuidString)\(suffix)")
try Data().write(to: file, options: .withoutOverwriting)
return file
}
func downloadCoverToFileSized(url: String, outputPath: String, maxDimension: Int64) throws {
try owner().downloadCoverToFileSized(url: url,
outputPath: URL(fileURLWithPath: outputPath).resolvingSymlinksInPath().standardizedFileURL.path,
maxDimension: maxDimension, lease: nil)
}
func releaseIdleResources() throws {
try owner().releaseMemory(underPressure: false)
}
func releaseMemoryUnderPressure() throws {
try owner().releaseMemory(underPressure: true)
}
private func initializeOwner(_ arguments: [String: Any]) throws {
guard let sourcePath = arguments["extensions_dir"] as? String,
let dataPath = arguments["data_dir"] as? String,
let key = arguments["master_key"] as? String else {
throw failure("Extension storage arguments are required")
}
guard sourcePath.hasPrefix("/"), dataPath.hasPrefix("/") else {
throw failure("Extension storage directories must be absolute")
}
let sources = URL(fileURLWithPath: sourcePath).resolvingSymlinksInPath().standardizedFileURL.path
let data = URL(fileURLWithPath: dataPath).resolvingSymlinksInPath().standardizedFileURL.path
let keyHash = SHA256.hash(data: Data(key.utf8)).map { String(format: "%02x", $0) }.joined()
let requested = [sources, data, keyHash]
ownerLock.lock()
defer { ownerLock.unlock() }
let files = root.appendingPathComponent("files", isDirectory: true)
try FileManager.default.createDirectory(at: files, withIntermediateDirectories: true)
guard arguments["allowed_directories"] == nil || arguments["allowed_directories"] is [String] else {
throw failure("Invalid output directories")
}
var allowedDirectories = [files.resolvingSymlinksInPath().path, files.standardizedFileURL.path]
for value in arguments["allowed_directories"] as? [String] ?? [] {
for alias in try directoryAliases(value, sources: sources, data: data) where !allowedDirectories.contains(alias) {
allowedDirectories.append(alias)
}
}
if manager != nil {
guard identity == requested else { throw failure("Rust backend is already initialized with different storage") }
try manager!.environment().setAllowedDownloadDirectories(directories: allowedDirectories)
return
}
let created = try ExtensionManager.withLyricsSettings(
sourceDirectory: sources,
dataDirectory: data,
masterKey: key,
appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1",
timeoutMs: 30000,
providersJson: arguments["lyrics_providers_json"] as? String ?? "[]",
optionsJson: arguments["lyrics_options_json"] as? String ?? "{}"
)
do {
let environment = created.environment()
try environment.setAllowedDownloadDirectories(directories: allowedDirectories)
try environment.setAllowPrivateNetwork(allow: allowPrivateNetwork)
try environment.setNetworkCompatibilityOptions(allowHttp: allowHttpFallback, insecureTls: false)
try environment.logBuffer().setEnabled(enabled: loggingEnabled)
try created.setFallbackProviders(ids: fallbackProviders)
} catch {
created.shutdown()
throw error
}
identity = requested
manager = created
}
private func shutdownOwner() {
ownerLock.lock()
defer { ownerLock.unlock() }
requests?.shutdown()
requests = nil
repository?.shutdown()
repository = nil
manager?.shutdown()
manager = nil
directoryScopes.values.forEach { $0.close() }
directoryScopes.removeAll()
libraryCoverScope?.close()
libraryCoverScope = nil
identity = nil
}
func fileMetadataImplementation(path: String) -> String { "rust" }
func readFileMetadata(path: String, hint: String) throws -> String {
try SpotiFLACBackend.readFileMetadata(path: path, hint: hint, lease: nil)
}
func checkHiResAuthenticity(path: String, optionsJson: String) throws -> String {
try SpotiFLACBackend.checkHiresAuthenticity(path: path, optionsJson: optionsJson, lease: nil)
}
func editFileMetadata(path: String, metadataJson: String) throws -> String {
try owner().editFileMetadata(path: URL(fileURLWithPath: path).resolvingSymlinksInPath().standardizedFileURL.path, metadataJson: metadataJson, lease: nil)
}
private func mediaPath(_ path: String) -> String {
path.isEmpty ? "" : URL(fileURLWithPath: path).resolvingSymlinksInPath().standardizedFileURL.path
}
func reEnrichFile(requestJson: String) throws -> String {
guard var request = try JSONSerialization.jsonObject(with: Data(requestJson.utf8)) as? [String: Any] else {
throw failure("Re-enrich request must be an object")
}
if request["preview_only"] as? Bool != true,
let path = request["file_path"] as? String, path.hasPrefix("/") {
request["file_path"] = mediaPath(path)
}
let data = try JSONSerialization.data(withJSONObject: request)
return try owner().reenrichFile(requestJson: String(decoding: data, as: UTF8.self), lease: nil)
}
func rewriteSplitArtistTags(path: String, artist: String, albumArtist: String) throws -> String {
try owner().rewriteSplitArtistTags(path: mediaPath(path), artist: artist, albumArtist: albumArtist, lease: nil)
}
func extractCoverToFile(audioPath: String, outputPath: String) throws {
try owner().extractCoverToFile(audioPath: mediaPath(audioPath), outputPath: mediaPath(outputPath), lease: nil)
}
func writeM4aFreeformTags(path: String, metadataJson: String) throws -> String {
try owner().writeM4aFreeformTags(path: mediaPath(path), metadataJson: metadataJson, lease: nil)
}
func ensureAc4Config(path: String, reference: String) throws -> String {
try owner().ensureAc4Config(path: mediaPath(path), reference: mediaPath(reference), lease: nil)
}
func writeAc4Metadata(path: String, metadataJson: String, coverPath: String) throws -> String {
try owner().writeAc4Metadata(path: mediaPath(path), metadataJson: metadataJson, coverPath: mediaPath(coverPath), lease: nil)
}
func completeAuthCallback(state: String, code: String, sessionGrant: Bool, onResolved: (String) -> Void) throws {
let current = try owner()
let environment = current.environment()
let id = try (sessionGrant ? environment.resolveCallbackState(state: state) : environment.consumeCallbackState(state: state))
onResolved(id)
if sessionGrant {
try completeSessionGrant(current, id: id, grant: code)
} else {
try environment.setAuthCode(extensionId: id, code: code)
_ = try current.invokeAction(extensionId: id, action: "completeSpotifyLogin")
}
}
func openDownloadProgress() throws -> CoreDownloadProgress {
let subscription = try owner().environment().downloadState().subscribeProgress()
return CoreDownloadProgress(
wait: { try subscription.waitDelta(since: $0, timeoutMs: $1) },
close: { subscription.stop() }
)
}
func cancelActiveDownloads() throws -> String {
let ids = try owner().environment().downloadState().cancelActiveDownloads()
return String(decoding: try JSONSerialization.data(withJSONObject: ids), as: UTF8.self)
}
private func completeSessionGrant(_ current: ExtensionManager, id: String, grant: String) throws {
try current.environment().setSessionGrant(extensionId: id, grant: grant)
let response = try current.invokeAction(extensionId: id, action: "completeGrant")
try requireSuccessfulExtensionAction(extensionId: id, actionName: "completeGrant", response: response)
}
func invokeApplication(method: String, arguments: Any?) throws -> Any? {
let args = arguments as? [String: Any] ?? [:]
func string(_ key: String, _ fallback: String = "") -> String { args[key] as? String ?? fallback }
func ids(_ raw: String) throws -> [String] {
guard let value = try JSONSerialization.jsonObject(with: Data(raw.utf8)) as? [String] else {
throw failure("Expected an array of extension IDs")
}
return value
}
switch method {
case "cancelExtensionRequest":
ownerLock.lock()
defer { ownerLock.unlock() }
try requestRegistryLocked().cancel(id: string("request_id"))
return nil
case "customSearchWithExtension", "getExtensionHomeFeed":
let (current, lease) = try acquireRequest(string("request_id"))
defer { lease.release() }
if method == "customSearchWithExtension" {
return try current.customSearchJson(extensionId: string("extension_id"), query: string("query"), optionsJson: string("options"), lease: lease)
}
return try current.getExtensionHomeFeedJson(extensionId: string("extension_id"), lease: lease)
case "initExtensionSystem": try initializeOwner(args); return nil
case "setDownloadDirectory":
ownerLock.lock()
defer { ownerLock.unlock() }
guard let manager = manager, let storage = identity else { throw failure("Rust backend is not initialized") }
let files = root.appendingPathComponent("files", isDirectory: true)
var paths = [files.resolvingSymlinksInPath().path, files.standardizedFileURL.path]
let path = string("path")
if !path.isEmpty { paths += try directoryAliases(path, sources: storage[0], data: storage[1]) }
try manager.environment().setAllowedDownloadDirectories(directories: paths)
return nil
case "acquireDownloadDirectory":
ownerLock.lock()
defer { ownerLock.unlock() }
let scope = try openDownloadDirectoryLocked(path: string("path"))
let token = UUID().uuidString
directoryScopes[token] = scope
return token
case "releaseDownloadDirectory":
ownerLock.lock()
let scope = directoryScopes.removeValue(forKey: string("token"))
ownerLock.unlock()
scope?.close()
return nil
case "initExtensionRepo": try initializeRepository(string("cache_dir")); return nil
case "getRepoRegistryUrl": return try repositoryOwner().registryUrl()
case "setRepoRegistryUrl": try repositoryOwner().setRegistryUrl(url: string("registry_url")); return nil
case "clearRepoRegistryUrl": try repositoryOwner().clearRegistryUrl(); return nil
case "getRepoExtensions": return try repositoryOwner().extensions(forceRefresh: args["force_refresh"] as? Bool ?? false)
case "searchRepoExtensions": return try repositoryOwner().search(query: string("query"), category: string("category"))
case "getRepoCategories":
return String(decoding: try JSONSerialization.data(withJSONObject: repositoryOwner().categories()), as: UTF8.self)
case "downloadRepoExtension": return try repositoryOwner().download(extensionId: string("extension_id"), destinationDirectory: string("dest_dir"))
case "clearRepoCache": try repositoryOwner().clearCache(); return nil
case "cleanupExtensions": shutdownOwner(); return nil
case "buildFilename": return try buildFilename(template: string("template"), metadataJson: string("metadata", "{}"))
case "sanitizeFilename": return sanitizeFilename(filename: string("filename"))
case "readFileMetadata": return try readFileMetadata(path: string("file_path"), hint: string("display_name"))
case "editFileMetadata": return try editFileMetadata(path: string("file_path"), metadataJson: string("metadata_json", "{}"))
case "getLogsSince":
ownerLock.lock()
defer { ownerLock.unlock() }
return try manager?.environment().logBuffer().since(index: (args["index"] as? NSNumber)?.int64Value ?? 0) ?? "{\"logs\":[],\"next_index\":0}"
case "clearLogs":
ownerLock.lock()
defer { ownerLock.unlock() }
try manager?.environment().logBuffer().clear()
return nil
case "setLoggingEnabled", "setAllowPrivateNetwork", "setDownloadFallbackExtensionIds", "setLyricsProviders", "setLyricsFetchOptions", "setNetworkCompatibilityOptions", "setSongLinkNetworkOptions":
ownerLock.lock()
defer { ownerLock.unlock() }
switch method {
case "setLoggingEnabled":
let enabled = args["enabled"] as? Bool ?? false
try manager?.environment().logBuffer().setEnabled(enabled: enabled)
loggingEnabled = enabled
case "setAllowPrivateNetwork":
let allowed = args["allowed"] as? Bool ?? false
try manager?.environment().setAllowPrivateNetwork(allow: allowed)
allowPrivateNetwork = allowed
case "setNetworkCompatibilityOptions", "setSongLinkNetworkOptions":
let allowed = args["allow_http"] as? Bool ?? false
let insecureTLS = args["insecure_tls"] as? Bool ?? false
try manager?.environment().setNetworkCompatibilityOptions(allowHttp: allowed, insecureTls: insecureTLS)
allowHttpFallback = allowed
case "setDownloadFallbackExtensionIds":
let raw = string("extension_ids").trimmingCharacters(in: .whitespacesAndNewlines)
let value = raw.isEmpty || raw == "null" ? nil : try ids(raw)
try manager?.setFallbackProviders(ids: value)
fallbackProviders = value
case "setLyricsProviders":
let raw = string("providers_json", "[]")
if let manager = manager { try manager.setLyricsProvidersJson(providersJson: raw) }
else { _ = try ids(raw) }
return "{\"success\":true}"
case "setLyricsFetchOptions":
let raw = string("options_json", "{}")
if let manager = manager { try manager.setLyricsFetchOptionsJson(optionsJson: raw) }
else { _ = try JSONSerialization.jsonObject(with: Data(raw.utf8)) }
return "{\"success\":true}"
default: break
}
return nil
default: break
}
let current = try owner()
switch method {
case "loadExtensionsFromDir":
let path = URL(fileURLWithPath: string("dir_path")).resolvingSymlinksInPath().standardizedFileURL.path
ownerLock.lock()
let matches = identity?.first == path
ownerLock.unlock()
guard matches else { throw failure("Extension source directory does not match the initialized owner") }
return try current.loadAll()
case "loadExtensionFromPath": return try current.install(packagePath: string("file_path"))
case "upgradeExtension": return try current.upgrade(packagePath: string("file_path"))
case "checkExtensionUpgrade": return try current.checkUpgrade(packagePath: string("file_path"))
case "getInstalledExtensions": return try current.installed()
case "setExtensionEnabled": try current.setEnabled(extensionId: string("extension_id"), enabled: args["enabled"] as? Bool ?? false); return nil
case "unloadExtension": try current.unload(extensionId: string("extension_id")); return nil
case "removeExtension": try current.remove(extensionId: string("extension_id")); return nil
case "getExtensionSettings": return try current.environment().settings(extensionId: string("extension_id"))
case "setExtensionSettings": try current.updateSettings(extensionId: string("extension_id"), settingsJson: string("settings", "{}")); return nil
case "invokeExtensionAction": return try current.invokeAction(extensionId: string("extension_id"), action: string("action"))
case "checkExtensionHealth": return try current.checkExtensionHealthJson(extensionId: string("extension_id"))
case "searchTracksWithMetadataProviders":
return try current.searchMetadataProviders(query: string("query"), limit: (args["limit"] as? NSNumber)?.int64Value ?? 20, includeExtensions: args["include_extensions"] as? Bool ?? true, itemId: "", timeoutMs: 30000)
case "searchTracksWithMetadataProvider":
return try current.searchMetadataProvider(extensionId: string("extension_id"), query: string("query"), limit: (args["limit"] as? NSNumber)?.int64Value ?? 20, timeoutMs: 30000)
case "getProviderMetadata":
return try current.getProviderMetadataJson(providerId: string("provider_id"), resourceType: string("resource_type"), resourceId: string("resource_id"), lease: nil)
case "findCollectionAcrossExtensions":
return try current.findCollectionAcrossExtensionsJson(requestJson: arguments as? String ?? "{}", lease: nil)
case "enrichTrackWithExtension": return try current.enrichTrackJson(extensionId: string("extension_id"), trackJson: string("track", "{}"))
case "handleURLWithExtension": return try current.handleUrlJson(url: string("url"))
case "findURLHandler": return try current.findUrlHandler(url: string("url")) ?? ""
case "searchDeezerByISRC": return try current.searchDeezerByIsrcForItemId(isrc: string("isrc"), itemId: string("item_id"), lease: nil)
case "getDeezerExtendedMetadata": return try current.getDeezerExtendedMetadata(trackId: string("track_id"), lease: nil)
case "convertSpotifyToDeezer": return try current.convertSpotifyToDeezer(resourceType: string("resource_type"), spotifyId: string("spotify_id"), lease: nil)
case "getSpotifyIDFromDeezerTrack": return try current.getSpotifyIdFromDeezerTrack(trackId: string("deezer_track_id"), lease: nil)
case "getTidalURLFromDeezerTrack": return try current.getTidalUrlFromDeezerTrack(trackId: string("deezer_track_id"), lease: nil)
case "getTrackPlatformLinks": return try current.getTrackPlatformLinksJson(spotifyId: string("spotify_id"), isrc: string("isrc"), lease: nil)
case "fetchMusicBrainzTags":
let genre = (try? current.fetchMusicBrainzGenreByIsrc(isrc: string("isrc"), lease: nil)) ?? ""
let albumArtist = (try? current.fetchMusicBrainzAlbumArtistByIsrc(isrc: string("isrc"), albumName: string("album_name"), lease: nil)) ?? ""
return String(decoding: try JSONSerialization.data(withJSONObject: ["genre": genre, "album_artist": albumArtist]), as: UTF8.self)
case "getTrackCacheSize": return Int(try current.getTrackCacheSize())
case "readAudioMetadata": return try current.readAudioMetadata(path: string("file_path"), hint: "", cacheKey: "", lease: nil)
case "clearTrackCache": try current.clearTrackIdCache(); return nil
case "setMetadataLanguage": try current.setMetadataLanguage(tag: string("tag")); return nil
case "downloadByStrategy", "downloadWithExtensions":
guard let request = arguments as? String else {
throw NSError(domain: "CoreBackend", code: 1, userInfo: [NSLocalizedDescriptionKey: "Download request must be a JSON string"])
}
return try withFFmpegCommands(current) {
if method == "downloadWithExtensions" { return try current.downloadWithExtensionsJson(requestJson: request) }
return try current.downloadByStrategy(requestJson: request)
}
case "getAllDownloadProgress": return try current.environment().downloadState().allProgress()
case "cleanupConnections": try current.environment().cleanupConnections(); return nil
case "clearItemProgress": try current.environment().downloadState().clearItemProgress(itemId: string("item_id")); return nil
case "cancelDownload": try current.environment().downloadState().cancelDownload(itemId: string("item_id")); return nil
case "resetDownloadCancel": try current.environment().downloadState().resetDownloadCancel(itemId: string("item_id")); return nil
case "getExtensionPendingAuth": return try current.getExtensionPendingAuthJson(extensionId: string("extension_id"))
case "setExtensionAuthCode": try current.environment().setAuthCode(extensionId: string("extension_id"), code: string("auth_code")); return nil
case "completeExtensionSessionGrant":
try completeSessionGrant(current, id: string("extension_id"), grant: string("grant"))
return true
case "setExtensionTokens":
try current.environment().setAuthTokens(extensionId: string("extension_id"), accessToken: string("access_token"), refreshToken: string("refresh_token"), expiresIn: (args["expires_in"] as? NSNumber)?.int64Value ?? 0)
return nil
case "clearExtensionPendingAuth": try current.environment().clearPendingAuth(extensionId: string("extension_id")); return nil
case "isExtensionAuthenticated": return try current.environment().isAuthenticated(extensionId: string("extension_id"))
case "getAllPendingAuthRequests": return try current.environment().allPendingAuth()
case "getLyricsLRC", "getLyricsLRCWithSource", "fetchAndSaveLyrics":
let request = LyricsRequest(
spotifyId: string("spotify_id"),
track: string("track_name"),
artist: string("artist_name"),
filePath: string(method == "fetchAndSaveLyrics" ? "audio_file_path" : "file_path"),
durationMs: (args["duration_ms"] as? NSNumber)?.int64Value ?? 0
)
if method == "getLyricsLRC" { return try current.getLyricsLrc(request: request, lease: nil) }
if method == "getLyricsLRCWithSource" { return try current.getLyricsLrcWithSource(request: request, lease: nil) }
try current.fetchAndSaveLyrics(request: request, outputPath: string("output_path"), lease: nil)
return "{\"success\":true}"
case "getPendingFFmpegCommand":
return try current.environment().ffmpegCommands().getCommand(commandId: string("command_id"))
case "getAllPendingFFmpegCommands":
return try current.environment().ffmpegCommands().pending()
case "setFFmpegCommandResult":
_ = try current.environment().ffmpegCommands().complete(
commandId: string("command_id"),
success: args["success"] as? Bool ?? false,
output: string("output"),
error: string("error")
)
return nil
case "runPostProcessingV2":
return try withFFmpegCommands(current) {
try current.runPostProcessing(inputJson: string("input"), metadataJson: string("metadata"), timeoutMs: 120_000)
}
case "embedLyricsToFile":
return try current.embedLyricsToFile(path: string("file_path"), lyrics: string("lyrics"), lease: nil)
case "setProviderPriority", "setMetadataProviderPriority":
try current.setProviderPriority(kind: method == "setProviderPriority" ? "download" : "metadata", ids: ids(string("priority", "[]")))
return nil
case "getProviderPriority", "getMetadataProviderPriority":
let priorities = try JSONSerialization.jsonObject(with: Data(current.providerPriorities().utf8)) as! [String: Any]
let value = priorities[method == "getProviderPriority" ? "download" : "metadata"] as? [String] ?? []
return String(decoding: try JSONSerialization.data(withJSONObject: value), as: UTF8.self)
case "getLyricsProviders": return try current.getLyricsProvidersJson()
case "getLyricsFetchOptions": return try current.getLyricsFetchOptionsJson()
case "getAvailableLyricsProviders": return try current.getAvailableLyricsProvidersJson()
default: throw failure("Rust application method is not connected yet: \(method)")
}
}
func buildFilename(template: String, metadataJson: String) throws -> String {
try SpotiFLACBackend.buildFilename(template: template, metadataJson: metadataJson)
}
func sanitizeFilename(filename: String) -> String {
SpotiFLACBackend.sanitizeFilename(filename: filename)
}
}
func requireSuccessfulExtensionAction(extensionId: String, actionName: String, response: String?) throws {
let text = response ?? ""
guard let data = text.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw NSError(
domain: "SpotiFLAC",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Extension \(actionName) for \(extensionId) returned invalid JSON: \(String(text.prefix(240)))"]
)
}
if (obj["success"] as? Bool) == true { return }
let error =
(obj["error"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ??
(obj["message"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ??
String(text.prefix(240))
throw NSError(
domain: "SpotiFLAC",
code: 2,
userInfo: [NSLocalizedDescriptionKey: "Extension \(actionName) failed for \(extensionId): \(error)"]
)
}
func createCoreBackend() -> CoreBackend {
return RustCoreBackend.shared
}