use std::collections::{BTreeMap, BTreeSet};
use std::error::Error as StdError;
use std::fmt;
use std::fs::{self, File};
use std::io::{self, Read, Write};
use std::path::{Component, Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use blake3::Hasher as Blake3;
use cap_std::ambient_authority;
use cap_std::fs::Dir as CapDir;
use reqwest::blocking::{Client, Response};
use reqwest::redirect::Policy;
use serde::{Deserialize, Serialize};
use sha1::{Digest, Sha1};
const COMMONS_API: &str = "https://commons.wikimedia.org/w/api.php";
const COMMONS_HOST: &str = "commons.wikimedia.org";
const COMMONS_DOWNLOAD_HOST: &str = "upload.wikimedia.org";
const DEFAULT_DISCOVERY_LIMIT: usize = 20;
const MAX_DISCOVERY_LIMIT: usize = 50;
const DEFAULT_MAX_DOWNLOAD_BYTES: u64 = 512 * 1024 * 1024;
const MAX_API_RESPONSE_BYTES: u64 = 16 * 1024 * 1024;
const MAX_ERROR_RESPONSE_BYTES: u64 = 8 * 1024;
const MAX_MANIFEST_BYTES: u64 = 2 * 1024 * 1024;
const PROGRESS_GRANULARITY: u64 = 1024 * 1024;
const MANIFEST_NAME: &str = "soundforge-audio.json";
pub const COMMONS_REUSE_URL: &str =
"https://commons.wikimedia.org/wiki/Commons:Reusing_content_outside_Wikimedia";
pub type Result<T> = std::result::Result<T, SoundForgeError>;
#[derive(Debug, thiserror::Error)]
pub enum SoundForgeError {
#[error("invalid audio target '{0}'")]
InvalidTarget(String),
#[error("invalid audio option '{0}'")]
InvalidOption(String),
#[error("{provider} API returned {status}: {body}")]
ProviderApi {
provider: &'static str,
status: u16,
body: String,
},
#[error("{provider} audio '{audio}' was not found with an allowed license")]
NotFound {
provider: &'static str,
audio: String,
},
#[error("refusing untrusted audio URL '{0}'")]
UntrustedUrl(String),
#[error("refusing unsafe audio path '{0}'")]
UnsafePath(String),
#[error("audio download is {size} bytes, exceeding the {limit} byte limit")]
DownloadLimitExceeded {
size: u64,
limit: u64,
},
#[error("{label} is {size} bytes, exceeding the {limit} byte limit")]
ResponseLimitExceeded {
label: String,
size: u64,
limit: u64,
},
#[error("audio verification failed for '{path}': {message}")]
VerificationFailed {
path: String,
message: String,
},
#[error("refusing to replace unrecognized output '{}': {reason}", path.display())]
UnrecognizedExistingOutput {
path: PathBuf,
reason: String,
},
#[error("output path '{}' is not a directory", .0.display())]
OutputNotDirectory(PathBuf),
#[error("I/O error: {0}")]
Io(#[from] io::Error),
#[error("request error: {0}")]
Request(String),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
}
impl From<reqwest::Error> for SoundForgeError {
fn from(error: reqwest::Error) -> Self {
Self::Request(error_chain(&error))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SoundProvider {
WikimediaCommons,
}
impl SoundProvider {
pub const ALL: [Self; 1] = [Self::WikimediaCommons];
pub fn slug(self) -> &'static str {
match self {
Self::WikimediaCommons => "wikimedia-commons",
}
}
pub fn label(self) -> &'static str {
match self {
Self::WikimediaCommons => "Wikimedia Commons",
}
}
}
impl fmt::Display for SoundProvider {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.slug())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SoundKind {
Music,
GameAudio,
Any,
}
impl SoundKind {
pub fn slug(self) -> &'static str {
match self {
Self::Music => "music",
Self::GameAudio => "game-audio",
Self::Any => "any",
}
}
}
impl fmt::Display for SoundKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.slug())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SoundTarget {
File(String),
Search(String),
Category(String),
Top,
Latest,
}
impl SoundTarget {
pub fn is_collection(&self) -> bool {
!matches!(self, Self::File(_))
}
}
pub fn parse_sound_target(input: &str) -> Result<SoundTarget> {
let input = input.trim();
if input.is_empty() {
return Err(SoundForgeError::InvalidTarget("empty target".to_string()));
}
match input.to_ascii_lowercase().as_str() {
"top" | "popular" | "trending" => return Ok(SoundTarget::Top),
"latest" | "newest" | "new" => return Ok(SoundTarget::Latest),
_ => {}
}
for (prefix, constructor) in [
("file:", SoundTarget::File as fn(String) -> SoundTarget),
("search:", SoundTarget::Search),
("category:", SoundTarget::Category),
] {
if input
.get(..prefix.len())
.is_some_and(|value| value.eq_ignore_ascii_case(prefix))
{
return non_empty_target(&input[prefix.len()..], constructor);
}
}
if input.contains("://") {
return parse_commons_url(input);
}
Ok(SoundTarget::Search(input.to_string()))
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SoundRights {
pub license: String,
pub license_url: String,
pub attribution: String,
pub attribution_required: bool,
pub provider_terms_url: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SoundFile {
pub url: String,
pub size: u64,
pub sha1: String,
pub mime: String,
pub duration_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SoundSummary {
pub provider: SoundProvider,
pub id: String,
pub title: String,
pub description: String,
pub categories: Vec<String>,
pub usage_count: u64,
pub source_url: String,
pub rights: SoundRights,
pub file: SoundFile,
}
#[derive(Debug, Clone)]
pub struct ArchiveOptions {
pub output: PathBuf,
pub skip_existing: bool,
pub max_download_bytes: u64,
}
impl Default for ArchiveOptions {
fn default() -> Self {
Self {
output: PathBuf::from("audio"),
skip_existing: false,
max_download_bytes: DEFAULT_MAX_DOWNLOAD_BYTES,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArchiveFailure {
pub sound_id: String,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArchivedSound {
pub sound_id: String,
pub output: PathBuf,
pub bytes_written: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArchiveSummary {
pub selected: usize,
pub archived: usize,
pub skipped: usize,
pub bytes_written: u64,
pub sounds: Vec<ArchivedSound>,
pub failed: Vec<ArchiveFailure>,
}
impl ArchiveSummary {
pub fn is_success(&self) -> bool {
self.failed.is_empty()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArchiveProgressPhase {
Starting,
Downloading,
Completed,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArchiveProgress {
pub phase: ArchiveProgressPhase,
pub total_sounds: usize,
pub completed_sounds: usize,
pub succeeded_sounds: usize,
pub failed_sounds: usize,
pub bytes_written: u64,
pub active_sound: Option<String>,
pub active_file: Option<String>,
pub active_bytes_written: u64,
pub active_total_bytes: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SoundManifest {
pub format_version: String,
pub sound: SoundSummary,
pub path: String,
pub bytes_written: u64,
pub blake3: String,
pub complete: bool,
}
#[derive(Clone)]
pub struct SoundForge {
provider: SoundProvider,
client: Client,
discovery_limit: usize,
}
impl SoundForge {
pub fn with_user_agent(user_agent: &str) -> Result<Self> {
let user_agent = user_agent.trim();
if user_agent.is_empty() {
return Err(SoundForgeError::InvalidOption(
"User-Agent must not be empty".to_string(),
));
}
let client = Client::builder()
.user_agent(user_agent)
.redirect(Policy::none())
.timeout(Duration::from_secs(90))
.build()?;
Ok(Self {
provider: SoundProvider::WikimediaCommons,
client,
discovery_limit: DEFAULT_DISCOVERY_LIMIT,
})
}
pub fn with_discovery_limit(mut self, limit: usize) -> Self {
self.discovery_limit = limit.clamp(1, MAX_DISCOVERY_LIMIT);
self
}
pub fn discover(&self, target: &SoundTarget, kind: SoundKind) -> Result<Vec<SoundSummary>> {
match target {
SoundTarget::File(title) => Ok(vec![self.commons_file(title)?]),
_ => self.commons_search(target, kind),
}
}
pub fn archive(
&self,
sounds: &[SoundSummary],
options: &ArchiveOptions,
) -> Result<ArchiveSummary> {
self.archive_inner(sounds, options, None)
}
pub fn archive_with_progress<F>(
&self,
sounds: &[SoundSummary],
options: &ArchiveOptions,
mut progress: F,
) -> Result<ArchiveSummary>
where
F: FnMut(ArchiveProgress),
{
self.archive_inner(sounds, options, Some(&mut progress))
}
fn commons_search(&self, target: &SoundTarget, kind: SoundKind) -> Result<Vec<SoundSummary>> {
let query = match target {
SoundTarget::Search(value) => format!("{value} filetype:audio"),
SoundTarget::Category(value) => {
format!(
"incategory:\"{}\" filetype:audio",
escape_search_phrase(value)
)
}
SoundTarget::Top => match kind {
SoundKind::Music => "incategory:\"Music\" filetype:audio".to_string(),
SoundKind::GameAudio => "incategory:\"Sound effects\" filetype:audio".to_string(),
SoundKind::Any => "filetype:audio".to_string(),
},
SoundTarget::Latest => "filetype:audio".to_string(),
SoundTarget::File(_) => unreachable!(),
};
let candidate_limit = match target {
SoundTarget::Search(_) => self.discovery_limit.saturating_mul(3).min(50),
_ => self.discovery_limit,
};
let sort = match target {
SoundTarget::Top => "incoming_links_desc",
SoundTarget::Latest => "create_timestamp_desc",
_ => "relevance",
};
let mut url = commons_api_url();
append_common_query(&mut url);
url.query_pairs_mut()
.append_pair("generator", "search")
.append_pair("gsrsearch", &query)
.append_pair("gsrnamespace", "6")
.append_pair("gsrlimit", &candidate_limit.to_string())
.append_pair("gsrsort", sort);
let response: CommonsResponse = self.get_json(url.as_str())?;
let mut sounds = response
.query
.map(|query| query.pages)
.unwrap_or_default()
.into_iter()
.filter_map(|page| commons_summary(page).transpose())
.collect::<Result<Vec<_>>>()?;
rank_sounds(&mut sounds, target, kind);
sounds.truncate(self.discovery_limit);
Ok(sounds)
}
fn commons_file(&self, title: &str) -> Result<SoundSummary> {
let title = normalized_file_title(title);
let mut url = commons_api_url();
append_common_query(&mut url);
url.query_pairs_mut().append_pair("titles", &title);
let response: CommonsResponse = self.get_json(url.as_str())?;
let page = response
.query
.and_then(|query| query.pages.into_iter().next())
.ok_or_else(|| SoundForgeError::NotFound {
provider: self.provider.slug(),
audio: title.clone(),
})?;
commons_summary(page)?.ok_or_else(|| SoundForgeError::NotFound {
provider: self.provider.slug(),
audio: title,
})
}
fn archive_inner(
&self,
sounds: &[SoundSummary],
options: &ArchiveOptions,
mut progress: Option<&mut dyn FnMut(ArchiveProgress)>,
) -> Result<ArchiveSummary> {
prepare_output(&options.output)?;
let mut summary = ArchiveSummary {
selected: sounds.len(),
..ArchiveSummary::default()
};
emit_progress(
&mut progress,
progress_snapshot(ArchiveProgressPhase::Starting, sounds.len(), &summary),
);
for sound in sounds {
let result = self.archive_one(sound, options, sounds.len(), &summary, &mut progress);
match result {
Ok(Some(archived)) => {
summary.archived += 1;
summary.bytes_written =
summary.bytes_written.saturating_add(archived.bytes_written);
summary.sounds.push(archived);
}
Ok(None) => summary.skipped += 1,
Err(error) => summary.failed.push(ArchiveFailure {
sound_id: sound.id.clone(),
message: error.to_string(),
}),
}
emit_progress(
&mut progress,
progress_snapshot(ArchiveProgressPhase::Downloading, sounds.len(), &summary),
);
}
emit_progress(
&mut progress,
progress_snapshot(ArchiveProgressPhase::Completed, sounds.len(), &summary),
);
Ok(summary)
}
fn archive_one(
&self,
sound: &SoundSummary,
options: &ArchiveOptions,
total_sounds: usize,
summary: &ArchiveSummary,
progress: &mut Option<&mut dyn FnMut(ArchiveProgress)>,
) -> Result<Option<ArchivedSound>> {
if sound.provider != self.provider {
return Err(SoundForgeError::VerificationFailed {
path: sound.id.clone(),
message: "sound metadata belongs to another provider".to_string(),
});
}
let authoritative = self.commons_file(&sound.title)?;
if authoritative.id != sound.id {
return Err(SoundForgeError::VerificationFailed {
path: sound.id.clone(),
message: "Commons page identity changed during license revalidation".to_string(),
});
}
let sound = &authoritative;
validate_summary(sound)?;
if options.max_download_bytes > 0 && sound.file.size > options.max_download_bytes {
return Err(SoundForgeError::DownloadLimitExceeded {
size: sound.file.size,
limit: options.max_download_bytes,
});
}
let sound_dir = options.output.join(safe_id(&sound.id)?);
if options.skip_existing && sound_dir.join(MANIFEST_NAME).exists() {
let manifest = read_manifest(&sound_dir)?;
validate_manifest(&manifest, &sound.id)?;
if manifest.sound == *sound {
verify_manifest_file(&sound_dir, &manifest)?;
return Ok(None);
}
}
ensure_replaceable(&sound_dir, &sound.id)?;
let staging = staging_path(&options.output, &sound.id)?;
fs::create_dir(&staging)?;
let archive_result = (|| -> Result<ArchivedSound> {
let extension = extension_for(&sound.file.mime, &sound.file.url)?;
let relative = format!("audio.{extension}");
let output = staging.join(&relative);
let downloaded = download_file(
&self.client,
sound,
&output,
options.max_download_bytes,
|active_bytes| {
emit_progress(
progress,
ArchiveProgress {
phase: ArchiveProgressPhase::Downloading,
total_sounds,
completed_sounds: summary.archived
+ summary.skipped
+ summary.failed.len(),
succeeded_sounds: summary.archived,
failed_sounds: summary.failed.len(),
bytes_written: summary.bytes_written.saturating_add(active_bytes),
active_sound: Some(sound.title.clone()),
active_file: Some(relative.clone()),
active_bytes_written: active_bytes,
active_total_bytes: Some(sound.file.size),
},
);
},
)?;
let manifest = SoundManifest {
format_version: "1".to_string(),
sound: sound.clone(),
path: relative,
bytes_written: downloaded.bytes,
blake3: downloaded.blake3,
complete: true,
};
write_manifest(&staging, &manifest)?;
validate_manifest(&manifest, &sound.id)?;
verify_manifest_file(&staging, &manifest)?;
Ok(ArchivedSound {
sound_id: sound.id.clone(),
output: sound_dir.clone(),
bytes_written: downloaded.bytes,
})
})();
match archive_result {
Ok(archived) => {
commit_staging(&staging, &sound_dir)?;
Ok(Some(archived))
}
Err(error) => {
let _ = fs::remove_dir_all(staging);
Err(error)
}
}
}
fn get_json<T>(&self, url: &str) -> Result<T>
where
T: for<'de> Deserialize<'de>,
{
trusted_api_url(url)?;
let response = self.client.get(url).send()?;
if !response.status().is_success() {
let status = response.status().as_u16();
return Err(SoundForgeError::ProviderApi {
provider: self.provider.slug(),
status,
body: bounded_error_body(response),
});
}
let bytes = read_response_limited(response, MAX_API_RESPONSE_BYTES, "provider response")?;
Ok(serde_json::from_slice(&bytes)?)
}
}
#[derive(Debug, Deserialize)]
struct CommonsResponse {
query: Option<CommonsQuery>,
}
#[derive(Debug, Deserialize)]
struct CommonsQuery {
#[serde(default)]
pages: Vec<CommonsPage>,
}
#[derive(Debug, Deserialize)]
struct CommonsPage {
pageid: Option<u64>,
title: String,
#[serde(default)]
missing: bool,
#[serde(default)]
imageinfo: Vec<CommonsImageInfo>,
#[serde(default)]
categories: Vec<CommonsCategory>,
#[serde(default)]
globalusage: Vec<CommonsUsage>,
}
#[derive(Debug, Deserialize)]
struct CommonsCategory {
title: String,
}
#[derive(Debug, Deserialize)]
struct CommonsUsage {
#[allow(dead_code)]
title: String,
}
#[derive(Debug, Deserialize)]
struct CommonsImageInfo {
size: u64,
#[serde(default)]
duration: f64,
url: String,
descriptionurl: String,
sha1: String,
mime: String,
mediatype: String,
#[serde(default)]
extmetadata: BTreeMap<String, CommonsMetadataValue>,
}
#[derive(Debug, Deserialize)]
struct CommonsMetadataValue {
#[serde(default)]
value: String,
}
fn commons_summary(page: CommonsPage) -> Result<Option<SoundSummary>> {
if page.missing {
return Ok(None);
}
let Some(pageid) = page.pageid else {
return Ok(None);
};
let Some(info) = page.imageinfo.into_iter().next() else {
return Ok(None);
};
if info.mediatype != "AUDIO" || info.size == 0 {
return Ok(None);
}
trusted_download_url(&info.url)?;
let license_name = metadata_value(&info.extmetadata, "LicenseShortName");
let license_url = metadata_value(&info.extmetadata, "LicenseUrl");
let Some((license, license_url, attribution_required)) =
allowed_license(&license_name, &license_url)
else {
return Ok(None);
};
if info.sha1.len() != 40 || !info.sha1.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err(SoundForgeError::VerificationFailed {
path: page.title,
message: "Commons SHA-1 is not 40 hexadecimal characters".to_string(),
});
}
let creator = sanitize_html(&metadata_value(&info.extmetadata, "Artist"));
let creator = if creator.is_empty() {
"Unknown creator via Wikimedia Commons".to_string()
} else {
format!("{creator} via Wikimedia Commons")
};
let description = sanitize_html(&metadata_value(&info.extmetadata, "ImageDescription"));
let categories = page
.categories
.into_iter()
.map(|category| {
category
.title
.strip_prefix("Category:")
.unwrap_or(&category.title)
.to_string()
})
.collect::<Vec<_>>();
Ok(Some(SoundSummary {
provider: SoundProvider::WikimediaCommons,
id: pageid.to_string(),
title: page.title,
description,
categories,
usage_count: page.globalusage.len() as u64,
source_url: info.descriptionurl,
rights: SoundRights {
license,
license_url,
attribution: creator,
attribution_required,
provider_terms_url: COMMONS_REUSE_URL.to_string(),
},
file: SoundFile {
url: info.url,
size: info.size,
sha1: info.sha1.to_ascii_lowercase(),
mime: info.mime,
duration_ms: (info.duration.max(0.0) * 1000.0).round() as u64,
},
}))
}
fn allowed_license(name: &str, url: &str) -> Option<(String, String, bool)> {
let normalized = sanitize_html(name).to_ascii_lowercase();
if normalized == "cc0" || normalized.starts_with("cc0 ") {
return Some((
"CC0-1.0".to_string(),
"https://creativecommons.org/publicdomain/zero/1.0/".to_string(),
false,
));
}
if normalized == "public domain" || normalized == "public domain mark 1.0" {
return Some((
"PDM-1.0".to_string(),
"https://creativecommons.org/publicdomain/mark/1.0/".to_string(),
false,
));
}
for (prefix, id) in [("cc by-sa ", "CC-BY-SA"), ("cc by ", "CC-BY")] {
if let Some(version) = normalized.strip_prefix(prefix) {
if matches!(version, "2.0" | "2.5" | "3.0" | "4.0") {
let license_url = if url.trim().is_empty() {
format!(
"https://creativecommons.org/licenses/{}/{version}/",
if id == "CC-BY-SA" { "by-sa" } else { "by" }
)
} else {
sanitize_html(url)
};
return Some((format!("{id}-{version}"), license_url, true));
}
}
}
None
}
fn rank_sounds(sounds: &mut Vec<SoundSummary>, target: &SoundTarget, kind: SoundKind) {
match target {
SoundTarget::Search(query) => {
sounds.retain(|sound| relevance_score(sound, query, kind).is_some());
sounds.sort_by(|left, right| {
relevance_score(right, query, kind)
.cmp(&relevance_score(left, query, kind))
.then_with(|| right.usage_count.cmp(&left.usage_count))
.then_with(|| left.id.cmp(&right.id))
});
}
SoundTarget::Category(_) | SoundTarget::Top => sounds.sort_by(|left, right| {
right
.usage_count
.cmp(&left.usage_count)
.then_with(|| left.id.cmp(&right.id))
}),
SoundTarget::Latest | SoundTarget::File(_) => {}
}
}
fn relevance_score(sound: &SoundSummary, query: &str, kind: SoundKind) -> Option<u64> {
let query = normalized_text(query);
let terms = query.split_whitespace().collect::<BTreeSet<_>>();
if terms.is_empty() {
return None;
}
let title = normalized_text(&sound.title);
let categories = normalized_text(&sound.categories.join(" "));
let description = normalized_text(&sound.description);
let all = format!("{title} {categories} {description}");
if !terms.iter().all(|term| all.contains(term)) {
return None;
}
let mut score: u64 = if title == query {
600
} else if title.contains(&query) {
500
} else if terms.iter().all(|term| title.contains(term)) {
450
} else if terms
.iter()
.all(|term| title.contains(term) || categories.contains(term))
{
400
} else if terms.iter().any(|term| title.contains(term)) {
300
} else {
100
};
match kind {
SoundKind::Music => {
if ["music", "song", "instrument", "orchestra", "audio"]
.iter()
.any(|term| categories.contains(term) || title.contains(term))
{
score += 40;
}
}
SoundKind::GameAudio => {
if [
"sound effect",
"sound effects",
"game",
"ambience",
"ambient sound",
"ui sound",
"audio files by",
]
.iter()
.any(|term| categories.contains(term) || title.contains(term))
{
score += 75;
}
if [
"science",
"scientific",
"speech",
"pronunciation",
"audiobook",
"lecture",
]
.iter()
.any(|term| all.contains(term))
{
score = score.saturating_sub(100);
}
}
SoundKind::Any => {}
}
Some(score)
}
fn commons_api_url() -> reqwest::Url {
reqwest::Url::parse(COMMONS_API).expect("static Commons API URL")
}
fn append_common_query(url: &mut reqwest::Url) {
url.query_pairs_mut()
.append_pair("action", "query")
.append_pair("prop", "imageinfo|categories|globalusage")
.append_pair(
"iiprop",
"url|size|sha1|mime|mediatype|extmetadata|commonmetadata",
)
.append_pair(
"iiextmetadatafilter",
"Artist|LicenseShortName|LicenseUrl|UsageTerms|AttributionRequired|Categories|ImageDescription|Credit",
)
.append_pair("cllimit", "max")
.append_pair("gulimit", "max")
.append_pair("format", "json")
.append_pair("formatversion", "2")
.append_pair("maxlag", "5");
}
fn parse_commons_url(input: &str) -> Result<SoundTarget> {
let url = reqwest::Url::parse(input)
.map_err(|_| SoundForgeError::InvalidTarget(input.to_string()))?;
if !secure_https_url(&url) || url.host_str() != Some(COMMONS_HOST) {
return Err(SoundForgeError::InvalidTarget(input.to_string()));
}
let segments = url
.path_segments()
.map(|segments| segments.filter(|part| !part.is_empty()).collect::<Vec<_>>())
.unwrap_or_default();
if segments.len() != 2 || segments[0] != "wiki" {
return Err(SoundForgeError::InvalidTarget(input.to_string()));
}
let title = urlencoding::decode(segments[1])
.map_err(|_| SoundForgeError::InvalidTarget(input.to_string()))?
.into_owned();
if !title.starts_with("File:") {
return Err(SoundForgeError::InvalidTarget(input.to_string()));
}
non_empty_target(&title, SoundTarget::File)
}
fn non_empty_target(value: &str, constructor: fn(String) -> SoundTarget) -> Result<SoundTarget> {
let value = value.trim();
if value.is_empty() {
Err(SoundForgeError::InvalidTarget(value.to_string()))
} else {
Ok(constructor(value.to_string()))
}
}
fn normalized_file_title(value: &str) -> String {
let value = value.trim().replace(' ', "_");
if value.to_ascii_lowercase().starts_with("file:") {
format!("File:{}", &value[5..])
} else {
format!("File:{value}")
}
}
fn escape_search_phrase(value: &str) -> String {
value.replace(['"', '\\'], " ")
}
fn metadata_value(metadata: &BTreeMap<String, CommonsMetadataValue>, key: &str) -> String {
metadata
.get(key)
.map(|value| value.value.clone())
.unwrap_or_default()
}
fn sanitize_html(value: &str) -> String {
let mut output = String::new();
let mut in_tag = false;
for character in value.chars() {
match character {
'<' => in_tag = true,
'>' => {
in_tag = false;
output.push(' ');
}
_ if !in_tag => output.push(character),
_ => {}
}
}
output
.replace("&", "&")
.replace(""", "\"")
.replace("'", "'")
.replace(" ", " ")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
fn normalized_text(value: &str) -> String {
value
.chars()
.map(|character| {
if character.is_alphanumeric() {
character.to_ascii_lowercase()
} else {
' '
}
})
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
fn validate_summary(sound: &SoundSummary) -> Result<()> {
let source_url = reqwest::Url::parse(&sound.source_url).ok();
if sound.provider != SoundProvider::WikimediaCommons
|| sound.id.parse::<u64>().is_err()
|| source_url
.as_ref()
.is_none_or(|url| !secure_https_url(url) || url.host_str() != Some(COMMONS_HOST))
|| sound.rights.attribution.trim().is_empty()
|| sound.rights.provider_terms_url != COMMONS_REUSE_URL
|| sound.file.size == 0
|| sound.file.sha1.len() != 40
|| !sound.file.sha1.bytes().all(|byte| byte.is_ascii_hexdigit())
|| !allowed_license_id(&sound.rights.license, &sound.rights.license_url)
{
return Err(SoundForgeError::VerificationFailed {
path: sound.id.clone(),
message: "sound identity, license, or file metadata is invalid".to_string(),
});
}
trusted_download_url(&sound.file.url)?;
Ok(())
}
fn allowed_license_id(id: &str, license_url: &str) -> bool {
let valid_id = matches!(id, "CC0-1.0" | "PDM-1.0")
|| ["CC-BY-", "CC-BY-SA-"].iter().any(|prefix| {
id.strip_prefix(prefix)
.is_some_and(|version| matches!(version, "2.0" | "2.5" | "3.0" | "4.0"))
});
let Ok(url) = reqwest::Url::parse(license_url) else {
return false;
};
valid_id
&& secure_https_url(&url)
&& matches!(
url.host_str(),
Some("creativecommons.org" | "www.creativecommons.org")
)
}
fn trusted_api_url(url: &str) -> Result<reqwest::Url> {
let url =
reqwest::Url::parse(url).map_err(|_| SoundForgeError::UntrustedUrl(url.to_string()))?;
if !secure_https_url(&url) || url.host_str() != Some(COMMONS_HOST) || url.path() != "/w/api.php"
{
return Err(SoundForgeError::UntrustedUrl(url.to_string()));
}
Ok(url)
}
fn trusted_download_url(url: &str) -> Result<reqwest::Url> {
let url =
reqwest::Url::parse(url).map_err(|_| SoundForgeError::UntrustedUrl(url.to_string()))?;
if !secure_https_url(&url) || url.host_str() != Some(COMMONS_DOWNLOAD_HOST) {
return Err(SoundForgeError::UntrustedUrl(url.to_string()));
}
Ok(url)
}
fn secure_https_url(url: &reqwest::Url) -> bool {
url.scheme() == "https"
&& url.port().is_none_or(|port| port == 443)
&& url.username().is_empty()
&& url.password().is_none()
}
struct DownloadedFile {
bytes: u64,
blake3: String,
}
fn download_file<F>(
client: &Client,
sound: &SoundSummary,
output: &Path,
max_download_bytes: u64,
mut progress: F,
) -> Result<DownloadedFile>
where
F: FnMut(u64),
{
trusted_download_url(&sound.file.url)?;
let mut response = client.get(&sound.file.url).send()?;
trusted_download_url(response.url().as_str())?;
if !response.status().is_success() {
let status = response.status().as_u16();
return Err(SoundForgeError::ProviderApi {
provider: sound.provider.slug(),
status,
body: bounded_error_body(response),
});
}
if response
.content_length()
.is_some_and(|length| length != sound.file.size)
{
return Err(SoundForgeError::VerificationFailed {
path: sound.id.clone(),
message: "server Content-Length differs from Commons metadata".to_string(),
});
}
let partial = partial_path(output);
let mut file = File::create(&partial)?;
let mut sha1 = Sha1::new();
let mut blake3 = Blake3::new();
let mut bytes_written = 0u64;
let mut last_emit = 0u64;
let mut magic = Vec::with_capacity(16);
let mut buffer = [0u8; 64 * 1024];
loop {
let read = match response.read(&mut buffer) {
Ok(read) => read,
Err(error) => {
drop(file);
let _ = fs::remove_file(&partial);
return Err(SoundForgeError::Request(error_chain(&error)));
}
};
if read == 0 {
break;
}
file.write_all(&buffer[..read])?;
if magic.len() < 16 {
let retained = (16 - magic.len()).min(read);
magic.extend_from_slice(&buffer[..retained]);
}
sha1.update(&buffer[..read]);
blake3.update(&buffer[..read]);
bytes_written = bytes_written.saturating_add(read as u64);
if bytes_written > sound.file.size
|| (max_download_bytes > 0 && bytes_written > max_download_bytes)
{
drop(file);
let _ = fs::remove_file(&partial);
return Err(SoundForgeError::DownloadLimitExceeded {
size: bytes_written,
limit: if max_download_bytes == 0 {
sound.file.size
} else {
max_download_bytes.min(sound.file.size)
},
});
}
if bytes_written.saturating_sub(last_emit) >= PROGRESS_GRANULARITY {
progress(bytes_written);
last_emit = bytes_written;
}
}
if bytes_written != sound.file.size {
drop(file);
let _ = fs::remove_file(&partial);
return Err(SoundForgeError::VerificationFailed {
path: sound.id.clone(),
message: format!(
"expected {} bytes but received {bytes_written}",
sound.file.size
),
});
}
if !valid_audio_magic(&sound.file.mime, &magic) {
drop(file);
let _ = fs::remove_file(&partial);
return Err(SoundForgeError::VerificationFailed {
path: sound.id.clone(),
message: format!(
"downloaded bytes do not match audio MIME type '{}'",
sound.file.mime
),
});
}
let provider_sha1 = format!("{:x}", sha1.finalize());
if !provider_sha1.eq_ignore_ascii_case(&sound.file.sha1) {
drop(file);
let _ = fs::remove_file(&partial);
return Err(SoundForgeError::VerificationFailed {
path: sound.id.clone(),
message: format!(
"expected Commons SHA-1 {} but received {provider_sha1}",
sound.file.sha1
),
});
}
file.flush()?;
file.sync_all()?;
drop(file);
if last_emit != bytes_written {
progress(bytes_written);
}
fs::rename(&partial, output)?;
Ok(DownloadedFile {
bytes: bytes_written,
blake3: blake3.finalize().to_hex().to_string(),
})
}
fn valid_audio_magic(mime: &str, bytes: &[u8]) -> bool {
match mime {
"audio/mpeg" => {
bytes.starts_with(b"ID3")
|| bytes
.get(..2)
.is_some_and(|prefix| prefix[0] == 0xff && prefix[1] & 0xe0 == 0xe0)
}
"audio/ogg" | "application/ogg" => bytes.starts_with(b"OggS"),
"audio/flac" | "audio/x-flac" => bytes.starts_with(b"fLaC"),
"audio/wav" | "audio/x-wav" | "audio/wave" => {
bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WAVE")
}
"audio/webm" => bytes.starts_with(&[0x1a, 0x45, 0xdf, 0xa3]),
"audio/mp4" | "audio/x-m4a" => bytes.get(4..8) == Some(b"ftyp"),
_ => true,
}
}
fn extension_for(mime: &str, url: &str) -> Result<&'static str> {
match mime {
"audio/mpeg" => Ok("mp3"),
"audio/ogg" | "application/ogg" => Ok("ogg"),
"audio/flac" | "audio/x-flac" => Ok("flac"),
"audio/wav" | "audio/x-wav" | "audio/wave" => Ok("wav"),
"audio/webm" => Ok("webm"),
"audio/mp4" | "audio/x-m4a" => Ok("m4a"),
_ => {
let extension = reqwest::Url::parse(url)
.ok()
.and_then(|url| {
Path::new(url.path())
.extension()
.and_then(|extension| extension.to_str())
.map(str::to_ascii_lowercase)
})
.unwrap_or_default();
match extension.as_str() {
"mp3" => Ok("mp3"),
"ogg" | "oga" => Ok("ogg"),
"flac" => Ok("flac"),
"wav" => Ok("wav"),
"webm" => Ok("webm"),
"m4a" => Ok("m4a"),
_ => Err(SoundForgeError::InvalidOption(format!(
"unsupported audio MIME type '{mime}'"
))),
}
}
}
}
fn prepare_output(output: &Path) -> Result<()> {
if output.exists() && !output.is_dir() {
return Err(SoundForgeError::OutputNotDirectory(output.to_path_buf()));
}
fs::create_dir_all(output)?;
Ok(())
}
fn safe_id(value: &str) -> Result<String> {
if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(SoundForgeError::UnsafePath(value.to_string()));
}
Ok(value.to_string())
}
fn safe_relative_path(value: &str) -> Result<PathBuf> {
let path = Path::new(value);
if path.is_absolute() || value.len() > 256 {
return Err(SoundForgeError::UnsafePath(value.to_string()));
}
let mut safe = PathBuf::new();
for component in path.components() {
match component {
Component::Normal(part) if !part.is_empty() => safe.push(part),
_ => return Err(SoundForgeError::UnsafePath(value.to_string())),
}
}
if safe.as_os_str().is_empty() {
return Err(SoundForgeError::UnsafePath(value.to_string()));
}
Ok(safe)
}
fn partial_path(path: &Path) -> PathBuf {
path.with_extension(format!(
"{}.soundforge-part",
path.extension()
.and_then(|value| value.to_str())
.unwrap_or("audio")
))
}
fn staging_path(output: &Path, id: &str) -> Result<PathBuf> {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|error| SoundForgeError::InvalidOption(error.to_string()))?
.as_nanos();
Ok(output.join(format!(
".soundforge-{}-{}-{nonce}.staging",
safe_id(id)?,
std::process::id()
)))
}
fn write_manifest(directory: &Path, manifest: &SoundManifest) -> Result<()> {
let payload = serde_json::to_vec_pretty(manifest)?;
if payload.len() as u64 > MAX_MANIFEST_BYTES {
return Err(SoundForgeError::ResponseLimitExceeded {
label: MANIFEST_NAME.to_string(),
size: payload.len() as u64,
limit: MAX_MANIFEST_BYTES,
});
}
let temporary = directory.join(format!("{MANIFEST_NAME}.tmp"));
let mut file = File::create(&temporary)?;
file.write_all(&payload)?;
file.flush()?;
file.sync_all()?;
drop(file);
fs::rename(temporary, directory.join(MANIFEST_NAME))?;
Ok(())
}
fn read_manifest(directory: &Path) -> Result<SoundManifest> {
let directory = open_archive_directory(directory)?;
let metadata = directory.symlink_metadata(MANIFEST_NAME)?;
if !metadata.is_file() || metadata.file_type().is_symlink() {
return Err(SoundForgeError::UnsafePath(MANIFEST_NAME.to_string()));
}
if metadata.len() > MAX_MANIFEST_BYTES {
return Err(SoundForgeError::ResponseLimitExceeded {
label: MANIFEST_NAME.to_string(),
size: metadata.len(),
limit: MAX_MANIFEST_BYTES,
});
}
let mut contents = Vec::with_capacity(metadata.len() as usize);
directory
.open(MANIFEST_NAME)?
.take(MAX_MANIFEST_BYTES.saturating_add(1))
.read_to_end(&mut contents)?;
Ok(serde_json::from_slice(&contents)?)
}
fn validate_manifest(manifest: &SoundManifest, id: &str) -> Result<()> {
validate_summary(&manifest.sound)?;
let valid = manifest.format_version == "1"
&& manifest.complete
&& manifest.sound.id == id
&& manifest.bytes_written == manifest.sound.file.size
&& manifest.blake3.len() == 64
&& manifest.blake3.bytes().all(|byte| byte.is_ascii_hexdigit())
&& safe_relative_path(&manifest.path).is_ok()
&& !manifest.path.eq_ignore_ascii_case(MANIFEST_NAME);
if !valid {
return Err(SoundForgeError::VerificationFailed {
path: MANIFEST_NAME.to_string(),
message: "manifest identity or integrity metadata is inconsistent".to_string(),
});
}
Ok(())
}
fn verify_manifest_file(directory: &Path, manifest: &SoundManifest) -> Result<()> {
let directory = open_archive_directory(directory)?;
let relative = safe_relative_path(&manifest.path)?;
let metadata = directory.symlink_metadata(&relative)?;
if !metadata.is_file() || metadata.file_type().is_symlink() {
return Err(SoundForgeError::UnsafePath(manifest.path.clone()));
}
let mut opened = directory.open(&relative)?;
let mut sha1 = Sha1::new();
let mut blake3 = Blake3::new();
let mut bytes = 0u64;
let mut buffer = [0u8; 64 * 1024];
loop {
let read = opened.read(&mut buffer)?;
if read == 0 {
break;
}
bytes = bytes.saturating_add(read as u64);
sha1.update(&buffer[..read]);
blake3.update(&buffer[..read]);
}
let provider_sha1 = format!("{:x}", sha1.finalize());
let local_blake3 = blake3.finalize().to_hex().to_string();
if bytes != manifest.bytes_written
|| !provider_sha1.eq_ignore_ascii_case(&manifest.sound.file.sha1)
|| !local_blake3.eq_ignore_ascii_case(&manifest.blake3)
{
return Err(SoundForgeError::VerificationFailed {
path: manifest.path.clone(),
message: "archived bytes or checksums do not match the manifest".to_string(),
});
}
Ok(())
}
fn ensure_replaceable(directory: &Path, id: &str) -> Result<()> {
if !directory.exists() {
return Ok(());
}
let manifest =
read_manifest(directory).map_err(|error| SoundForgeError::UnrecognizedExistingOutput {
path: directory.to_path_buf(),
reason: error.to_string(),
})?;
validate_manifest(&manifest, id).map_err(|error| SoundForgeError::UnrecognizedExistingOutput {
path: directory.to_path_buf(),
reason: error.to_string(),
})
}
fn open_archive_directory(path: &Path) -> Result<CapDir> {
let parent_path = path.parent().unwrap_or_else(|| Path::new("."));
let name = path
.file_name()
.ok_or_else(|| SoundForgeError::UnsafePath(path.display().to_string()))?;
let parent = CapDir::open_ambient_dir(parent_path, ambient_authority())?;
let directory = parent.open_dir(name)?;
if !directory.dir_metadata()?.is_dir() {
return Err(SoundForgeError::UnsafePath(path.display().to_string()));
}
Ok(directory)
}
fn commit_staging(staging: &Path, destination: &Path) -> Result<()> {
if !destination.exists() {
fs::rename(staging, destination)?;
return Ok(());
}
let name = destination
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| SoundForgeError::UnsafePath(destination.display().to_string()))?;
let backup =
destination.with_file_name(format!(".soundforge-{name}-{}-backup", std::process::id()));
if backup.exists() {
return Err(SoundForgeError::UnrecognizedExistingOutput {
path: backup,
reason: "stale replacement backup exists".to_string(),
});
}
fs::rename(destination, &backup)?;
if let Err(error) = fs::rename(staging, destination) {
let _ = fs::rename(&backup, destination);
return Err(SoundForgeError::Io(error));
}
let _ = fs::remove_dir_all(backup);
Ok(())
}
fn progress_snapshot(
phase: ArchiveProgressPhase,
total: usize,
summary: &ArchiveSummary,
) -> ArchiveProgress {
ArchiveProgress {
phase,
total_sounds: total,
completed_sounds: summary.archived + summary.skipped + summary.failed.len(),
succeeded_sounds: summary.archived,
failed_sounds: summary.failed.len(),
bytes_written: summary.bytes_written,
active_sound: None,
active_file: None,
active_bytes_written: 0,
active_total_bytes: None,
}
}
fn emit_progress(
progress: &mut Option<&mut dyn FnMut(ArchiveProgress)>,
snapshot: ArchiveProgress,
) {
if let Some(progress) = progress.as_deref_mut() {
progress(snapshot);
}
}
fn read_response_limited(mut response: Response, limit: u64, label: &str) -> Result<Vec<u8>> {
if response
.content_length()
.is_some_and(|length| length > limit)
{
return Err(SoundForgeError::ResponseLimitExceeded {
label: label.to_string(),
size: response.content_length().unwrap_or_default(),
limit,
});
}
let mut bytes = Vec::new();
response
.by_ref()
.take(limit.saturating_add(1))
.read_to_end(&mut bytes)?;
if bytes.len() as u64 > limit {
return Err(SoundForgeError::ResponseLimitExceeded {
label: label.to_string(),
size: bytes.len() as u64,
limit,
});
}
Ok(bytes)
}
fn bounded_error_body(response: Response) -> String {
match read_response_limited(
response,
MAX_ERROR_RESPONSE_BYTES,
"provider error response",
) {
Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
Err(SoundForgeError::ResponseLimitExceeded { .. }) => {
"<error response exceeded safety limit>".to_string()
}
Err(_) => "<unable to read body>".to_string(),
}
}
fn error_chain(error: &(dyn StdError + 'static)) -> String {
let mut message = error.to_string();
let mut source = error.source();
while let Some(error) = source {
message.push_str("; caused by: ");
message.push_str(&error.to_string());
source = error.source();
}
message
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn parses_search_category_file_and_commons_url_targets() {
assert_eq!(
parse_sound_target("laser").unwrap(),
SoundTarget::Search("laser".to_string())
);
assert_eq!(
parse_sound_target("category:Sound effects").unwrap(),
SoundTarget::Category("Sound effects".to_string())
);
assert_eq!(
parse_sound_target("file:Laser (Gravity Sound).mp3").unwrap(),
SoundTarget::File("Laser (Gravity Sound).mp3".to_string())
);
assert_eq!(
parse_sound_target(
"https://commons.wikimedia.org/wiki/File:Laser_%28Gravity_Sound%29.mp3"
)
.unwrap(),
SoundTarget::File("File:Laser_(Gravity_Sound).mp3".to_string())
);
assert!(parse_sound_target("https://example.com/audio.mp3").is_err());
}
#[test]
fn accepts_only_commercial_capable_license_families() {
assert!(allowed_license("CC0 1.0", "").is_some());
assert!(allowed_license("Public domain", "").is_some());
assert!(allowed_license("CC BY 4.0", "").is_some());
assert!(allowed_license("CC BY-SA 3.0", "").is_some());
assert!(allowed_license("CC BY-NC 4.0", "").is_none());
assert!(allowed_license("CC BY-ND 4.0", "").is_none());
assert!(allowed_license("Sampling+", "").is_none());
}
#[test]
fn relevance_beats_usage_count() {
let sound = |id: &str, title: &str, description: &str, usage_count: u64| SoundSummary {
provider: SoundProvider::WikimediaCommons,
id: id.to_string(),
title: title.to_string(),
description: description.to_string(),
categories: Vec::new(),
usage_count,
source_url: format!("https://commons.wikimedia.org/wiki/File:{title}"),
rights: SoundRights {
license: "CC0-1.0".to_string(),
license_url: "https://creativecommons.org/publicdomain/zero/1.0/".to_string(),
attribution: "Creator via Wikimedia Commons".to_string(),
attribution_required: false,
provider_terms_url: COMMONS_REUSE_URL.to_string(),
},
file: SoundFile {
url: format!("https://upload.wikimedia.org/{id}.ogg"),
size: 1,
sha1: "a".repeat(40),
mime: "application/ogg".to_string(),
duration_ms: 1000,
},
};
let mut sounds = vec![
sound("1", "Laser scientific recording", "science", 10_000),
sound("2", "Laser sound effect", "game sound", 10),
];
rank_sounds(
&mut sounds,
&SoundTarget::Search("laser".to_string()),
SoundKind::GameAudio,
);
assert_eq!(sounds[0].title, "Laser sound effect");
}
#[test]
fn manifest_verification_detects_corruption() {
let temp = tempdir().unwrap();
let directory = temp.path().join("123");
fs::create_dir(&directory).unwrap();
let bytes = b"audio";
fs::write(directory.join("audio.ogg"), bytes).unwrap();
let sha1 = format!("{:x}", Sha1::digest(bytes));
let blake3 = blake3::hash(bytes).to_hex().to_string();
let manifest = SoundManifest {
format_version: "1".to_string(),
sound: SoundSummary {
provider: SoundProvider::WikimediaCommons,
id: "123".to_string(),
title: "File:Audio.ogg".to_string(),
description: String::new(),
categories: Vec::new(),
usage_count: 0,
source_url: "https://commons.wikimedia.org/wiki/File:Audio.ogg".to_string(),
rights: SoundRights {
license: "CC0-1.0".to_string(),
license_url: "https://creativecommons.org/publicdomain/zero/1.0/".to_string(),
attribution: "Creator via Wikimedia Commons".to_string(),
attribution_required: false,
provider_terms_url: COMMONS_REUSE_URL.to_string(),
},
file: SoundFile {
url: "https://upload.wikimedia.org/audio.ogg".to_string(),
size: bytes.len() as u64,
sha1,
mime: "application/ogg".to_string(),
duration_ms: 1000,
},
},
path: "audio.ogg".to_string(),
bytes_written: bytes.len() as u64,
blake3,
complete: true,
};
write_manifest(&directory, &manifest).unwrap();
verify_manifest_file(&directory, &manifest).unwrap();
fs::write(directory.join("audio.ogg"), b"evil!").unwrap();
assert!(verify_manifest_file(&directory, &manifest).is_err());
}
#[test]
#[ignore = "requires Wikimedia Commons access and downloads one small CC BY sound"]
fn live_commons_game_audio_archive_and_progress() {
let forge = SoundForge::with_user_agent(
"soundforge-live-test/0.1 (https://github.com/Tknott95/SoundForge)",
)
.unwrap()
.with_discovery_limit(5);
let sounds = forge
.discover(
&SoundTarget::Search("laser gravity sound".to_string()),
SoundKind::GameAudio,
)
.unwrap();
assert!(!sounds.is_empty());
let music = forge
.discover(
&SoundTarget::Search("Mozart symphony".to_string()),
SoundKind::Music,
)
.unwrap();
assert!(!music.is_empty());
let selected = sounds
.iter()
.find(|sound| sound.title.contains("Gravity Sound"))
.cloned()
.unwrap_or_else(|| sounds[0].clone());
let temp = tempdir().unwrap();
let options = ArchiveOptions {
output: temp.path().to_path_buf(),
skip_existing: false,
max_download_bytes: 16 * 1024 * 1024,
};
let mut saw_bytes = false;
let summary = forge
.archive_with_progress(&[selected], &options, |progress| {
saw_bytes |= progress.active_bytes_written > 0;
})
.unwrap();
assert!(summary.is_success(), "failures: {:?}", summary.failed);
assert!(saw_bytes);
let manifest = read_manifest(&summary.sounds[0].output).unwrap();
assert_eq!(manifest.sound.rights.license, "CC-BY-4.0");
assert_eq!(manifest.blake3.len(), 64);
}
}