Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .vscode/tasks.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
{
"label": "debug: cargo build",
"type": "shell",
"command": "cargo build --debug",
"command": "cargo build",
"problemMatcher": [
"$rustc"
],
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Thanks for your interest in contributing!

## Development setup
- Install Rust (stable) and `cargo`.
- Optional: `yt-dlp` in PATH if you want to test resolver behavior locally.
- Optional: `yt-dlp` in PATH if you want; otherwise the node will auto-download tools to `~/.resonix/bin` on first run.
- Build: `cargo build` | Format: `cargo fmt`

## Coding guidelines
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "resonix-node"
version = "0.2.0"
version = "0.2.5"
edition = "2021"
description = "High-performance audio node. HTTP/WebSocket server that resolves and streams audio using Symphonia and yt-dlp."
license = "BSD-3-Clause"
Expand Down Expand Up @@ -57,6 +57,9 @@ rspotify = { version = "0.15", default-features = false, features = [
dotenvy = "0.15"
base64 = "0.22"
uuid = { version = "1", features = ["v4"] }
zip = "0.4"
tar = "0.4"
xz2 = "0.1"

[badges]
docsrs = {}
Expand All @@ -65,9 +68,6 @@ maintenance = { status = "actively-developed" }
[build-dependencies]
winres = "0.1"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
zip = "0.4"
tar = "0.4"
xz2 = "0.1"

[package.metadata.bundle]
name = "Resonix"
Expand Down
29 changes: 11 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Features
- Allow/block URL patterns via regex
- Lightweight EQ and volume filters
- Minimal authentication via static password header
- Self-contained (optional): embeds `yt-dlp` and `ffmpeg` binaries inside the executable
- First-run auto-download of `yt-dlp` and `ffmpeg` into a user cache (`~/.resonix/bin`) keeping the executable slim

### Status
Early preview. APIs may evolve. See License (BSD-3-Clause).
Expand Down Expand Up @@ -109,35 +109,28 @@ Environment overrides
- `RESOLVE_TIMEOUT_MS=...` → override timeout
- `SPOTIFY_CLIENT_ID` / `SPOTIFY_CLIENT_SECRET` → fallback env vars if `[spotify]` section is omitted.
- You can also set custom env var names and reference them from the config, e.g.: `client_id = "MY_APP_SPOTIFY_ID"` and then set `MY_APP_SPOTIFY_ID` in your `.env` or environment.
- `RESONIX_EMBED_EXTRACT_DIR=...` → directory to which embedded binaries are written (default: OS temp dir / `resonix-embedded`)
- (legacy) `RESONIX_EMBED_EXTRACT_DIR` is ignored now; tools are stored in `~/.resonix/bin`
(runtime export `RESONIX_TOOLS_DIR` shows the resolved directory)

Runtime export (informational)
- On startup the resolved paths are placed into `RESONIX_YTDLP_BIN` / `RESONIX_FFMPEG_BIN` env vars for child processes spawned by the node. Normally you do not need to set these manually.

### Embedded binaries (standalone mode)
### Tool management (yt-dlp / ffmpeg)

Resonix can bundle `yt-dlp` and `ffmpeg` directly into the executable:
On startup Resonix checks for `yt-dlp` and `ffmpeg`.

1. During build, `build.rs` downloads platform-appropriate binaries into `assets/bin` (if they are missing).
2. Those files are embedded with `include_bytes!` so the final `resonix-node` can run without the tools installed system-wide.
3. At runtime, if the configured / external tool paths fail validation, the embedded bytes are extracted to a writable folder (default temporary directory or `RESONIX_EMBED_EXTRACT_DIR`) and invoked from there.

Selection order for each tool:
Resolution order (per tool):
1. Explicit env (`YTDLP_PATH` / `FFMPEG_PATH`)
2. Config value (`resolver.ytdlp_path` / `resolver.ffmpeg_path`)
3. Embedded binary fallback (if present)

To force use of your own system tools, either:
- Set `YTDLP_PATH` / `FFMPEG_PATH` to the desired executables, or
- Provide paths in `Resonix.toml` and keep them working; embedded versions are only used if validation (`--version` / `-version`) fails.
2. Config (`resolver.ytdlp_path` / `resolver.ffmpeg_path`)
3. Auto-managed download to `~/.resonix/bin` (created if missing)

To update embedded versions, delete the corresponding file(s) in `assets/bin` and rebuild; they will be re-downloaded.
If only one tool is missing, only that one is downloaded. Existing executables are left untouched. Delete a file to force re-download of the latest release.

To ship a smaller binary without embedding, remove the files from `assets/bin` before building (they will not be embedded if absent) and rely on external paths/env values.
macOS: `ffmpeg` is not auto-downloaded (install via Homebrew: `brew install ffmpeg`).

Notes
- The resolver downloads temporary audio files using `yt-dlp`. Ensure sufficient disk space and legal use in your jurisdiction.
- For sources needing remux/extraction, `ffmpeg` is required. Embedded or external versions are acceptable.
- For sources needing remux/extraction, `ffmpeg` is required.

---

Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,4 @@ Handling and Disclosure
Operational Guidance
- If you enable authentication, keep your `server.password` secret and rotate it if leaked.
- Avoid exposing the node directly to the public internet; prefer a private network or authentication proxy.
- Keep dependencies and `yt-dlp` up to date if you use the resolver.
- Keep dependencies and the auto-downloaded tools (`yt-dlp`, `ffmpeg`) up to date if you use the resolver. Delete them in `~/.resonix/bin` to force fresh download.
242 changes: 22 additions & 220 deletions build.rs
Original file line number Diff line number Diff line change
@@ -1,237 +1,39 @@
use std::{
env, fs, io,
path::{Path, PathBuf},
};

fn download_to(url: &str, dest: &Path) -> Result<(), String> {
println!("cargo:warning=Downloading {} -> {}", url, dest.display());
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let resp = reqwest::blocking::get(url).map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("Request failed: {}", resp.status()));
}
let bytes = resp.bytes().map_err(|e| e.to_string())?;
fs::write(dest, &bytes).map_err(|e| e.to_string())?;
Ok(())
}

fn ensure_yt_dlp(target_os: &str, out_dir: &Path) -> Result<PathBuf, String> {
let (url, filename) = match target_os {
"windows" => ("https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe", "yt-dlp.exe"),
"macos" => ("https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos", "yt-dlp"),
_ => ("https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp", "yt-dlp"), // linux & others
};
let dest = out_dir.join(filename);
if !dest.exists() {
download_to(url, &dest)?;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = fs::metadata(&dest) {
let mut perm = meta.permissions();
perm.set_mode(0o755);
let _ = fs::set_permissions(&dest, perm);
}
}
Ok(dest)
}

fn ensure_ffmpeg(target_os: &str, out_dir: &Path) -> Result<PathBuf, String> {
if target_os == "macos" {
return Err("Skipping ffmpeg embed on macOS".into());
}

let target_arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_else(|_| "x86_64".into());
let (platform_tag, is_zip) = match (target_os, target_arch.as_str()) {
("windows", "aarch64") => ("winarm64", true),
("windows", _) => ("win64", true),
("linux", "aarch64") => ("linuxarm64", false),
("linux", _) => ("linux64", false),
(other, _) => return Err(format!("Unsupported OS for ffmpeg embedding: {other}")),
};
let archive_name = if is_zip { "ffmpeg.zip" } else { "ffmpeg.tar.xz" };
let archive_url = format!(
"https://github.com/BtbN/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-{platform_tag}-gpl.{}",
if is_zip { "zip" } else { "tar.xz" }
);
let bin_subpath = if target_os == "windows" {
format!("ffmpeg-master-latest-{platform_tag}-gpl/bin/ffmpeg.exe")
} else {
format!("ffmpeg-master-latest-{platform_tag}-gpl/bin/ffmpeg")
};
let ffmpeg_bin = out_dir.join(if target_os == "windows" { "ffmpeg.exe" } else { "ffmpeg" });
if ffmpeg_bin.exists() {
// Assume prior full extraction already happened.
return Ok(ffmpeg_bin);
}

let archive_path = out_dir.join(archive_name);
download_to(&archive_url, &archive_path)?;
if is_zip {
let file = fs::File::open(&archive_path).map_err(|e| e.to_string())?;
let mut zip = zip::ZipArchive::new(file).map_err(|e| e.to_string())?;
for i in 0..zip.len() {
let mut f = zip.by_index(i).map_err(|e| e.to_string())?;
let name = f.name().to_string();
if let Some(bin_prefix) = bin_subpath.rsplit_once('/') {
// (dir, file)
let bin_dir_prefix = bin_prefix.0.to_string();
if name.ends_with('/') {
continue;
}
if name.contains(&bin_dir_prefix) {
if let Some(filename) = name.split('/').last() {
let out_path = out_dir.join(filename);
let mut out_f = fs::File::create(&out_path).map_err(|e| e.to_string())?;
io::copy(&mut f, &mut out_f).map_err(|e| e.to_string())?;
}
}
}
}
} else {
// tar.xz
let file = fs::File::open(&archive_path).map_err(|e| e.to_string())?;
let decompressor = xz2::read::XzDecoder::new(file);
let mut archive = tar::Archive::new(decompressor);
for entry in archive.entries().map_err(|e| e.to_string())? {
let mut entry = entry.map_err(|e| e.to_string())?;
if let Ok(path) = entry.path() {
if let Some(path_str) = path.to_str() {
if let Some((bin_dir_prefix, _file)) = bin_subpath.rsplit_once('/') {
if path_str.contains(bin_dir_prefix) && !path_str.ends_with('/') {
if let Some(filename) = path.file_name() {
let out_path = out_dir.join(filename);
entry.unpack(&out_path).map_err(|e| e.to_string())?;
}
}
}
}
}
}
}
let _ = fs::remove_file(&archive_path);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = fs::metadata(&ffmpeg_bin) {
let mut perm = meta.permissions();
perm.set_mode(0o755);
let _ = fs::set_permissions(&ffmpeg_bin, perm);
}
}
Ok(ffmpeg_bin)
}
use std::env;
use std::path::Path;

fn main() {
let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_else(|_| String::from("unknown"));
println!("cargo:rerun-if-env-changed=CARGO_CFG_TARGET_OS");
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rustc-check-cfg=cfg(has_embedded_bins)");

let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_else(|_| String::from("unknown"));
if target_os == "windows" {
println!("cargo:rerun-if-changed=assets/app/avatar.ico");
println!("cargo:rerun-if-changed=assets/app/icon.ico");
let mut res = winres::WindowsResource::new();

for path in ["assets/app/icon.ico", "assets/app/avatar.ico"] {
if Path::new(path).exists() {
res.set_icon(path);
let _ = res.compile();
break;
}
}
}

let bin_dir = Path::new("assets").join("bin");
fs::create_dir_all(&bin_dir).expect("create bin dir");

if let Err(e) = ensure_yt_dlp(&target_os, &bin_dir) {
println!("cargo:warning=Failed to ensure yt-dlp: {e}");
}
if let Err(e) = ensure_ffmpeg(&target_os, &bin_dir) {
println!("cargo:warning=Failed to ensure ffmpeg: {e}");
}

println!("cargo:rerun-if-changed={}", bin_dir.display());

println!("cargo:rustc-env=RESONIX_EMBED_OS_DIR={}", bin_dir.display());

let out_dir_fs = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR set"));
let gen_path = out_dir_fs.join("embedded_bins.rs");
let mut gen = String::new();
gen.push_str("// @generated by build.rs\n");
let yt = match target_os.as_str() {
"windows" => bin_dir.join("yt-dlp.exe"),
_ => bin_dir.join("yt-dlp"),
};
if yt.exists() {
let rel = yt.strip_prefix(".").unwrap_or(&yt); // best-effort
let rel_str = rel.to_string_lossy().replace('\\', "/");
gen.push_str(&format!("pub const YT_DLP_PATH: &str = r#\"{}\"#;\n", yt.display()));
gen.push_str(&format!(
"pub const YT_DLP: &[u8] = include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), r#\"/{}\"#));\n",
rel_str
));
} else {
gen.push_str("pub const YT_DLP_PATH: &str = \"\";\npub const YT_DLP: &[u8] = &[];\n");
}
let ff = if target_os == "windows" { bin_dir.join("ffmpeg.exe") } else { bin_dir.join("ffmpeg") };
if ff.exists() {
let rel = ff.strip_prefix(".").unwrap_or(&ff);
let rel_str = rel.to_string_lossy().replace('\\', "/");
gen.push_str(&format!("pub const FFMPEG_PATH: &str = r#\"{}\"#;\n", ff.display()));
gen.push_str(&format!(
"pub const FFMPEG: &[u8] = include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), r#\"/{}\"#));\n",
rel_str
));
} else {
gen.push_str("pub const FFMPEG_PATH: &str = \"\";\npub const FFMPEG: &[u8] = &[];\n");
}

// Generic embedding of every file present in assets/bin for completeness (so ffprobe, dlls, etc. are shipped).
let mut embedded_list_entries = String::new();
embedded_list_entries
.push_str("pub struct EmbeddedFile { pub name: &'static str, pub bytes: &'static [u8] }\n");
let mut array_items = Vec::new();
if let Ok(read_dir) = fs::read_dir(&bin_dir) {
for entry in read_dir.flatten() {
if let Ok(ft) = entry.file_type() {
if ft.is_dir() {
continue;
}
}
let path = entry.path();
if let Some(fname) = path.file_name().and_then(|s| s.to_str()) {
let rel = path.strip_prefix(".").unwrap_or(&path);
let rel_str = rel.to_string_lossy().replace('\\', "/");
// Sanitize identifier
let mut ident = fname
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect::<String>();
if !ident.chars().next().map(|c| c.is_ascii_alphabetic() || c == '_').unwrap_or(false) {
ident = format!("_{}", ident);
}
ident = ident.to_ascii_uppercase();
embedded_list_entries.push_str(&format!("pub const EMBED_FILE_{ident}: &[u8] = include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), r#\"/{}\"#));\n", rel_str));
array_items
.push(format!("EmbeddedFile {{ name: r#\"{fname}\"#, bytes: EMBED_FILE_{ident} }}"));
}
let company = env::var("RESONIX_COMPANY").unwrap_or_else(|_| "Resonix OSS Team".into());
let product = env::var("RESONIX_PRODUCT").unwrap_or_else(|_| "Resonix".into());
let copyright = env::var("RESONIX_COPYRIGHT")
.unwrap_or_else(|_| "© 2025 Resonix OSS".into());

let version = env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "0.0.0".into());
res.set("CompanyName", &company);
res.set("FileDescription", "High-performance audio node");
res.set("ProductName", &product);
res.set("ProductVersion", &version);
res.set("FileVersion", &version);
res.set("OriginalFilename", "resonix-node.exe");
res.set("InternalName", "resonix-node");
res.set("LegalCopyright", &copyright);

if let Err(e) = res.compile() {
eprintln!("Failed to embed Windows resources: {e}");
}
}
embedded_list_entries.push_str("pub const EMBEDDED_FILES: &[EmbeddedFile] = &[\n");
for item in &array_items {
embedded_list_entries.push_str(" ");
embedded_list_entries.push_str(item);
embedded_list_entries.push_str(",\n");
}
embedded_list_entries.push_str("];");
gen.push_str(&embedded_list_entries);
if fs::write(&gen_path, gen).is_err() {
println!("cargo:warning=Failed to write embedded_bins.rs");
}
println!("cargo:rustc-env=RESONIX_EMBED_BINS_RS={}", gen_path.display());
println!("cargo:rustc-cfg=has_embedded_bins");
// If not Windows or no icons, nothing else to do.
}
Loading