Files
SpotiFLAC-Mobile/rust_backend/crates/extensions/src/ffmpeg_host.rs
T
zarzet aa99726439 feat(backend): migrate production backend to Rust
Replace the Go backend with the Rust workspace and route Android/iOS through
UniFFI bindings. Include the migrated extension runtime, network, providers,
media metadata, downloads and library operations, with version 5.0.0+147.

Remove Go sources, adapters, native build selection and CI dependencies.
Build Rust unconditionally and lock the iOS Rust pod using a relative path.
Retain legacy source and migration evidence in a local ignored archive.

Validation: Android Kotlin compile and 53 native tests; Swift Rust-branch
equivalence and syntax; CocoaPods install; workflow, shell, Ruby, plist and
diff checks. Reuse the preceding 100 Rust tests, fmt/Clippy and five-ABI
build checkpoint; no new APK or iOS application build for this cleanup.

Known follow-up: URL/URLSearchParams globals are missing from the Rust JS
runtime. A controlled extension replay confirms a URL-resolution regression;
this commit does not fix that runtime gap.
2026-09-14 22:58:27 +07:00

156 lines
6.0 KiB
Rust

use crate::files::ExtensionFiles;
use crate::runtime::{Control, ExtensionServices};
use cap_std::fs::OpenOptions;
use rquickjs::{Ctx, Function, Object, Value};
use std::sync::Arc;
pub(crate) fn register<'js>(
ctx: &Ctx<'js>,
host: &Object<'js>,
control: Arc<Control>,
services: &ExtensionServices,
) -> rquickjs::Result<()> {
host.set("rawFfmpegStub", services.raw_ffmpeg_stub)?;
let Some(files) = &services.files else {
return Ok(());
};
let info_files = Arc::clone(files);
let info_control = Arc::clone(&control);
host.set(
"mediaInfo",
Function::new(ctx.clone(), move |ctx: Ctx<'js>, path: String| {
let check = || info_control.check().map_err(|e| e.to_string());
let result = media_info(&info_files, &path, &check);
let object = Object::new(ctx.clone())?;
match result {
Ok(quality) => {
object.set("success", true)?;
object.set("bit_depth", quality.bit_depth)?;
object.set("sample_rate", quality.sample_rate)?;
object.set("total_samples", quality.total_samples)?;
object.set(
"duration",
quality.total_samples as f64 / quality.sample_rate as f64,
)?;
object.set("codec", quality.codec)?;
}
Err(error) => {
object.set("success", false)?;
object.set("error", error)?;
}
}
Ok::<_, rquickjs::Error>(object)
})?,
)?;
let files = Arc::clone(files);
let registry = Arc::clone(&services.ffmpeg);
let id = services.extension_id.clone();
host.set(
"mediaConvert",
Function::new(
ctx.clone(),
move |input: String, output: String, options: Value<'js>| {
let result = (|| {
let input = files
.resolve(&input)
.and_then(|path| path.native_display())
.map_err(|e| format!("invalid input path: {e}"))?;
let output = files
.resolve(&output)
.and_then(|path| path.native_display())
.map_err(|e| format!("invalid output path: {e}"))?;
let arguments = conversion_arguments(&input, &output, options.as_object())?;
let budget = control.resolution();
let _pause = budget.as_ref().map(|budget| budget.enter(false));
registry.execute(&id, arguments, input, output, &control)
})();
match result {
Ok(result) => serde_json::to_string(&result).expect("FFmpeg result JSON"),
Err(error) => serde_json::json!({"success":false,"error":error}).to_string(),
}
},
)?,
)?;
Ok(())
}
fn media_info(
files: &ExtensionFiles,
path: &str,
check: &dyn Fn() -> Result<(), String>,
) -> Result<spotiflac_core::media::AudioQuality, String> {
let path = files.resolve(path)?;
let mut file = path
.open(OpenOptions::new().read(true))
.map_err(|e| format!("failed to open file: {e}"))?;
spotiflac_core::media::probe_quality(&mut file, check)
}
fn conversion_arguments(
input: &str,
output: &str,
options: Option<&Object<'_>>,
) -> Result<Vec<String>, String> {
let mut arguments = vec![
"-hide_banner".into(),
"-nostdin".into(),
"-i".into(),
input.into(),
];
if let Some(options) = options {
for (key, flag) in [("codec", "-c:a"), ("bitrate", "-b:a")] {
let value: Value = options.get(key).map_err(|e| e.to_string())?;
if let Some(value) = value.as_string() {
let value = value.to_string().map_err(|e| e.to_string())?;
if key == "codec" {
if !matches!(
value.as_str(),
"aac"
| "alac"
| "copy"
| "flac"
| "libmp3lame"
| "libopus"
| "opus"
| "pcm_s16le"
| "pcm_s24le"
) {
return Err("unsupported audio codec".into());
}
} else {
let digits = value.strip_suffix(['k', 'K', 'm', 'M']).unwrap_or(&value);
if digits.is_empty()
|| digits.len() > 8
|| digits.starts_with('0')
|| !digits.bytes().all(|byte| byte.is_ascii_digit())
{
return Err("invalid audio bitrate".into());
}
}
arguments.extend([flag.into(), value]);
}
}
for (key, message) in [
("sample_rate", "invalid sample rate"),
("channels", "invalid channel count"),
] {
let value: Value = options.get(key).map_err(|e| e.to_string())?;
if let Some(value) = value.as_number() {
// Goja exports integral JS Numbers as int64; the old host only
// accepts float64 here. Preserve its ignored integer options.
// Every remaining float is fractional, non-finite, or outside
// Goja's integer range, and fails the host's range/integer check.
if !value.is_finite()
|| value.fract() != 0.0
|| (value == 0.0 && value.is_sign_negative())
|| !(-9_007_199_254_740_992.0..=9_007_199_254_740_992.0).contains(&value)
{
return Err(message.into());
}
}
}
}
arguments.extend(["-y".into(), output.into()]);
Ok(arguments)
}