mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 15:02:21 +02:00
refactor(rust): move inline tests and the test-only hires decoder into submodules
Inline test modules of tags/write.rs, lyrics/payloads.rs and extensions/src/files.rs become sibling tests.rs files, and the cfg(test) reference decoder of media/hires moves into hires/reference.rs. The artist-mode helpers of tags/write.rs move to write/artists.rs. No behavior or public API change; the supplement_tests module of payloads.rs is now named tests.
This commit is contained in:
1 parent
92adce43ef
commit
3f904004ff
9 files changed
+1243
-1242
No files matched your search
@@ -662,379 +662,4 @@ pub fn format_kpoe(response: &KpoeResponse, multi_person: bool, word_timing: boo
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod supplement_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn spotiflac_apple_preserves_syllables_and_separate_background_groups() {
|
||||
let raw = serde_json::json!({
|
||||
"type": "Syllable",
|
||||
"elrcMultiPerson": "[00:01.00]Lead\n[00:02.00]Echo",
|
||||
"content": [{
|
||||
"timestamp": 1009, "endtime": 4000, "agent": "v3", "oppositeTurn": true,
|
||||
"text": [
|
||||
{"text": "To", "part": true, "timestamp": 1009, "endtime": 1500},
|
||||
{"text": "gether", "timestamp": 1500, "endtime": 4000}
|
||||
],
|
||||
"backgroundLines": [
|
||||
{"timestamp": 2000, "endtime": 2500, "text": [
|
||||
{"text": "Echo", "timestamp": 2000, "endtime": 2500}
|
||||
]},
|
||||
{"timestamp": 3000, "endtime": 3500, "text": [
|
||||
{"text": "Again", "timestamp": 3000, "endtime": 3500}
|
||||
]}
|
||||
]
|
||||
}]
|
||||
})
|
||||
.to_string();
|
||||
for words in [false, true] {
|
||||
let text = format_spotiflac_apple(&raw, true, words).unwrap();
|
||||
assert!(text.starts_with("[00:01.009]v1:"));
|
||||
assert_eq!(text.matches("[bg:").count(), 2);
|
||||
let lyrics = LyricsResponse::from_text(
|
||||
&text,
|
||||
"SpotiFLAC-Mobile Lyrics",
|
||||
"SpotiFLAC-Mobile Lyrics",
|
||||
);
|
||||
assert_eq!(lyrics.lines().len(), 1);
|
||||
assert_eq!(lyrics.lines()[0].start_time_ms, 1009);
|
||||
if words {
|
||||
assert!(text.contains("To<00:01.50><00:01.50>gether"));
|
||||
} else {
|
||||
assert!(text.contains("Together"));
|
||||
}
|
||||
let solo = format_spotiflac_apple(&raw, false, words).unwrap();
|
||||
assert!(!solo.contains("[bg:"));
|
||||
assert!(!solo.contains("v1:"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spotiflac_apple_handles_line_only_unsynced_and_invalid_responses() {
|
||||
let mut raw = serde_json::json!({
|
||||
"type": "Line", "content": [{
|
||||
"timestamp": 1500, "text": [{"text": "Whole"}, {"text": "line"}]
|
||||
}]
|
||||
});
|
||||
assert_eq!(
|
||||
format_spotiflac_apple(&raw.to_string(), false, true).unwrap(),
|
||||
"[00:01.50]Whole line"
|
||||
);
|
||||
raw["type"] = "Unsynced".into();
|
||||
raw["content"][0]["timestamp"] = serde_json::Value::Null;
|
||||
let text = format_spotiflac_apple(&raw.to_string(), true, true).unwrap();
|
||||
assert_eq!(text, "Whole line");
|
||||
assert_eq!(
|
||||
LyricsResponse::from_text(&text, "Example", "Example").sync_type,
|
||||
"UNSYNCED"
|
||||
);
|
||||
for raw in [
|
||||
"<html>Error</html>",
|
||||
r#"{"error":{"code":"APPLE_AUTH_REQUIRED"}}"#,
|
||||
"{}",
|
||||
] {
|
||||
assert!(format_spotiflac_apple(raw, true, true).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apple_agents_restore_vocal_sides_without_reformatting_word_times() {
|
||||
let mut raw = serde_json::json!({
|
||||
"type": "Syllable",
|
||||
"elrcMultiPerson": "[00:01.01]v1: <00:01.009>Lead<00:02.00>\n[bg:<00:01.50>Echo<00:02.50>]\n[00:03.00]v2: <00:03.00>Guest<00:04.00>\n[00:05.00]v2: <00:05.00>Together<00:06.00>\n[00:07.00]v2: Third",
|
||||
"content": [
|
||||
{"timestamp": 1009, "agent": "lead"},
|
||||
{"timestamp": 3000, "agent": "guest"},
|
||||
{"timestamp": 5000, "agent": "all"},
|
||||
{"timestamp": 7000, "agent": "third"}
|
||||
],
|
||||
"metadata": {"agents": [
|
||||
{"id": "lead", "type": "person"},
|
||||
{"id": "guest", "type": "person"},
|
||||
{"id": "all", "type": "group"},
|
||||
{"id": "third", "type": "person"}
|
||||
]}
|
||||
});
|
||||
let text = format_apple(&raw.to_string(), true, true).unwrap();
|
||||
assert_eq!(
|
||||
text,
|
||||
"[00:01.01]v1: <00:01.009>Lead<00:02.00>\n[bg:<00:01.50>Echo<00:02.50>]\n[00:03.00]v2: <00:03.00>Guest<00:04.00>\n[00:05.00]v1: <00:05.00>Together<00:06.00>\n[00:07.00]v3: Third"
|
||||
);
|
||||
let lyrics = LyricsResponse::from_text(&text, "Apple Music", "Apple Music");
|
||||
let stored = lrc::with_metadata(&lyrics, "Track", "Artist");
|
||||
assert!(stored.contains("[00:05.00]v1: <00:05.00>Together<00:06.00>"));
|
||||
assert!(stored.contains("[bg:<00:01.50>Echo<00:02.50>]"));
|
||||
|
||||
// Some responses carry vocal labels only in the regular eLRC field.
|
||||
// It must use the same agent correction as the multi-person field.
|
||||
raw["elrc"] = raw
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("elrcMultiPerson")
|
||||
.unwrap();
|
||||
assert_eq!(format_apple(&raw.to_string(), true, true).unwrap(), text);
|
||||
assert_eq!(
|
||||
format_apple(&raw.to_string(), false, true).unwrap(),
|
||||
raw["elrc"].as_str().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apple_v3_does_not_depend_on_agent_order_or_group_metadata() {
|
||||
for agents in [
|
||||
serde_json::Value::Null,
|
||||
serde_json::json!([
|
||||
{"id": "v1", "type": "person"},
|
||||
{"id": "v3", "type": "person"},
|
||||
{"id": "v2", "type": "person"}
|
||||
]),
|
||||
] {
|
||||
let raw = serde_json::json!({
|
||||
"type": "Syllable",
|
||||
"elrcMultiPerson": "[00:01.00]v2:Together\n[00:02.00]v1:Guest",
|
||||
"content": [{"timestamp": 1000, "agent": "v3"}, {"timestamp": 2000, "agent": "v2"}],
|
||||
"metadata": {"agents": agents}
|
||||
});
|
||||
assert_eq!(
|
||||
format_apple(&raw.to_string(), true, true).unwrap(),
|
||||
"[00:01.00]v1:Together\n[00:02.00]v2:Guest"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apple_content_fallback_honors_agents_only_when_multi_person_is_enabled() {
|
||||
let raw = serde_json::json!({
|
||||
"type": "Syllable",
|
||||
"content": [{"timestamp": 1000, "oppositeTurn": true, "agent": "group", "text": [
|
||||
{"text": "Together", "timestamp": 1000, "endtime": 2000}
|
||||
]}],
|
||||
"metadata": {"agents": [{"id": "group", "type": "group"}]}
|
||||
})
|
||||
.to_string();
|
||||
for timing in [false, true] {
|
||||
assert!(
|
||||
format_apple(&raw, true, timing)
|
||||
.unwrap()
|
||||
.starts_with("[00:01.00]v1:")
|
||||
);
|
||||
assert!(!format_apple(&raw, false, timing).unwrap().contains("v1:"));
|
||||
assert!(!format_apple(&raw, false, timing).unwrap().contains("v2:"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apple_voice_correction_preserves_unknown_or_ambiguous_lines() {
|
||||
let text = "[00:01.00]v2: Unknown\n[00:02.00]v2: Too far\n[00:03.00]v2: Ambiguous\n[00:04.00]v2: Exact";
|
||||
let mut raw = serde_json::json!({"elrcMultiPerson": text});
|
||||
assert_eq!(format_apple(&raw.to_string(), true, true).unwrap(), text);
|
||||
raw["metadata"] = serde_json::json!({"agents": [
|
||||
{"id": "v1", "type": "person"}, {"id": "v2", "type": "person"},
|
||||
{"id": "v3", "type": "group"}
|
||||
]});
|
||||
raw["content"] = serde_json::json!([
|
||||
{"timestamp": 1000, "agent": "unknown"},
|
||||
{"timestamp": 2011, "agent": "v3"},
|
||||
{"timestamp": 3000, "agent": "v3"},
|
||||
{"timestamp": 3000, "agent": "v2"},
|
||||
{"timestamp": 3999, "agent": "v2"},
|
||||
{"timestamp": 4000, "agent": "v3"}
|
||||
]);
|
||||
assert_eq!(
|
||||
format_apple(&raw.to_string(), true, true).unwrap(),
|
||||
text.replace("[00:04.00]v2:", "[00:04.00]v1:")
|
||||
);
|
||||
}
|
||||
|
||||
fn payload(lang: &str) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "Syllable",
|
||||
"elrc": "[00:01.010]<00:01.009>Original <00:01.307>\n[00:02.000]Next",
|
||||
"content": [{"timestamp": 1009, "text": [
|
||||
{"text": "Original", "timestamp": 1009, "endtime": 1307}
|
||||
]}, {"timestamp": 2001, "text": [{"text": "Next"}]}],
|
||||
"metadata": {
|
||||
"language": lang,
|
||||
"transliterations": [{"lang": format!("{lang}-Latn"), "lines": [
|
||||
{"timestamp": 1009, "text": "Romanized"},
|
||||
{"timestamp": 2001, "text": "Next romanized"}
|
||||
]}],
|
||||
"translations": [{"lang": "en-US", "lines": [
|
||||
{"timestamp": 1009, "text": "English"},
|
||||
{"timestamp": 2001, "text": "Next English"}
|
||||
]}]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supplements_follow_output_timestamps_in_both_apple_modes() {
|
||||
for lang in ["ja", "ko", "zh-Hans"] {
|
||||
for word_timing in [false, true] {
|
||||
let raw = payload(lang).to_string();
|
||||
let text = format_apple(&raw, false, word_timing).unwrap();
|
||||
let mut lyrics = LyricsResponse::from_text(&text, "Apple Music", "Apple Music");
|
||||
apple_supplements(&raw, &mut lyrics);
|
||||
assert_eq!(lyrics.lines()[0].romanization.as_deref(), Some("Romanized"));
|
||||
assert_eq!(
|
||||
lyrics.lines()[1].translation.as_deref(),
|
||||
Some("Next English")
|
||||
);
|
||||
let start = if word_timing { 1010 } else { 1009 };
|
||||
assert_eq!(lyrics.lines()[0].start_time_ms, start);
|
||||
let exported = lrc::with_metadata(&lyrics, "Track", "Artist");
|
||||
assert!(exported.contains(&format!("[x-romaji:{start}:Um9tYW5pemVk]")));
|
||||
assert!(exported.contains(&lrc::timestamp(start)));
|
||||
if word_timing {
|
||||
assert!(exported.contains("<00:01.307>"));
|
||||
}
|
||||
// Persistence uses this same JSON response contract.
|
||||
let restored: LyricsResponse =
|
||||
serde_json::from_str(&serde_json::to_string(&lyrics).unwrap()).unwrap();
|
||||
assert_eq!(restored, lyrics);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearest_matching_is_bounded_and_exact_matches_win() {
|
||||
let mut raw = payload("ja");
|
||||
raw["metadata"]["transliterations"][0]["lines"] = serde_json::json!([
|
||||
{"timestamp": 1009, "text": "Rounded"},
|
||||
{"timestamp": 1010, "text": "Exact"},
|
||||
{"timestamp": 1001, "text": "Less accurate"},
|
||||
{"timestamp": 2011, "text": "Too far"},
|
||||
{"timestamp": 2990, "text": "Boundary"},
|
||||
{"timestamp": 4005, "text": "Tie"}
|
||||
]);
|
||||
let mut lyrics = LyricsResponse::from_text(
|
||||
"[00:01.010]A\n[00:02.00]B\n[00:03.00]C\n[00:04.00]D\n[00:04.010]E",
|
||||
"",
|
||||
"",
|
||||
);
|
||||
apple_supplements(&raw.to_string(), &mut lyrics);
|
||||
let actual: Vec<_> = lyrics
|
||||
.lines()
|
||||
.iter()
|
||||
.map(|line| line.romanization.as_deref())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
actual,
|
||||
[Some("Exact"), None, Some("Boundary"), Some("Tie"), None]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_optional_lines_do_not_hide_other_supplements() {
|
||||
let mut raw = payload("ja");
|
||||
raw["metadata"]["transliterations"][0]["lines"] = serde_json::json!([
|
||||
null, {"text": "Missing time"}, {"timestamp": "1009", "text": "Bad time"},
|
||||
{"timestamp": 1009, "text": []}, {"timestamp": -1, "text": "Negative"},
|
||||
{"timestamp": 2001, "text": " Next romanized "}
|
||||
]);
|
||||
let mut lyrics = LyricsResponse::from_text("[00:01.01]A\n[00:02.00]B", "", "");
|
||||
apple_supplements(&raw.to_string(), &mut lyrics);
|
||||
assert!(lyrics.lines()[0].romanization.is_none());
|
||||
assert_eq!(lyrics.lines()[0].translation.as_deref(), Some("English"));
|
||||
assert_eq!(
|
||||
lyrics.lines()[1].romanization.as_deref(),
|
||||
Some("Next romanized")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_selection_prefers_song_language_and_usable_groups() {
|
||||
let mut raw = payload("ja");
|
||||
raw["metadata"]["transliterations"] = serde_json::json!([
|
||||
{"lang": "ja-Latn", "lines": []},
|
||||
{"lang": "ko-Latn", "lines": [{"timestamp": 1009, "text": "Other language"}]},
|
||||
{"lang": "ja-Kana", "lines": [{"timestamp": 1009, "text": "Not Latin"}]},
|
||||
{"lang": "ja-Latn", "lines": [{"timestamp": 1009, "text": "Romanized"}]}
|
||||
]);
|
||||
raw["metadata"]["translations"] = serde_json::json!([
|
||||
{"lang": "en", "lines": null},
|
||||
{"lang": "fr", "lines": [{"timestamp": 1009, "text": "French"}]},
|
||||
{"lang": "en-GB", "lines": [{"timestamp": 1009, "text": "English"}]}
|
||||
]);
|
||||
let mut lyrics = LyricsResponse::from_text("[00:01.01]Original", "", "");
|
||||
apple_supplements(&raw.to_string(), &mut lyrics);
|
||||
assert_eq!(lyrics.lines()[0].romanization.as_deref(), Some("Romanized"));
|
||||
assert_eq!(lyrics.lines()[0].translation.as_deref(), Some("English"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_or_invalid_metadata_preserves_original_lyrics() {
|
||||
for raw in [
|
||||
"{}",
|
||||
"not json",
|
||||
r#"{"metadata":null}"#,
|
||||
r#"{"metadata":{"translations":5}}"#,
|
||||
] {
|
||||
let mut lyrics = LyricsResponse::from_text("[00:01.00]Original", "", "");
|
||||
let original = lyrics.clone();
|
||||
apple_supplements(raw, &mut lyrics);
|
||||
assert_eq!(lyrics, original);
|
||||
}
|
||||
let mut plain = LyricsResponse::from_text("Original", "", "");
|
||||
apple_supplements(&payload("ja").to_string(), &mut plain);
|
||||
assert!(plain.lines()[0].romanization.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn romanization_spans_keep_syllables_spaces_and_original_word_times() {
|
||||
let mut raw = payload("ja");
|
||||
raw["metadata"]["transliterations"][0]["lines"][0] = serde_json::json!({
|
||||
"timestamp": 1009, "text": "Roma nized", "spans": [
|
||||
{"text": "Ro", "begin": 1009, "end": 1107},
|
||||
{"text": "ma", "begin": 1107, "end": 1307},
|
||||
{"text": "nized", "begin": 2003, "end": 2497}
|
||||
]
|
||||
});
|
||||
let mut lyrics = LyricsResponse::from_text("[00:01.010]Original", "", "");
|
||||
apple_supplements(&raw.to_string(), &mut lyrics);
|
||||
let words = lyrics.lines()[0].romanization_words.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
words
|
||||
.iter()
|
||||
.map(|word| word.text.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["Ro", "ma ", "nized"]
|
||||
);
|
||||
assert_eq!(words[0].start_time_ms, 1009);
|
||||
assert_eq!(words[1].end_time_ms, 1307);
|
||||
assert_eq!(words[2].start_time_ms, 2003);
|
||||
assert_eq!(words[2].end_time_ms, 2497);
|
||||
|
||||
let output = lrc::with_metadata(&lyrics, "Track", "Artist");
|
||||
let encoded = output
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
line.strip_prefix("[x-romaji-words:1010:")
|
||||
.and_then(|line| line.strip_suffix(']'))
|
||||
})
|
||||
.unwrap();
|
||||
use base64::Engine;
|
||||
let decoded = base64::engine::general_purpose::STANDARD
|
||||
.decode(encoded)
|
||||
.unwrap();
|
||||
let restored: Vec<LyricsWord> = serde_json::from_slice(&decoded).unwrap();
|
||||
assert_eq!(&restored, words);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_romanization_timing_falls_back_to_readable_text() {
|
||||
for spans in [
|
||||
serde_json::json!([{"text": "Other", "begin": 1009, "end": 1307}]),
|
||||
serde_json::json!([{"text": "Romanized", "begin": 1009, "end": 900}]),
|
||||
serde_json::json!([{"text": "Romanized", "begin": 1009}]),
|
||||
serde_json::json!([{"text": "Ro", "begin": 1009, "end": 1307}]),
|
||||
] {
|
||||
let mut raw = payload("ja");
|
||||
raw["metadata"]["transliterations"][0]["lines"][0]["spans"] = spans;
|
||||
let mut lyrics = LyricsResponse::from_text("[00:01.010]Original", "", "");
|
||||
apple_supplements(&raw.to_string(), &mut lyrics);
|
||||
assert_eq!(lyrics.lines()[0].romanization.as_deref(), Some("Romanized"));
|
||||
assert!(lyrics.lines()[0].romanization_words.is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
@@ -0,0 +1,371 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn spotiflac_apple_preserves_syllables_and_separate_background_groups() {
|
||||
let raw = serde_json::json!({
|
||||
"type": "Syllable",
|
||||
"elrcMultiPerson": "[00:01.00]Lead\n[00:02.00]Echo",
|
||||
"content": [{
|
||||
"timestamp": 1009, "endtime": 4000, "agent": "v3", "oppositeTurn": true,
|
||||
"text": [
|
||||
{"text": "To", "part": true, "timestamp": 1009, "endtime": 1500},
|
||||
{"text": "gether", "timestamp": 1500, "endtime": 4000}
|
||||
],
|
||||
"backgroundLines": [
|
||||
{"timestamp": 2000, "endtime": 2500, "text": [
|
||||
{"text": "Echo", "timestamp": 2000, "endtime": 2500}
|
||||
]},
|
||||
{"timestamp": 3000, "endtime": 3500, "text": [
|
||||
{"text": "Again", "timestamp": 3000, "endtime": 3500}
|
||||
]}
|
||||
]
|
||||
}]
|
||||
})
|
||||
.to_string();
|
||||
for words in [false, true] {
|
||||
let text = format_spotiflac_apple(&raw, true, words).unwrap();
|
||||
assert!(text.starts_with("[00:01.009]v1:"));
|
||||
assert_eq!(text.matches("[bg:").count(), 2);
|
||||
let lyrics =
|
||||
LyricsResponse::from_text(&text, "SpotiFLAC-Mobile Lyrics", "SpotiFLAC-Mobile Lyrics");
|
||||
assert_eq!(lyrics.lines().len(), 1);
|
||||
assert_eq!(lyrics.lines()[0].start_time_ms, 1009);
|
||||
if words {
|
||||
assert!(text.contains("To<00:01.50><00:01.50>gether"));
|
||||
} else {
|
||||
assert!(text.contains("Together"));
|
||||
}
|
||||
let solo = format_spotiflac_apple(&raw, false, words).unwrap();
|
||||
assert!(!solo.contains("[bg:"));
|
||||
assert!(!solo.contains("v1:"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spotiflac_apple_handles_line_only_unsynced_and_invalid_responses() {
|
||||
let mut raw = serde_json::json!({
|
||||
"type": "Line", "content": [{
|
||||
"timestamp": 1500, "text": [{"text": "Whole"}, {"text": "line"}]
|
||||
}]
|
||||
});
|
||||
assert_eq!(
|
||||
format_spotiflac_apple(&raw.to_string(), false, true).unwrap(),
|
||||
"[00:01.50]Whole line"
|
||||
);
|
||||
raw["type"] = "Unsynced".into();
|
||||
raw["content"][0]["timestamp"] = serde_json::Value::Null;
|
||||
let text = format_spotiflac_apple(&raw.to_string(), true, true).unwrap();
|
||||
assert_eq!(text, "Whole line");
|
||||
assert_eq!(
|
||||
LyricsResponse::from_text(&text, "Example", "Example").sync_type,
|
||||
"UNSYNCED"
|
||||
);
|
||||
for raw in [
|
||||
"<html>Error</html>",
|
||||
r#"{"error":{"code":"APPLE_AUTH_REQUIRED"}}"#,
|
||||
"{}",
|
||||
] {
|
||||
assert!(format_spotiflac_apple(raw, true, true).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apple_agents_restore_vocal_sides_without_reformatting_word_times() {
|
||||
let mut raw = serde_json::json!({
|
||||
"type": "Syllable",
|
||||
"elrcMultiPerson": "[00:01.01]v1: <00:01.009>Lead<00:02.00>\n[bg:<00:01.50>Echo<00:02.50>]\n[00:03.00]v2: <00:03.00>Guest<00:04.00>\n[00:05.00]v2: <00:05.00>Together<00:06.00>\n[00:07.00]v2: Third",
|
||||
"content": [
|
||||
{"timestamp": 1009, "agent": "lead"},
|
||||
{"timestamp": 3000, "agent": "guest"},
|
||||
{"timestamp": 5000, "agent": "all"},
|
||||
{"timestamp": 7000, "agent": "third"}
|
||||
],
|
||||
"metadata": {"agents": [
|
||||
{"id": "lead", "type": "person"},
|
||||
{"id": "guest", "type": "person"},
|
||||
{"id": "all", "type": "group"},
|
||||
{"id": "third", "type": "person"}
|
||||
]}
|
||||
});
|
||||
let text = format_apple(&raw.to_string(), true, true).unwrap();
|
||||
assert_eq!(
|
||||
text,
|
||||
"[00:01.01]v1: <00:01.009>Lead<00:02.00>\n[bg:<00:01.50>Echo<00:02.50>]\n[00:03.00]v2: <00:03.00>Guest<00:04.00>\n[00:05.00]v1: <00:05.00>Together<00:06.00>\n[00:07.00]v3: Third"
|
||||
);
|
||||
let lyrics = LyricsResponse::from_text(&text, "Apple Music", "Apple Music");
|
||||
let stored = lrc::with_metadata(&lyrics, "Track", "Artist");
|
||||
assert!(stored.contains("[00:05.00]v1: <00:05.00>Together<00:06.00>"));
|
||||
assert!(stored.contains("[bg:<00:01.50>Echo<00:02.50>]"));
|
||||
|
||||
// Some responses carry vocal labels only in the regular eLRC field.
|
||||
// It must use the same agent correction as the multi-person field.
|
||||
raw["elrc"] = raw
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("elrcMultiPerson")
|
||||
.unwrap();
|
||||
assert_eq!(format_apple(&raw.to_string(), true, true).unwrap(), text);
|
||||
assert_eq!(
|
||||
format_apple(&raw.to_string(), false, true).unwrap(),
|
||||
raw["elrc"].as_str().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apple_v3_does_not_depend_on_agent_order_or_group_metadata() {
|
||||
for agents in [
|
||||
serde_json::Value::Null,
|
||||
serde_json::json!([
|
||||
{"id": "v1", "type": "person"},
|
||||
{"id": "v3", "type": "person"},
|
||||
{"id": "v2", "type": "person"}
|
||||
]),
|
||||
] {
|
||||
let raw = serde_json::json!({
|
||||
"type": "Syllable",
|
||||
"elrcMultiPerson": "[00:01.00]v2:Together\n[00:02.00]v1:Guest",
|
||||
"content": [{"timestamp": 1000, "agent": "v3"}, {"timestamp": 2000, "agent": "v2"}],
|
||||
"metadata": {"agents": agents}
|
||||
});
|
||||
assert_eq!(
|
||||
format_apple(&raw.to_string(), true, true).unwrap(),
|
||||
"[00:01.00]v1:Together\n[00:02.00]v2:Guest"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apple_content_fallback_honors_agents_only_when_multi_person_is_enabled() {
|
||||
let raw = serde_json::json!({
|
||||
"type": "Syllable",
|
||||
"content": [{"timestamp": 1000, "oppositeTurn": true, "agent": "group", "text": [
|
||||
{"text": "Together", "timestamp": 1000, "endtime": 2000}
|
||||
]}],
|
||||
"metadata": {"agents": [{"id": "group", "type": "group"}]}
|
||||
})
|
||||
.to_string();
|
||||
for timing in [false, true] {
|
||||
assert!(
|
||||
format_apple(&raw, true, timing)
|
||||
.unwrap()
|
||||
.starts_with("[00:01.00]v1:")
|
||||
);
|
||||
assert!(!format_apple(&raw, false, timing).unwrap().contains("v1:"));
|
||||
assert!(!format_apple(&raw, false, timing).unwrap().contains("v2:"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apple_voice_correction_preserves_unknown_or_ambiguous_lines() {
|
||||
let text = "[00:01.00]v2: Unknown\n[00:02.00]v2: Too far\n[00:03.00]v2: Ambiguous\n[00:04.00]v2: Exact";
|
||||
let mut raw = serde_json::json!({"elrcMultiPerson": text});
|
||||
assert_eq!(format_apple(&raw.to_string(), true, true).unwrap(), text);
|
||||
raw["metadata"] = serde_json::json!({"agents": [
|
||||
{"id": "v1", "type": "person"}, {"id": "v2", "type": "person"},
|
||||
{"id": "v3", "type": "group"}
|
||||
]});
|
||||
raw["content"] = serde_json::json!([
|
||||
{"timestamp": 1000, "agent": "unknown"},
|
||||
{"timestamp": 2011, "agent": "v3"},
|
||||
{"timestamp": 3000, "agent": "v3"},
|
||||
{"timestamp": 3000, "agent": "v2"},
|
||||
{"timestamp": 3999, "agent": "v2"},
|
||||
{"timestamp": 4000, "agent": "v3"}
|
||||
]);
|
||||
assert_eq!(
|
||||
format_apple(&raw.to_string(), true, true).unwrap(),
|
||||
text.replace("[00:04.00]v2:", "[00:04.00]v1:")
|
||||
);
|
||||
}
|
||||
|
||||
fn payload(lang: &str) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "Syllable",
|
||||
"elrc": "[00:01.010]<00:01.009>Original <00:01.307>\n[00:02.000]Next",
|
||||
"content": [{"timestamp": 1009, "text": [
|
||||
{"text": "Original", "timestamp": 1009, "endtime": 1307}
|
||||
]}, {"timestamp": 2001, "text": [{"text": "Next"}]}],
|
||||
"metadata": {
|
||||
"language": lang,
|
||||
"transliterations": [{"lang": format!("{lang}-Latn"), "lines": [
|
||||
{"timestamp": 1009, "text": "Romanized"},
|
||||
{"timestamp": 2001, "text": "Next romanized"}
|
||||
]}],
|
||||
"translations": [{"lang": "en-US", "lines": [
|
||||
{"timestamp": 1009, "text": "English"},
|
||||
{"timestamp": 2001, "text": "Next English"}
|
||||
]}]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supplements_follow_output_timestamps_in_both_apple_modes() {
|
||||
for lang in ["ja", "ko", "zh-Hans"] {
|
||||
for word_timing in [false, true] {
|
||||
let raw = payload(lang).to_string();
|
||||
let text = format_apple(&raw, false, word_timing).unwrap();
|
||||
let mut lyrics = LyricsResponse::from_text(&text, "Apple Music", "Apple Music");
|
||||
apple_supplements(&raw, &mut lyrics);
|
||||
assert_eq!(lyrics.lines()[0].romanization.as_deref(), Some("Romanized"));
|
||||
assert_eq!(
|
||||
lyrics.lines()[1].translation.as_deref(),
|
||||
Some("Next English")
|
||||
);
|
||||
let start = if word_timing { 1010 } else { 1009 };
|
||||
assert_eq!(lyrics.lines()[0].start_time_ms, start);
|
||||
let exported = lrc::with_metadata(&lyrics, "Track", "Artist");
|
||||
assert!(exported.contains(&format!("[x-romaji:{start}:Um9tYW5pemVk]")));
|
||||
assert!(exported.contains(&lrc::timestamp(start)));
|
||||
if word_timing {
|
||||
assert!(exported.contains("<00:01.307>"));
|
||||
}
|
||||
// Persistence uses this same JSON response contract.
|
||||
let restored: LyricsResponse =
|
||||
serde_json::from_str(&serde_json::to_string(&lyrics).unwrap()).unwrap();
|
||||
assert_eq!(restored, lyrics);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearest_matching_is_bounded_and_exact_matches_win() {
|
||||
let mut raw = payload("ja");
|
||||
raw["metadata"]["transliterations"][0]["lines"] = serde_json::json!([
|
||||
{"timestamp": 1009, "text": "Rounded"},
|
||||
{"timestamp": 1010, "text": "Exact"},
|
||||
{"timestamp": 1001, "text": "Less accurate"},
|
||||
{"timestamp": 2011, "text": "Too far"},
|
||||
{"timestamp": 2990, "text": "Boundary"},
|
||||
{"timestamp": 4005, "text": "Tie"}
|
||||
]);
|
||||
let mut lyrics = LyricsResponse::from_text(
|
||||
"[00:01.010]A\n[00:02.00]B\n[00:03.00]C\n[00:04.00]D\n[00:04.010]E",
|
||||
"",
|
||||
"",
|
||||
);
|
||||
apple_supplements(&raw.to_string(), &mut lyrics);
|
||||
let actual: Vec<_> = lyrics
|
||||
.lines()
|
||||
.iter()
|
||||
.map(|line| line.romanization.as_deref())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
actual,
|
||||
[Some("Exact"), None, Some("Boundary"), Some("Tie"), None]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_optional_lines_do_not_hide_other_supplements() {
|
||||
let mut raw = payload("ja");
|
||||
raw["metadata"]["transliterations"][0]["lines"] = serde_json::json!([
|
||||
null, {"text": "Missing time"}, {"timestamp": "1009", "text": "Bad time"},
|
||||
{"timestamp": 1009, "text": []}, {"timestamp": -1, "text": "Negative"},
|
||||
{"timestamp": 2001, "text": " Next romanized "}
|
||||
]);
|
||||
let mut lyrics = LyricsResponse::from_text("[00:01.01]A\n[00:02.00]B", "", "");
|
||||
apple_supplements(&raw.to_string(), &mut lyrics);
|
||||
assert!(lyrics.lines()[0].romanization.is_none());
|
||||
assert_eq!(lyrics.lines()[0].translation.as_deref(), Some("English"));
|
||||
assert_eq!(
|
||||
lyrics.lines()[1].romanization.as_deref(),
|
||||
Some("Next romanized")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_selection_prefers_song_language_and_usable_groups() {
|
||||
let mut raw = payload("ja");
|
||||
raw["metadata"]["transliterations"] = serde_json::json!([
|
||||
{"lang": "ja-Latn", "lines": []},
|
||||
{"lang": "ko-Latn", "lines": [{"timestamp": 1009, "text": "Other language"}]},
|
||||
{"lang": "ja-Kana", "lines": [{"timestamp": 1009, "text": "Not Latin"}]},
|
||||
{"lang": "ja-Latn", "lines": [{"timestamp": 1009, "text": "Romanized"}]}
|
||||
]);
|
||||
raw["metadata"]["translations"] = serde_json::json!([
|
||||
{"lang": "en", "lines": null},
|
||||
{"lang": "fr", "lines": [{"timestamp": 1009, "text": "French"}]},
|
||||
{"lang": "en-GB", "lines": [{"timestamp": 1009, "text": "English"}]}
|
||||
]);
|
||||
let mut lyrics = LyricsResponse::from_text("[00:01.01]Original", "", "");
|
||||
apple_supplements(&raw.to_string(), &mut lyrics);
|
||||
assert_eq!(lyrics.lines()[0].romanization.as_deref(), Some("Romanized"));
|
||||
assert_eq!(lyrics.lines()[0].translation.as_deref(), Some("English"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_or_invalid_metadata_preserves_original_lyrics() {
|
||||
for raw in [
|
||||
"{}",
|
||||
"not json",
|
||||
r#"{"metadata":null}"#,
|
||||
r#"{"metadata":{"translations":5}}"#,
|
||||
] {
|
||||
let mut lyrics = LyricsResponse::from_text("[00:01.00]Original", "", "");
|
||||
let original = lyrics.clone();
|
||||
apple_supplements(raw, &mut lyrics);
|
||||
assert_eq!(lyrics, original);
|
||||
}
|
||||
let mut plain = LyricsResponse::from_text("Original", "", "");
|
||||
apple_supplements(&payload("ja").to_string(), &mut plain);
|
||||
assert!(plain.lines()[0].romanization.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn romanization_spans_keep_syllables_spaces_and_original_word_times() {
|
||||
let mut raw = payload("ja");
|
||||
raw["metadata"]["transliterations"][0]["lines"][0] = serde_json::json!({
|
||||
"timestamp": 1009, "text": "Roma nized", "spans": [
|
||||
{"text": "Ro", "begin": 1009, "end": 1107},
|
||||
{"text": "ma", "begin": 1107, "end": 1307},
|
||||
{"text": "nized", "begin": 2003, "end": 2497}
|
||||
]
|
||||
});
|
||||
let mut lyrics = LyricsResponse::from_text("[00:01.010]Original", "", "");
|
||||
apple_supplements(&raw.to_string(), &mut lyrics);
|
||||
let words = lyrics.lines()[0].romanization_words.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
words
|
||||
.iter()
|
||||
.map(|word| word.text.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["Ro", "ma ", "nized"]
|
||||
);
|
||||
assert_eq!(words[0].start_time_ms, 1009);
|
||||
assert_eq!(words[1].end_time_ms, 1307);
|
||||
assert_eq!(words[2].start_time_ms, 2003);
|
||||
assert_eq!(words[2].end_time_ms, 2497);
|
||||
|
||||
let output = lrc::with_metadata(&lyrics, "Track", "Artist");
|
||||
let encoded = output
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
line.strip_prefix("[x-romaji-words:1010:")
|
||||
.and_then(|line| line.strip_suffix(']'))
|
||||
})
|
||||
.unwrap();
|
||||
use base64::Engine;
|
||||
let decoded = base64::engine::general_purpose::STANDARD
|
||||
.decode(encoded)
|
||||
.unwrap();
|
||||
let restored: Vec<LyricsWord> = serde_json::from_slice(&decoded).unwrap();
|
||||
assert_eq!(&restored, words);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_romanization_timing_falls_back_to_readable_text() {
|
||||
for spans in [
|
||||
serde_json::json!([{"text": "Other", "begin": 1009, "end": 1307}]),
|
||||
serde_json::json!([{"text": "Romanized", "begin": 1009, "end": 900}]),
|
||||
serde_json::json!([{"text": "Romanized", "begin": 1009}]),
|
||||
serde_json::json!([{"text": "Ro", "begin": 1009, "end": 1307}]),
|
||||
] {
|
||||
let mut raw = payload("ja");
|
||||
raw["metadata"]["transliterations"][0]["lines"][0]["spans"] = spans;
|
||||
let mut lyrics = LyricsResponse::from_text("[00:01.010]Original", "", "");
|
||||
apple_supplements(&raw.to_string(), &mut lyrics);
|
||||
assert_eq!(lyrics.lines()[0].romanization.as_deref(), Some("Romanized"));
|
||||
assert!(lyrics.lines()[0].romanization_words.is_none());
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,8 @@ mod fft;
|
||||
mod replacement;
|
||||
pub use replacement::replacement_preserves_audio;
|
||||
#[cfg(test)]
|
||||
mod reference;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use evidence::*;
|
||||
@@ -161,18 +163,6 @@ impl HiResCheckResult {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reference decoder retained for streaming parity tests.
|
||||
#[cfg(test)]
|
||||
#[derive(Default)]
|
||||
struct Window {
|
||||
signal: Vec<f32>,
|
||||
/// OR of all raw integer samples, right-justified at the declared depth.
|
||||
or_bits: u32,
|
||||
/// This channel's raw integer samples, for the upsampling-artifact
|
||||
/// tests; empty for float PCM.
|
||||
samples: Vec<i32>,
|
||||
}
|
||||
|
||||
struct ChannelAnalysis {
|
||||
stft: StftAccumulator,
|
||||
integers: IntegerEvidence,
|
||||
@@ -499,20 +489,6 @@ impl<'a> Source<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn read_window(
|
||||
&mut self,
|
||||
start_frame: u64,
|
||||
frames: u64,
|
||||
channel: u32,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<Window, String> {
|
||||
match self {
|
||||
Source::Flac(reader) => read_flac_window(reader, start_frame, frames, channel, check),
|
||||
Source::Wav(wav) => wav.read_window(start_frame, frames, channel, check),
|
||||
}
|
||||
}
|
||||
|
||||
fn analyze_window(
|
||||
&mut self,
|
||||
start_frame: u64,
|
||||
@@ -586,60 +562,6 @@ impl<'a> Source<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes [start_frame, start_frame + frames). claxon hands back samples
|
||||
/// right-justified with wasted bits already shifted back in and inter-channel
|
||||
/// decorrelation undone, so each value is the stored sample. It cannot seek,
|
||||
/// so the frames before the window are decoded and skipped.
|
||||
#[cfg(test)]
|
||||
fn read_flac_window(
|
||||
reader: &mut claxon::FlacReader<BufReader<&mut File>>,
|
||||
start_frame: u64,
|
||||
frames: u64,
|
||||
channel: u32,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<Window, String> {
|
||||
let bits = reader.streaminfo().bits_per_sample;
|
||||
let scale = 1.0 / 2f64.powi(bits as i32 - 1);
|
||||
let capacity = usize::try_from(frames).unwrap_or(0);
|
||||
let mut out = Window {
|
||||
signal: Vec::with_capacity(capacity),
|
||||
samples: Vec::with_capacity(capacity),
|
||||
..Window::default()
|
||||
};
|
||||
let end = start_frame + frames;
|
||||
let mut blocks = reader.blocks();
|
||||
let mut buffer = Vec::new();
|
||||
let mut decoded = 0u64;
|
||||
while (out.signal.len() as u64) < frames {
|
||||
if decoded.is_multiple_of(64) {
|
||||
check()?;
|
||||
}
|
||||
decoded += 1;
|
||||
let block = match blocks.read_next_or_eof(buffer) {
|
||||
Ok(Some(block)) => block,
|
||||
Ok(None) => break,
|
||||
// A truncated tail still leaves a usable window.
|
||||
Err(_) if !out.signal.is_empty() => break,
|
||||
Err(e) => return Err(e.to_string()),
|
||||
};
|
||||
let first = block.time();
|
||||
let n = u64::from(block.duration());
|
||||
if first + n > start_frame {
|
||||
let from = start_frame.saturating_sub(first);
|
||||
let to = n.min(end - first);
|
||||
for i in from..to {
|
||||
let i = i as usize;
|
||||
let v = block.channel(channel)[i];
|
||||
out.or_bits |= v as u32;
|
||||
out.samples.push(v);
|
||||
out.signal.push((f64::from(v) * scale) as f32);
|
||||
}
|
||||
}
|
||||
buffer = block.into_buffer();
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
const WAV_FORMAT_PCM: u16 = 1;
|
||||
const WAV_FORMAT_FLOAT: u16 = 3;
|
||||
const WAV_FORMAT_EXTENSIBLE: u16 = 0xFFFE;
|
||||
@@ -738,70 +660,6 @@ impl<'a> WavSource<'a> {
|
||||
u64::from(self.channels * self.container_bits / 8)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn read_window(
|
||||
&mut self,
|
||||
start_frame: u64,
|
||||
frames: u64,
|
||||
channel: u32,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<Window, String> {
|
||||
let total = self.data_size / self.frame_bytes();
|
||||
let frames = frames.min(total.saturating_sub(start_frame));
|
||||
let mut out = Window::default();
|
||||
if frames == 0 {
|
||||
return Ok(out);
|
||||
}
|
||||
let frame_bytes = self.frame_bytes() as usize;
|
||||
self.file
|
||||
.seek(SeekFrom::Start(
|
||||
self.data_offset + start_frame * frame_bytes as u64,
|
||||
))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let capacity = usize::try_from(frames).unwrap_or(0);
|
||||
out.signal.reserve(capacity);
|
||||
if !self.is_float {
|
||||
out.samples.reserve(capacity);
|
||||
}
|
||||
let bytes_per_sample = (self.container_bits / 8) as usize;
|
||||
let scale = 1.0 / 2f64.powi(self.container_bits as i32 - 1);
|
||||
let mut reader = BufReader::with_capacity(1 << 16, &mut *self.file);
|
||||
let mut frame = vec![0u8; frame_bytes];
|
||||
|
||||
for index in 0..frames {
|
||||
if index.is_multiple_of(65_536) {
|
||||
check()?;
|
||||
}
|
||||
if reader.read_exact(&mut frame).is_err() {
|
||||
break;
|
||||
}
|
||||
for (c, b) in frame.chunks_exact(bytes_per_sample).enumerate() {
|
||||
if c != channel as usize {
|
||||
continue;
|
||||
}
|
||||
if self.is_float {
|
||||
let v = if self.container_bits == 32 {
|
||||
f64::from(f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
|
||||
} else {
|
||||
f64::from_le_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]])
|
||||
};
|
||||
out.signal.push(v as f32);
|
||||
continue;
|
||||
}
|
||||
let v: i32 = match self.container_bits {
|
||||
8 => i32::from(b[0]) - 128, // 8-bit WAV is unsigned
|
||||
16 => i32::from(i16::from_le_bytes([b[0], b[1]])),
|
||||
24 => i32::from_le_bytes([0, b[0], b[1], b[2]]) >> 8,
|
||||
_ => i32::from_le_bytes([b[0], b[1], b[2], b[3]]),
|
||||
};
|
||||
out.or_bits |= v as u32;
|
||||
out.samples.push(v);
|
||||
out.signal.push((f64::from(v) * scale) as f32);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn analyze_window(
|
||||
&mut self,
|
||||
start_frame: u64,
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Reference decoder retained for streaming parity tests.
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Window {
|
||||
pub(super) signal: Vec<f32>,
|
||||
/// OR of all raw integer samples, right-justified at the declared depth.
|
||||
pub(super) or_bits: u32,
|
||||
/// This channel's raw integer samples, for the upsampling-artifact
|
||||
/// tests; empty for float PCM.
|
||||
pub(super) samples: Vec<i32>,
|
||||
}
|
||||
|
||||
impl<'a> Source<'a> {
|
||||
pub(super) fn read_window(
|
||||
&mut self,
|
||||
start_frame: u64,
|
||||
frames: u64,
|
||||
channel: u32,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<Window, String> {
|
||||
match self {
|
||||
Source::Flac(reader) => read_flac_window(reader, start_frame, frames, channel, check),
|
||||
Source::Wav(wav) => wav.read_window(start_frame, frames, channel, check),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes [start_frame, start_frame + frames). claxon hands back samples
|
||||
/// right-justified with wasted bits already shifted back in and inter-channel
|
||||
/// decorrelation undone, so each value is the stored sample. It cannot seek,
|
||||
/// so the frames before the window are decoded and skipped.
|
||||
fn read_flac_window(
|
||||
reader: &mut claxon::FlacReader<BufReader<&mut File>>,
|
||||
start_frame: u64,
|
||||
frames: u64,
|
||||
channel: u32,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<Window, String> {
|
||||
let bits = reader.streaminfo().bits_per_sample;
|
||||
let scale = 1.0 / 2f64.powi(bits as i32 - 1);
|
||||
let capacity = usize::try_from(frames).unwrap_or(0);
|
||||
let mut out = Window {
|
||||
signal: Vec::with_capacity(capacity),
|
||||
samples: Vec::with_capacity(capacity),
|
||||
..Window::default()
|
||||
};
|
||||
let end = start_frame + frames;
|
||||
let mut blocks = reader.blocks();
|
||||
let mut buffer = Vec::new();
|
||||
let mut decoded = 0u64;
|
||||
while (out.signal.len() as u64) < frames {
|
||||
if decoded.is_multiple_of(64) {
|
||||
check()?;
|
||||
}
|
||||
decoded += 1;
|
||||
let block = match blocks.read_next_or_eof(buffer) {
|
||||
Ok(Some(block)) => block,
|
||||
Ok(None) => break,
|
||||
// A truncated tail still leaves a usable window.
|
||||
Err(_) if !out.signal.is_empty() => break,
|
||||
Err(e) => return Err(e.to_string()),
|
||||
};
|
||||
let first = block.time();
|
||||
let n = u64::from(block.duration());
|
||||
if first + n > start_frame {
|
||||
let from = start_frame.saturating_sub(first);
|
||||
let to = n.min(end - first);
|
||||
for i in from..to {
|
||||
let i = i as usize;
|
||||
let v = block.channel(channel)[i];
|
||||
out.or_bits |= v as u32;
|
||||
out.samples.push(v);
|
||||
out.signal.push((f64::from(v) * scale) as f32);
|
||||
}
|
||||
}
|
||||
buffer = block.into_buffer();
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
impl<'a> WavSource<'a> {
|
||||
pub(super) fn read_window(
|
||||
&mut self,
|
||||
start_frame: u64,
|
||||
frames: u64,
|
||||
channel: u32,
|
||||
check: &dyn Fn() -> Result<(), String>,
|
||||
) -> Result<Window, String> {
|
||||
let total = self.data_size / self.frame_bytes();
|
||||
let frames = frames.min(total.saturating_sub(start_frame));
|
||||
let mut out = Window::default();
|
||||
if frames == 0 {
|
||||
return Ok(out);
|
||||
}
|
||||
let frame_bytes = self.frame_bytes() as usize;
|
||||
self.file
|
||||
.seek(SeekFrom::Start(
|
||||
self.data_offset + start_frame * frame_bytes as u64,
|
||||
))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let capacity = usize::try_from(frames).unwrap_or(0);
|
||||
out.signal.reserve(capacity);
|
||||
if !self.is_float {
|
||||
out.samples.reserve(capacity);
|
||||
}
|
||||
let bytes_per_sample = (self.container_bits / 8) as usize;
|
||||
let scale = 1.0 / 2f64.powi(self.container_bits as i32 - 1);
|
||||
let mut reader = BufReader::with_capacity(1 << 16, &mut *self.file);
|
||||
let mut frame = vec![0u8; frame_bytes];
|
||||
|
||||
for index in 0..frames {
|
||||
if index.is_multiple_of(65_536) {
|
||||
check()?;
|
||||
}
|
||||
if reader.read_exact(&mut frame).is_err() {
|
||||
break;
|
||||
}
|
||||
for (c, b) in frame.chunks_exact(bytes_per_sample).enumerate() {
|
||||
if c != channel as usize {
|
||||
continue;
|
||||
}
|
||||
if self.is_float {
|
||||
let v = if self.container_bits == 32 {
|
||||
f64::from(f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
|
||||
} else {
|
||||
f64::from_le_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]])
|
||||
};
|
||||
out.signal.push(v as f32);
|
||||
continue;
|
||||
}
|
||||
let v: i32 = match self.container_bits {
|
||||
8 => i32::from(b[0]) - 128, // 8-bit WAV is unsigned
|
||||
16 => i32::from(i16::from_le_bytes([b[0], b[1]])),
|
||||
24 => i32::from_le_bytes([0, b[0], b[1], b[2]]) >> 8,
|
||||
_ => i32::from_le_bytes([b[0], b[1], b[2], b[3]]),
|
||||
};
|
||||
out.or_bits |= v as u32;
|
||||
out.samples.push(v);
|
||||
out.signal.push((f64::from(v) * scale) as f32);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
//! output and publishes it only after this operation succeeds and is synced.
|
||||
|
||||
mod ape;
|
||||
mod artists;
|
||||
mod id3;
|
||||
mod mp4;
|
||||
mod ogg;
|
||||
@@ -10,10 +11,12 @@ mod riff;
|
||||
pub(super) use id3::cover as embedded_cover;
|
||||
|
||||
use super::{CheckedReader, bytes, exact, pair, seek};
|
||||
use crate::matching::{lowercase, uppercase};
|
||||
use crate::matching::uppercase;
|
||||
use artists::{
|
||||
artist_mode_fields, primary_artist, primary_artist_mode, split_artists, tag_separator,
|
||||
};
|
||||
use regex::Regex;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{BufReader, Cursor, Read, Seek, SeekFrom, Write};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
@@ -659,93 +662,6 @@ fn metadata_fields(metadata: &super::AudioMetadata, fields: &Fields) -> Fields {
|
||||
result
|
||||
}
|
||||
|
||||
fn primary_artist_mode(mode: &str) -> bool {
|
||||
mode.trim().eq_ignore_ascii_case("primary")
|
||||
}
|
||||
|
||||
/// The first credited artist, or the trimmed value when nothing separates it.
|
||||
/// Dart and Kotlin share this exact separator set so every writer agrees;
|
||||
/// Vorbis split mode also recognizes these featured-credit variants.
|
||||
fn primary_artist(value: &str) -> String {
|
||||
static SEPARATOR: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?i)\s*[,;&]\s*|\s+x\s+|\s+(?:feat(?:uring|ured|ure)?|ft|with)\.?(?:\s+|$)")
|
||||
.unwrap()
|
||||
});
|
||||
let value = value.trim();
|
||||
SEPARATOR
|
||||
.split(value)
|
||||
.map(str::trim)
|
||||
.find(|part| !part.is_empty())
|
||||
.unwrap_or(value)
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
/// Applies the "primary" artist tag mode to editor fields before any format
|
||||
/// writer runs, so ID3, MP4, APE, RIFF and Vorbis all store the same name.
|
||||
fn artist_mode_fields(fields: &Fields) -> Cow<'_, Fields> {
|
||||
let primary = fields
|
||||
.get("artist_tag_mode")
|
||||
.is_some_and(|mode| primary_artist_mode(mode));
|
||||
if !primary
|
||||
&& ["artist", "album_artist", "composer", "genre"]
|
||||
.iter()
|
||||
.all(|key| tag_separator(fields, key).is_empty())
|
||||
{
|
||||
return Cow::Borrowed(fields);
|
||||
}
|
||||
let mut fields = fields.clone();
|
||||
for key in ["artist", "album_artist", "composer", "genre"] {
|
||||
let separator = tag_separator(&fields, key).to_owned();
|
||||
if let Some(value) = fields.get_mut(key) {
|
||||
if primary && matches!(key, "artist" | "album_artist") && !value.trim().is_empty() {
|
||||
*value = primary_artist(value);
|
||||
}
|
||||
*value = super::format_metadata_tag_value(value, &separator);
|
||||
}
|
||||
}
|
||||
Cow::Owned(fields)
|
||||
}
|
||||
|
||||
fn tag_separator<'a>(fields: &'a Fields, key: &str) -> &'a str {
|
||||
let key = match key {
|
||||
"ARTIST" | "artist" => "artist_separator",
|
||||
"ALBUMARTIST" | "album_artist" => "album_artist_separator",
|
||||
"COMPOSER" | "composer" => "composer_separator",
|
||||
"GENRE" | "genre" => "genre_separator",
|
||||
_ => return "",
|
||||
};
|
||||
fields
|
||||
.get(key)
|
||||
.filter(|value| super::separators::valid_separator(value))
|
||||
.map(String::as_str)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn split_artists(value: &str) -> Vec<String> {
|
||||
static SPLIT: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(
|
||||
r"(?i)\s*(?:,|;|&|\bx\b)\s*|\s+\b(?:feat(?:uring|ured|ure)?|ft|with)\.?(?:\s+|$)",
|
||||
)
|
||||
.unwrap()
|
||||
});
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut result: Vec<String> = SPLIT
|
||||
.split(value)
|
||||
.map(str::trim)
|
||||
.filter(|part| !part.is_empty())
|
||||
.filter(|part| seen.insert(lowercase(part)))
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
if result.is_empty() {
|
||||
result.push(value.into());
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn cover_mime(data: &[u8]) -> &'static str {
|
||||
if data.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||
"image/png"
|
||||
@@ -838,393 +754,4 @@ fn picture(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::Cell;
|
||||
use std::io;
|
||||
use std::rc::Rc;
|
||||
|
||||
struct CountingReader {
|
||||
data: Cursor<Vec<u8>>,
|
||||
bytes_read: Rc<Cell<usize>>,
|
||||
}
|
||||
|
||||
impl Read for CountingReader {
|
||||
fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
|
||||
let count = self.data.read(buffer)?;
|
||||
self.bytes_read.set(self.bytes_read.get() + count);
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
||||
impl Seek for CountingReader {
|
||||
fn seek(&mut self, offset: SeekFrom) -> io::Result<u64> {
|
||||
self.data.seek(offset)
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared with the Dart and Kotlin primary-artist tests; keep in sync.
|
||||
#[test]
|
||||
fn split_artist_credits_recognize_variants_without_splitting_band_words() {
|
||||
assert_eq!(
|
||||
split_artists("One Featured Two; Three"),
|
||||
vec!["One", "Two", "Three"]
|
||||
);
|
||||
assert_eq!(split_artists("One feature Two"), vec!["One", "Two"]);
|
||||
assert_eq!(
|
||||
split_artists("Florence and the Machine"),
|
||||
vec!["Florence and the Machine"]
|
||||
);
|
||||
assert_eq!(
|
||||
split_artists("Artist Without Fear"),
|
||||
vec!["Artist Without Fear"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn primary_artist_uses_the_shared_separator_set() {
|
||||
for (input, expected) in [
|
||||
("Calle 24, Chino Pacas", "Calle 24"),
|
||||
("Calle 24 & Chino Pacas", "Calle 24"),
|
||||
("Artist A; Artist B", "Artist A"),
|
||||
("Artist A feat. Artist B", "Artist A"),
|
||||
("Artist A Feat Artist B", "Artist A"),
|
||||
("Artist A ft. Artist B", "Artist A"),
|
||||
("Artist A featuring Artist B", "Artist A"),
|
||||
("Artist A featured Artist B", "Artist A"),
|
||||
("Artist A feature Artist B", "Artist A"),
|
||||
("Artist A with Artist B", "Artist A"),
|
||||
("Artist A x Artist B", "Artist A"),
|
||||
("Artist A X Artist B", "Artist A"),
|
||||
(" , Artist A, Artist B", "Artist A"),
|
||||
("Malcolm X", "Malcolm X"),
|
||||
("Artist Without Fear", "Artist Without Fear"),
|
||||
("Maxx", "Maxx"),
|
||||
("AC/DC", "AC/DC"),
|
||||
(" Various Artists ", "Various Artists"),
|
||||
("", ""),
|
||||
] {
|
||||
assert_eq!(primary_artist(input), expected, "{input:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn editable_flac() -> (Vec<u8>, usize, Fields) {
|
||||
let mut comments = Vec::new();
|
||||
put_string(&mut comments, b"SpotiFLAC");
|
||||
comments.extend(2_u32.to_le_bytes());
|
||||
put_string(&mut comments, b"CUSTOM=preserve");
|
||||
put_string(&mut comments, b"TITLE=Example");
|
||||
let blocks = [
|
||||
(0, vec![0; 34]),
|
||||
(1, vec![0; 16]),
|
||||
(4, comments),
|
||||
(6, picture(b"original cover", &|| Ok(())).unwrap().unwrap()),
|
||||
];
|
||||
let mut input = b"fLaC".to_vec();
|
||||
for (index, (kind, data)) in blocks.iter().enumerate() {
|
||||
input.push(kind | if index + 1 == blocks.len() { 0x80 } else { 0 });
|
||||
input.extend(&(data.len() as u32).to_be_bytes()[1..]);
|
||||
input.extend(data);
|
||||
}
|
||||
let audio_start = input.len();
|
||||
input.extend([0xff, 0xf8]);
|
||||
input.resize(audio_start + 1024 * 1024, 0x55);
|
||||
let fields = Fields::from([("title".into(), "Example".into())]);
|
||||
(input, audio_start, fields)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_flac_tags_do_not_copy_audio_or_write_output() {
|
||||
for cover in [None, Some(b"original cover".as_slice())] {
|
||||
let (input, audio_start, fields) = editable_flac();
|
||||
let bytes_read = Rc::new(Cell::new(0));
|
||||
let mut source = CountingReader {
|
||||
data: Cursor::new(input),
|
||||
bytes_read: bytes_read.clone(),
|
||||
};
|
||||
let mut output = Vec::new();
|
||||
assert!(
|
||||
!rewrite_flac_tags_if_changed(
|
||||
&mut source,
|
||||
&mut output,
|
||||
&fields,
|
||||
cover,
|
||||
&|| Ok(()),
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
assert!(output.is_empty());
|
||||
// One header parse, two frame-sync bytes, one exact comparison.
|
||||
assert_eq!(bytes_read.get(), audio_start * 2 + 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_flac_tags_match_full_rewrite_and_preserve_audio() {
|
||||
let (input, audio_start, _) = editable_flac();
|
||||
for (title, cover) in [
|
||||
("Changed", None), // Equal-length header with different bytes.
|
||||
("A longer title", None),
|
||||
("Example", Some(b"different artwork".as_slice())),
|
||||
] {
|
||||
let fields = Fields::from([("title".into(), title.into())]);
|
||||
let mut expected = Vec::new();
|
||||
rewrite_audio_tags(
|
||||
&mut Cursor::new(&input),
|
||||
&mut expected,
|
||||
"flac",
|
||||
&fields,
|
||||
cover,
|
||||
&|| Ok(()),
|
||||
)
|
||||
.unwrap();
|
||||
let mut output = Vec::new();
|
||||
assert!(
|
||||
rewrite_flac_tags_if_changed(
|
||||
&mut Cursor::new(&input),
|
||||
&mut output,
|
||||
&fields,
|
||||
cover,
|
||||
&|| Ok(()),
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(output, expected);
|
||||
assert!(output.ends_with(&input[audio_start..]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_flac_still_validates_frames_and_observes_cancellation() {
|
||||
let (input, audio_start, fields) = editable_flac();
|
||||
let bytes_read = Rc::new(Cell::new(0));
|
||||
let mut source = CountingReader {
|
||||
data: Cursor::new(input.clone()),
|
||||
bytes_read: bytes_read.clone(),
|
||||
};
|
||||
let mut output = Vec::new();
|
||||
let error = rewrite_flac_tags_if_changed(&mut source, &mut output, &fields, None, &|| {
|
||||
if bytes_read.get() >= audio_start + 2 {
|
||||
Err("cancelled".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
.unwrap_err();
|
||||
assert_eq!(error, "cancelled");
|
||||
assert!(output.is_empty());
|
||||
|
||||
let mut invalid = input;
|
||||
invalid[audio_start] = 0;
|
||||
assert!(
|
||||
rewrite_flac_tags_if_changed(
|
||||
&mut Cursor::new(invalid),
|
||||
&mut output,
|
||||
&fields,
|
||||
None,
|
||||
&|| Ok(()),
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains("sync code")
|
||||
);
|
||||
assert!(output.is_empty());
|
||||
}
|
||||
|
||||
fn flac_with_pictures(lengths: &[usize]) -> (Vec<u8>, usize) {
|
||||
let mut comments = Vec::new();
|
||||
put_string(&mut comments, b"Example vendor");
|
||||
comments.extend(1_u32.to_le_bytes());
|
||||
put_string(&mut comments, b"CUSTOM=preserve");
|
||||
let mut blocks = vec![
|
||||
(0, vec![0; 34]),
|
||||
(2, b"unknown block".to_vec()),
|
||||
(4, comments),
|
||||
];
|
||||
blocks.extend(lengths.iter().map(|length| (6, vec![0x55; *length])));
|
||||
let mut input = b"fLaC".to_vec();
|
||||
for (index, (kind, data)) in blocks.iter().enumerate() {
|
||||
input.push(kind | if index + 1 == blocks.len() { 0x80 } else { 0 });
|
||||
input.extend(&(data.len() as u32).to_be_bytes()[1..]);
|
||||
input.extend(data);
|
||||
}
|
||||
let audio_start = input.len();
|
||||
input.extend([0xff, 0xf8, 0x12, 0x34, 0x56]);
|
||||
(input, audio_start)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacement_flac_artwork_preserves_other_blocks_and_audio() {
|
||||
let (input, audio_start) = flac_with_pictures(&[131_073, 262_145]);
|
||||
let (without_pictures, _) = flac_with_pictures(&[]);
|
||||
let cover = Some(b"replacement artwork".as_slice());
|
||||
for embed in [false, true] {
|
||||
let fields = Fields::from([(
|
||||
if embed { "TITLE" } else { "title" }.into(),
|
||||
"Changed title".into(),
|
||||
)]);
|
||||
let rewrite = |data: &[u8], cover| {
|
||||
let mut output = Vec::new();
|
||||
if embed {
|
||||
embed_flac_metadata(
|
||||
&mut Cursor::new(data),
|
||||
&mut output,
|
||||
&fields,
|
||||
"",
|
||||
cover,
|
||||
&|| Ok(()),
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
rewrite_audio_tags(
|
||||
&mut Cursor::new(data),
|
||||
&mut output,
|
||||
"flac",
|
||||
&fields,
|
||||
cover,
|
||||
&|| Ok(()),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
output
|
||||
};
|
||||
let output = rewrite(&input, cover);
|
||||
assert_eq!(output, rewrite(&without_pictures, cover));
|
||||
assert!(output.ends_with(&input[audio_start..]));
|
||||
let retained = rewrite(&input, None);
|
||||
assert!(
|
||||
retained
|
||||
.windows(131_073)
|
||||
.any(|bytes| bytes == [0x55; 131_073])
|
||||
);
|
||||
assert!(retained.ends_with(&input[audio_start..]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discarded_flac_picture_preserves_eof_and_cancellation() {
|
||||
let (input, audio_start) = flac_with_pictures(&[131_073]);
|
||||
let picture_start = audio_start - 131_073;
|
||||
let fields = Fields::new();
|
||||
for available in [0, 1, 65536, 65537, 131_072] {
|
||||
let truncated = &input[..picture_start + available];
|
||||
let mut expected = Vec::new();
|
||||
let error = rewrite_audio_tags(
|
||||
&mut Cursor::new(truncated),
|
||||
&mut expected,
|
||||
"flac",
|
||||
&fields,
|
||||
None,
|
||||
&|| Ok(()),
|
||||
)
|
||||
.unwrap_err();
|
||||
let mut output = Vec::new();
|
||||
assert_eq!(
|
||||
rewrite_audio_tags(
|
||||
&mut Cursor::new(truncated),
|
||||
&mut output,
|
||||
"flac",
|
||||
&fields,
|
||||
Some(b"replacement artwork"),
|
||||
&|| Ok(()),
|
||||
)
|
||||
.unwrap_err(),
|
||||
error
|
||||
);
|
||||
assert!(output.is_empty());
|
||||
}
|
||||
let bytes_read = Rc::new(Cell::new(0));
|
||||
let mut source = CountingReader {
|
||||
data: Cursor::new(input),
|
||||
bytes_read: bytes_read.clone(),
|
||||
};
|
||||
let mut output = Vec::new();
|
||||
assert_eq!(
|
||||
rewrite_flac_tags_if_changed(
|
||||
&mut source,
|
||||
&mut output,
|
||||
&fields,
|
||||
Some(b"replacement artwork"),
|
||||
&|| {
|
||||
if bytes_read.get() >= picture_start + 65536 {
|
||||
Err("cancelled".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
)
|
||||
.unwrap_err(),
|
||||
"cancelled"
|
||||
);
|
||||
assert!(bytes_read.get() < audio_start);
|
||||
assert!(output.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discarded_flac_pictures_still_count_toward_metadata_limit() {
|
||||
let (input, _) = flac_with_pictures(&vec![0; 65534]);
|
||||
let mut output = Vec::new();
|
||||
assert_eq!(
|
||||
rewrite_audio_tags(
|
||||
&mut Cursor::new(input),
|
||||
&mut output,
|
||||
"flac",
|
||||
&Fields::new(),
|
||||
Some(b"replacement artwork"),
|
||||
&|| Ok(()),
|
||||
)
|
||||
.unwrap_err(),
|
||||
"FLAC metadata block count exceeds 65536"
|
||||
);
|
||||
assert!(output.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_flac_cover_reencodes_or_omits_invalid_image() {
|
||||
let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
|
||||
3,
|
||||
2,
|
||||
image::Rgba([30, 60, 90, 255]),
|
||||
));
|
||||
let mut encoded = Cursor::new(Vec::new());
|
||||
image
|
||||
.write_to(&mut encoded, image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let mut cover = encoded.into_inner();
|
||||
// Valid PNG with trailing data forces the same size fallback as a
|
||||
// large artwork file without an expensive image-generation fixture.
|
||||
cover.resize(16_000_001, 0);
|
||||
let block = picture(&cover, &|| Ok(())).unwrap().unwrap();
|
||||
let mut reader = Cursor::new(block);
|
||||
let mut number = || {
|
||||
let mut value = [0; 4];
|
||||
reader.read_exact(&mut value).unwrap();
|
||||
u32::from_be_bytes(value)
|
||||
};
|
||||
assert_eq!(number(), 3);
|
||||
assert_eq!(number(), 10);
|
||||
assert_eq!(bytes(&mut reader, 10).unwrap(), b"image/jpeg");
|
||||
assert_eq!(bytes(&mut reader, 4).unwrap(), 11_u32.to_be_bytes());
|
||||
assert_eq!(bytes(&mut reader, 11).unwrap(), b"Front Cover");
|
||||
assert_eq!(bytes(&mut reader, 4).unwrap(), 3_u32.to_be_bytes());
|
||||
assert_eq!(bytes(&mut reader, 4).unwrap(), 2_u32.to_be_bytes());
|
||||
assert_eq!(bytes(&mut reader, 4).unwrap(), 24_u32.to_be_bytes());
|
||||
assert_eq!(bytes(&mut reader, 4).unwrap(), [0; 4]);
|
||||
let length = u32::from_be_bytes(bytes(&mut reader, 4).unwrap().try_into().unwrap());
|
||||
assert!(length < 16_000_000);
|
||||
let jpeg = bytes(&mut reader, length as usize).unwrap();
|
||||
assert_eq!(
|
||||
image::ImageReader::new(Cursor::new(jpeg))
|
||||
.with_guessed_format()
|
||||
.unwrap()
|
||||
.into_dimensions()
|
||||
.unwrap(),
|
||||
(3, 2)
|
||||
);
|
||||
cover.fill(0);
|
||||
assert!(picture(&cover, &|| Ok(())).unwrap().is_none());
|
||||
assert_eq!(
|
||||
picture(b"small cover", &|| Err("cancelled".into())).unwrap_err(),
|
||||
"cancelled"
|
||||
);
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
@@ -0,0 +1,93 @@
|
||||
use super::Fields;
|
||||
use crate::matching::lowercase;
|
||||
use regex::Regex;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
pub(super) fn primary_artist_mode(mode: &str) -> bool {
|
||||
mode.trim().eq_ignore_ascii_case("primary")
|
||||
}
|
||||
|
||||
/// The first credited artist, or the trimmed value when nothing separates it.
|
||||
/// Dart and Kotlin share this exact separator set so every writer agrees;
|
||||
/// Vorbis split mode also recognizes these featured-credit variants.
|
||||
pub(super) fn primary_artist(value: &str) -> String {
|
||||
static SEPARATOR: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?i)\s*[,;&]\s*|\s+x\s+|\s+(?:feat(?:uring|ured|ure)?|ft|with)\.?(?:\s+|$)")
|
||||
.unwrap()
|
||||
});
|
||||
let value = value.trim();
|
||||
SEPARATOR
|
||||
.split(value)
|
||||
.map(str::trim)
|
||||
.find(|part| !part.is_empty())
|
||||
.unwrap_or(value)
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
/// Applies the "primary" artist tag mode to editor fields before any format
|
||||
/// writer runs, so ID3, MP4, APE, RIFF and Vorbis all store the same name.
|
||||
pub(super) fn artist_mode_fields(fields: &Fields) -> Cow<'_, Fields> {
|
||||
let primary = fields
|
||||
.get("artist_tag_mode")
|
||||
.is_some_and(|mode| primary_artist_mode(mode));
|
||||
if !primary
|
||||
&& ["artist", "album_artist", "composer", "genre"]
|
||||
.iter()
|
||||
.all(|key| tag_separator(fields, key).is_empty())
|
||||
{
|
||||
return Cow::Borrowed(fields);
|
||||
}
|
||||
let mut fields = fields.clone();
|
||||
for key in ["artist", "album_artist", "composer", "genre"] {
|
||||
let separator = tag_separator(&fields, key).to_owned();
|
||||
if let Some(value) = fields.get_mut(key) {
|
||||
if primary && matches!(key, "artist" | "album_artist") && !value.trim().is_empty() {
|
||||
*value = primary_artist(value);
|
||||
}
|
||||
*value = super::super::format_metadata_tag_value(value, &separator);
|
||||
}
|
||||
}
|
||||
Cow::Owned(fields)
|
||||
}
|
||||
|
||||
pub(super) fn tag_separator<'a>(fields: &'a Fields, key: &str) -> &'a str {
|
||||
let key = match key {
|
||||
"ARTIST" | "artist" => "artist_separator",
|
||||
"ALBUMARTIST" | "album_artist" => "album_artist_separator",
|
||||
"COMPOSER" | "composer" => "composer_separator",
|
||||
"GENRE" | "genre" => "genre_separator",
|
||||
_ => return "",
|
||||
};
|
||||
fields
|
||||
.get(key)
|
||||
.filter(|value| super::super::separators::valid_separator(value))
|
||||
.map(String::as_str)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(super) fn split_artists(value: &str) -> Vec<String> {
|
||||
static SPLIT: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(
|
||||
r"(?i)\s*(?:,|;|&|\bx\b)\s*|\s+\b(?:feat(?:uring|ured|ure)?|ft|with)\.?(?:\s+|$)",
|
||||
)
|
||||
.unwrap()
|
||||
});
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut result: Vec<String> = SPLIT
|
||||
.split(value)
|
||||
.map(str::trim)
|
||||
.filter(|part| !part.is_empty())
|
||||
.filter(|part| seen.insert(lowercase(part)))
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
if result.is_empty() {
|
||||
result.push(value.into());
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
use super::*;
|
||||
use std::cell::Cell;
|
||||
use std::io;
|
||||
use std::rc::Rc;
|
||||
|
||||
struct CountingReader {
|
||||
data: Cursor<Vec<u8>>,
|
||||
bytes_read: Rc<Cell<usize>>,
|
||||
}
|
||||
|
||||
impl Read for CountingReader {
|
||||
fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
|
||||
let count = self.data.read(buffer)?;
|
||||
self.bytes_read.set(self.bytes_read.get() + count);
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
||||
impl Seek for CountingReader {
|
||||
fn seek(&mut self, offset: SeekFrom) -> io::Result<u64> {
|
||||
self.data.seek(offset)
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared with the Dart and Kotlin primary-artist tests; keep in sync.
|
||||
#[test]
|
||||
fn split_artist_credits_recognize_variants_without_splitting_band_words() {
|
||||
assert_eq!(
|
||||
split_artists("One Featured Two; Three"),
|
||||
vec!["One", "Two", "Three"]
|
||||
);
|
||||
assert_eq!(split_artists("One feature Two"), vec!["One", "Two"]);
|
||||
assert_eq!(
|
||||
split_artists("Florence and the Machine"),
|
||||
vec!["Florence and the Machine"]
|
||||
);
|
||||
assert_eq!(
|
||||
split_artists("Artist Without Fear"),
|
||||
vec!["Artist Without Fear"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn primary_artist_uses_the_shared_separator_set() {
|
||||
for (input, expected) in [
|
||||
("Calle 24, Chino Pacas", "Calle 24"),
|
||||
("Calle 24 & Chino Pacas", "Calle 24"),
|
||||
("Artist A; Artist B", "Artist A"),
|
||||
("Artist A feat. Artist B", "Artist A"),
|
||||
("Artist A Feat Artist B", "Artist A"),
|
||||
("Artist A ft. Artist B", "Artist A"),
|
||||
("Artist A featuring Artist B", "Artist A"),
|
||||
("Artist A featured Artist B", "Artist A"),
|
||||
("Artist A feature Artist B", "Artist A"),
|
||||
("Artist A with Artist B", "Artist A"),
|
||||
("Artist A x Artist B", "Artist A"),
|
||||
("Artist A X Artist B", "Artist A"),
|
||||
(" , Artist A, Artist B", "Artist A"),
|
||||
("Malcolm X", "Malcolm X"),
|
||||
("Artist Without Fear", "Artist Without Fear"),
|
||||
("Maxx", "Maxx"),
|
||||
("AC/DC", "AC/DC"),
|
||||
(" Various Artists ", "Various Artists"),
|
||||
("", ""),
|
||||
] {
|
||||
assert_eq!(primary_artist(input), expected, "{input:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn editable_flac() -> (Vec<u8>, usize, Fields) {
|
||||
let mut comments = Vec::new();
|
||||
put_string(&mut comments, b"SpotiFLAC");
|
||||
comments.extend(2_u32.to_le_bytes());
|
||||
put_string(&mut comments, b"CUSTOM=preserve");
|
||||
put_string(&mut comments, b"TITLE=Example");
|
||||
let blocks = [
|
||||
(0, vec![0; 34]),
|
||||
(1, vec![0; 16]),
|
||||
(4, comments),
|
||||
(6, picture(b"original cover", &|| Ok(())).unwrap().unwrap()),
|
||||
];
|
||||
let mut input = b"fLaC".to_vec();
|
||||
for (index, (kind, data)) in blocks.iter().enumerate() {
|
||||
input.push(kind | if index + 1 == blocks.len() { 0x80 } else { 0 });
|
||||
input.extend(&(data.len() as u32).to_be_bytes()[1..]);
|
||||
input.extend(data);
|
||||
}
|
||||
let audio_start = input.len();
|
||||
input.extend([0xff, 0xf8]);
|
||||
input.resize(audio_start + 1024 * 1024, 0x55);
|
||||
let fields = Fields::from([("title".into(), "Example".into())]);
|
||||
(input, audio_start, fields)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_flac_tags_do_not_copy_audio_or_write_output() {
|
||||
for cover in [None, Some(b"original cover".as_slice())] {
|
||||
let (input, audio_start, fields) = editable_flac();
|
||||
let bytes_read = Rc::new(Cell::new(0));
|
||||
let mut source = CountingReader {
|
||||
data: Cursor::new(input),
|
||||
bytes_read: bytes_read.clone(),
|
||||
};
|
||||
let mut output = Vec::new();
|
||||
assert!(
|
||||
!rewrite_flac_tags_if_changed(&mut source, &mut output, &fields, cover, &|| Ok(()),)
|
||||
.unwrap()
|
||||
);
|
||||
assert!(output.is_empty());
|
||||
// One header parse, two frame-sync bytes, one exact comparison.
|
||||
assert_eq!(bytes_read.get(), audio_start * 2 + 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_flac_tags_match_full_rewrite_and_preserve_audio() {
|
||||
let (input, audio_start, _) = editable_flac();
|
||||
for (title, cover) in [
|
||||
("Changed", None), // Equal-length header with different bytes.
|
||||
("A longer title", None),
|
||||
("Example", Some(b"different artwork".as_slice())),
|
||||
] {
|
||||
let fields = Fields::from([("title".into(), title.into())]);
|
||||
let mut expected = Vec::new();
|
||||
rewrite_audio_tags(
|
||||
&mut Cursor::new(&input),
|
||||
&mut expected,
|
||||
"flac",
|
||||
&fields,
|
||||
cover,
|
||||
&|| Ok(()),
|
||||
)
|
||||
.unwrap();
|
||||
let mut output = Vec::new();
|
||||
assert!(
|
||||
rewrite_flac_tags_if_changed(
|
||||
&mut Cursor::new(&input),
|
||||
&mut output,
|
||||
&fields,
|
||||
cover,
|
||||
&|| Ok(()),
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(output, expected);
|
||||
assert!(output.ends_with(&input[audio_start..]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_flac_still_validates_frames_and_observes_cancellation() {
|
||||
let (input, audio_start, fields) = editable_flac();
|
||||
let bytes_read = Rc::new(Cell::new(0));
|
||||
let mut source = CountingReader {
|
||||
data: Cursor::new(input.clone()),
|
||||
bytes_read: bytes_read.clone(),
|
||||
};
|
||||
let mut output = Vec::new();
|
||||
let error = rewrite_flac_tags_if_changed(&mut source, &mut output, &fields, None, &|| {
|
||||
if bytes_read.get() >= audio_start + 2 {
|
||||
Err("cancelled".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
.unwrap_err();
|
||||
assert_eq!(error, "cancelled");
|
||||
assert!(output.is_empty());
|
||||
|
||||
let mut invalid = input;
|
||||
invalid[audio_start] = 0;
|
||||
assert!(
|
||||
rewrite_flac_tags_if_changed(
|
||||
&mut Cursor::new(invalid),
|
||||
&mut output,
|
||||
&fields,
|
||||
None,
|
||||
&|| Ok(()),
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains("sync code")
|
||||
);
|
||||
assert!(output.is_empty());
|
||||
}
|
||||
|
||||
fn flac_with_pictures(lengths: &[usize]) -> (Vec<u8>, usize) {
|
||||
let mut comments = Vec::new();
|
||||
put_string(&mut comments, b"Example vendor");
|
||||
comments.extend(1_u32.to_le_bytes());
|
||||
put_string(&mut comments, b"CUSTOM=preserve");
|
||||
let mut blocks = vec![
|
||||
(0, vec![0; 34]),
|
||||
(2, b"unknown block".to_vec()),
|
||||
(4, comments),
|
||||
];
|
||||
blocks.extend(lengths.iter().map(|length| (6, vec![0x55; *length])));
|
||||
let mut input = b"fLaC".to_vec();
|
||||
for (index, (kind, data)) in blocks.iter().enumerate() {
|
||||
input.push(kind | if index + 1 == blocks.len() { 0x80 } else { 0 });
|
||||
input.extend(&(data.len() as u32).to_be_bytes()[1..]);
|
||||
input.extend(data);
|
||||
}
|
||||
let audio_start = input.len();
|
||||
input.extend([0xff, 0xf8, 0x12, 0x34, 0x56]);
|
||||
(input, audio_start)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacement_flac_artwork_preserves_other_blocks_and_audio() {
|
||||
let (input, audio_start) = flac_with_pictures(&[131_073, 262_145]);
|
||||
let (without_pictures, _) = flac_with_pictures(&[]);
|
||||
let cover = Some(b"replacement artwork".as_slice());
|
||||
for embed in [false, true] {
|
||||
let fields = Fields::from([(
|
||||
if embed { "TITLE" } else { "title" }.into(),
|
||||
"Changed title".into(),
|
||||
)]);
|
||||
let rewrite = |data: &[u8], cover| {
|
||||
let mut output = Vec::new();
|
||||
if embed {
|
||||
embed_flac_metadata(
|
||||
&mut Cursor::new(data),
|
||||
&mut output,
|
||||
&fields,
|
||||
"",
|
||||
cover,
|
||||
&|| Ok(()),
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
rewrite_audio_tags(
|
||||
&mut Cursor::new(data),
|
||||
&mut output,
|
||||
"flac",
|
||||
&fields,
|
||||
cover,
|
||||
&|| Ok(()),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
output
|
||||
};
|
||||
let output = rewrite(&input, cover);
|
||||
assert_eq!(output, rewrite(&without_pictures, cover));
|
||||
assert!(output.ends_with(&input[audio_start..]));
|
||||
let retained = rewrite(&input, None);
|
||||
assert!(
|
||||
retained
|
||||
.windows(131_073)
|
||||
.any(|bytes| bytes == [0x55; 131_073])
|
||||
);
|
||||
assert!(retained.ends_with(&input[audio_start..]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discarded_flac_picture_preserves_eof_and_cancellation() {
|
||||
let (input, audio_start) = flac_with_pictures(&[131_073]);
|
||||
let picture_start = audio_start - 131_073;
|
||||
let fields = Fields::new();
|
||||
for available in [0, 1, 65536, 65537, 131_072] {
|
||||
let truncated = &input[..picture_start + available];
|
||||
let mut expected = Vec::new();
|
||||
let error = rewrite_audio_tags(
|
||||
&mut Cursor::new(truncated),
|
||||
&mut expected,
|
||||
"flac",
|
||||
&fields,
|
||||
None,
|
||||
&|| Ok(()),
|
||||
)
|
||||
.unwrap_err();
|
||||
let mut output = Vec::new();
|
||||
assert_eq!(
|
||||
rewrite_audio_tags(
|
||||
&mut Cursor::new(truncated),
|
||||
&mut output,
|
||||
"flac",
|
||||
&fields,
|
||||
Some(b"replacement artwork"),
|
||||
&|| Ok(()),
|
||||
)
|
||||
.unwrap_err(),
|
||||
error
|
||||
);
|
||||
assert!(output.is_empty());
|
||||
}
|
||||
let bytes_read = Rc::new(Cell::new(0));
|
||||
let mut source = CountingReader {
|
||||
data: Cursor::new(input),
|
||||
bytes_read: bytes_read.clone(),
|
||||
};
|
||||
let mut output = Vec::new();
|
||||
assert_eq!(
|
||||
rewrite_flac_tags_if_changed(
|
||||
&mut source,
|
||||
&mut output,
|
||||
&fields,
|
||||
Some(b"replacement artwork"),
|
||||
&|| {
|
||||
if bytes_read.get() >= picture_start + 65536 {
|
||||
Err("cancelled".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
)
|
||||
.unwrap_err(),
|
||||
"cancelled"
|
||||
);
|
||||
assert!(bytes_read.get() < audio_start);
|
||||
assert!(output.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discarded_flac_pictures_still_count_toward_metadata_limit() {
|
||||
let (input, _) = flac_with_pictures(&vec![0; 65534]);
|
||||
let mut output = Vec::new();
|
||||
assert_eq!(
|
||||
rewrite_audio_tags(
|
||||
&mut Cursor::new(input),
|
||||
&mut output,
|
||||
"flac",
|
||||
&Fields::new(),
|
||||
Some(b"replacement artwork"),
|
||||
&|| Ok(()),
|
||||
)
|
||||
.unwrap_err(),
|
||||
"FLAC metadata block count exceeds 65536"
|
||||
);
|
||||
assert!(output.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_flac_cover_reencodes_or_omits_invalid_image() {
|
||||
let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
|
||||
3,
|
||||
2,
|
||||
image::Rgba([30, 60, 90, 255]),
|
||||
));
|
||||
let mut encoded = Cursor::new(Vec::new());
|
||||
image
|
||||
.write_to(&mut encoded, image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let mut cover = encoded.into_inner();
|
||||
// Valid PNG with trailing data forces the same size fallback as a
|
||||
// large artwork file without an expensive image-generation fixture.
|
||||
cover.resize(16_000_001, 0);
|
||||
let block = picture(&cover, &|| Ok(())).unwrap().unwrap();
|
||||
let mut reader = Cursor::new(block);
|
||||
let mut number = || {
|
||||
let mut value = [0; 4];
|
||||
reader.read_exact(&mut value).unwrap();
|
||||
u32::from_be_bytes(value)
|
||||
};
|
||||
assert_eq!(number(), 3);
|
||||
assert_eq!(number(), 10);
|
||||
assert_eq!(bytes(&mut reader, 10).unwrap(), b"image/jpeg");
|
||||
assert_eq!(bytes(&mut reader, 4).unwrap(), 11_u32.to_be_bytes());
|
||||
assert_eq!(bytes(&mut reader, 11).unwrap(), b"Front Cover");
|
||||
assert_eq!(bytes(&mut reader, 4).unwrap(), 3_u32.to_be_bytes());
|
||||
assert_eq!(bytes(&mut reader, 4).unwrap(), 2_u32.to_be_bytes());
|
||||
assert_eq!(bytes(&mut reader, 4).unwrap(), 24_u32.to_be_bytes());
|
||||
assert_eq!(bytes(&mut reader, 4).unwrap(), [0; 4]);
|
||||
let length = u32::from_be_bytes(bytes(&mut reader, 4).unwrap().try_into().unwrap());
|
||||
assert!(length < 16_000_000);
|
||||
let jpeg = bytes(&mut reader, length as usize).unwrap();
|
||||
assert_eq!(
|
||||
image::ImageReader::new(Cursor::new(jpeg))
|
||||
.with_guessed_format()
|
||||
.unwrap()
|
||||
.into_dimensions()
|
||||
.unwrap(),
|
||||
(3, 2)
|
||||
);
|
||||
cover.fill(0);
|
||||
assert!(picture(&cover, &|| Ok(())).unwrap().is_none());
|
||||
assert_eq!(
|
||||
picture(b"small cover", &|| Err("cancelled".into())).unwrap_err(),
|
||||
"cancelled"
|
||||
);
|
||||
}
|
||||
@@ -702,245 +702,4 @@ pub(crate) fn clean(path: &Path) -> PathBuf {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::{Seek, SeekFrom, Write};
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn native_reads_reject_links_at_every_component_and_never_write_or_wait_on_special_files() {
|
||||
use std::io::Read;
|
||||
use std::os::unix::{fs::symlink, net::UnixListener};
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let files = Arc::new(FileRegistry::default())
|
||||
.extension(root.path())
|
||||
.unwrap();
|
||||
std::fs::create_dir_all(root.path().join("album/disc")).unwrap();
|
||||
std::fs::write(root.path().join("album/disc/song.wav"), b"original").unwrap();
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let parent = root.path().join("album");
|
||||
std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o111)).unwrap();
|
||||
let result = files
|
||||
.resolve("album/disc/song.wav")
|
||||
.unwrap()
|
||||
.open_native_read();
|
||||
std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
assert!(result.is_ok(), "execute-only parent: {result:?}");
|
||||
}
|
||||
let mut file = files
|
||||
.resolve("album/disc/song.wav")
|
||||
.unwrap()
|
||||
.open_native_read()
|
||||
.unwrap();
|
||||
let mut data = Vec::new();
|
||||
file.read_to_end(&mut data).unwrap();
|
||||
assert_eq!(data, b"original");
|
||||
assert!(file.write_all(b"changed").is_err());
|
||||
for (target, link) in [
|
||||
("album", "album-link"),
|
||||
("album/disc/song.wav", "song-link.wav"),
|
||||
("missing.wav", "dangling.wav"),
|
||||
] {
|
||||
symlink(target, root.path().join(link)).unwrap();
|
||||
}
|
||||
for path in ["album-link/disc/song.wav", "song-link.wav", "dangling.wav"] {
|
||||
assert!(
|
||||
files
|
||||
.resolve(path)
|
||||
.unwrap()
|
||||
.open_native_read()
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("symlinks")
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
files
|
||||
.resolve("missing.wav")
|
||||
.unwrap()
|
||||
.open_native_read()
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
io::ErrorKind::NotFound
|
||||
);
|
||||
assert!(
|
||||
std::process::Command::new("mkfifo")
|
||||
.arg(root.path().join("pipe.wav"))
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
let _socket = UnixListener::bind(root.path().join("socket.wav")).unwrap();
|
||||
for path in ["pipe.wav", "socket.wav"] {
|
||||
assert!(
|
||||
files
|
||||
.resolve(path)
|
||||
.unwrap()
|
||||
.open_native_read()
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("special files")
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
std::fs::read(root.path().join("album/disc/song.wav")).unwrap(),
|
||||
b"original"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exclusive_publication_preserves_racing_output_and_cleans_cancelled_stage() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let files = Arc::new(FileRegistry::default())
|
||||
.extension(root.path())
|
||||
.unwrap();
|
||||
let path = files.resolve("track.flac").unwrap();
|
||||
let mut stage = path.stage().unwrap();
|
||||
stage.file.write_all(b"new audio").unwrap();
|
||||
assert!(!path.absolute.exists());
|
||||
let error = stage
|
||||
.publish_new(&|| {
|
||||
// A different publisher creates the destination after the
|
||||
// planner's existence check but before the publication syscall.
|
||||
std::fs::write(&path.absolute, b"other publisher").unwrap();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
|
||||
assert_eq!(std::fs::read(&path.absolute).unwrap(), b"other publisher");
|
||||
|
||||
let cancelled = files.resolve("cancelled.flac").unwrap();
|
||||
let mut stage = cancelled.stage().unwrap();
|
||||
stage.file.write_all(b"cancelled audio").unwrap();
|
||||
assert_eq!(
|
||||
stage
|
||||
.publish_new(&|| Err("cancelled".into()))
|
||||
.unwrap_err()
|
||||
.to_string(),
|
||||
"cancelled"
|
||||
);
|
||||
assert!(!cancelled.absolute.exists());
|
||||
|
||||
path.remove().unwrap();
|
||||
let mut stage = path.stage().unwrap();
|
||||
stage.file.write_all(b"complete audio").unwrap();
|
||||
stage.publish_new(&|| Ok(())).unwrap();
|
||||
assert_eq!(std::fs::read(&path.absolute).unwrap(), b"complete audio");
|
||||
assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn stage_from_links_same_filesystem_source_and_survives_source_cleanup() {
|
||||
use cap_std::fs::MetadataExt;
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let files = Arc::new(FileRegistry::default())
|
||||
.extension(root.path())
|
||||
.unwrap();
|
||||
let source_path = files.resolve("source.bin").unwrap();
|
||||
std::fs::write(&source_path.absolute, b"source audio").unwrap();
|
||||
let source = source_path.open(OpenOptions::new().read(true)).unwrap();
|
||||
let destination = files.resolve("destination.bin").unwrap();
|
||||
let (stage, promoted) = destination.stage_from(&source_path, &source).unwrap();
|
||||
assert!(promoted);
|
||||
let source_metadata = source.metadata().unwrap();
|
||||
let stage_metadata = stage.file.metadata().unwrap();
|
||||
assert_eq!(source_metadata.dev(), stage_metadata.dev());
|
||||
assert_eq!(source_metadata.ino(), stage_metadata.ino());
|
||||
|
||||
stage.publish_new(&|| Ok(())).unwrap();
|
||||
drop(source);
|
||||
source_path.remove().unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read(&destination.absolute).unwrap(),
|
||||
b"source audio"
|
||||
);
|
||||
assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn stage_from_copies_open_source_after_path_replacement() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let files = Arc::new(FileRegistry::default())
|
||||
.extension(root.path())
|
||||
.unwrap();
|
||||
let source_path = files.resolve("source.bin").unwrap();
|
||||
std::fs::write(&source_path.absolute, b"original audio").unwrap();
|
||||
let mut source = source_path.open(OpenOptions::new().read(true)).unwrap();
|
||||
std::fs::rename(&source_path.absolute, root.path().join("source.old")).unwrap();
|
||||
std::fs::write(&source_path.absolute, b"replacement audio").unwrap();
|
||||
let destination = files.resolve("destination.bin").unwrap();
|
||||
let (mut stage, promoted) = destination.stage_from(&source_path, &source).unwrap();
|
||||
assert!(!promoted);
|
||||
source.seek(SeekFrom::Start(0)).unwrap();
|
||||
std::io::copy(&mut source, &mut stage.file).unwrap();
|
||||
stage.publish_new(&|| Ok(())).unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read(&destination.absolute).unwrap(),
|
||||
b"original audio"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn stage_from_copies_open_source_after_path_becomes_symlink() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let files = Arc::new(FileRegistry::default())
|
||||
.extension(root.path())
|
||||
.unwrap();
|
||||
let source_path = files.resolve("source.bin").unwrap();
|
||||
std::fs::write(&source_path.absolute, b"original audio").unwrap();
|
||||
let mut source = source_path.open(OpenOptions::new().read(true)).unwrap();
|
||||
std::fs::rename(&source_path.absolute, root.path().join("source.old")).unwrap();
|
||||
symlink("source.old", &source_path.absolute).unwrap();
|
||||
let destination = files.resolve("destination.bin").unwrap();
|
||||
let (mut stage, promoted) = destination.stage_from(&source_path, &source).unwrap();
|
||||
assert!(!promoted);
|
||||
source.seek(SeekFrom::Start(0)).unwrap();
|
||||
std::io::copy(&mut source, &mut stage.file).unwrap();
|
||||
stage.publish_new(&|| Ok(())).unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read(&destination.absolute).unwrap(),
|
||||
b"original audio"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn stage_from_preserves_existing_destination_and_cleans_cancelled_stage() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let files = Arc::new(FileRegistry::default())
|
||||
.extension(root.path())
|
||||
.unwrap();
|
||||
let source_path = files.resolve("source.bin").unwrap();
|
||||
std::fs::write(&source_path.absolute, b"source audio").unwrap();
|
||||
let source = source_path.open(OpenOptions::new().read(true)).unwrap();
|
||||
let destination = files.resolve("destination.bin").unwrap();
|
||||
std::fs::write(&destination.absolute, b"existing audio").unwrap();
|
||||
|
||||
let (stage, promoted) = destination.stage_from(&source_path, &source).unwrap();
|
||||
assert!(promoted);
|
||||
let error = stage.publish_new(&|| Ok(())).unwrap_err();
|
||||
assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
|
||||
assert_eq!(
|
||||
std::fs::read(&destination.absolute).unwrap(),
|
||||
b"existing audio"
|
||||
);
|
||||
assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 2);
|
||||
|
||||
std::fs::remove_file(&destination.absolute).unwrap();
|
||||
let (stage, promoted) = destination.stage_from(&source_path, &source).unwrap();
|
||||
assert!(promoted);
|
||||
let error = stage.publish_new(&|| Err("cancelled".into())).unwrap_err();
|
||||
assert_eq!(error.to_string(), "cancelled");
|
||||
assert!(!destination.absolute.exists());
|
||||
assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 1);
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
@@ -0,0 +1,240 @@
|
||||
use super::*;
|
||||
use std::io::{Seek, SeekFrom, Write};
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn native_reads_reject_links_at_every_component_and_never_write_or_wait_on_special_files() {
|
||||
use std::io::Read;
|
||||
use std::os::unix::{fs::symlink, net::UnixListener};
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let files = Arc::new(FileRegistry::default())
|
||||
.extension(root.path())
|
||||
.unwrap();
|
||||
std::fs::create_dir_all(root.path().join("album/disc")).unwrap();
|
||||
std::fs::write(root.path().join("album/disc/song.wav"), b"original").unwrap();
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let parent = root.path().join("album");
|
||||
std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o111)).unwrap();
|
||||
let result = files
|
||||
.resolve("album/disc/song.wav")
|
||||
.unwrap()
|
||||
.open_native_read();
|
||||
std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
assert!(result.is_ok(), "execute-only parent: {result:?}");
|
||||
}
|
||||
let mut file = files
|
||||
.resolve("album/disc/song.wav")
|
||||
.unwrap()
|
||||
.open_native_read()
|
||||
.unwrap();
|
||||
let mut data = Vec::new();
|
||||
file.read_to_end(&mut data).unwrap();
|
||||
assert_eq!(data, b"original");
|
||||
assert!(file.write_all(b"changed").is_err());
|
||||
for (target, link) in [
|
||||
("album", "album-link"),
|
||||
("album/disc/song.wav", "song-link.wav"),
|
||||
("missing.wav", "dangling.wav"),
|
||||
] {
|
||||
symlink(target, root.path().join(link)).unwrap();
|
||||
}
|
||||
for path in ["album-link/disc/song.wav", "song-link.wav", "dangling.wav"] {
|
||||
assert!(
|
||||
files
|
||||
.resolve(path)
|
||||
.unwrap()
|
||||
.open_native_read()
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("symlinks")
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
files
|
||||
.resolve("missing.wav")
|
||||
.unwrap()
|
||||
.open_native_read()
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
io::ErrorKind::NotFound
|
||||
);
|
||||
assert!(
|
||||
std::process::Command::new("mkfifo")
|
||||
.arg(root.path().join("pipe.wav"))
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
let _socket = UnixListener::bind(root.path().join("socket.wav")).unwrap();
|
||||
for path in ["pipe.wav", "socket.wav"] {
|
||||
assert!(
|
||||
files
|
||||
.resolve(path)
|
||||
.unwrap()
|
||||
.open_native_read()
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("special files")
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
std::fs::read(root.path().join("album/disc/song.wav")).unwrap(),
|
||||
b"original"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exclusive_publication_preserves_racing_output_and_cleans_cancelled_stage() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let files = Arc::new(FileRegistry::default())
|
||||
.extension(root.path())
|
||||
.unwrap();
|
||||
let path = files.resolve("track.flac").unwrap();
|
||||
let mut stage = path.stage().unwrap();
|
||||
stage.file.write_all(b"new audio").unwrap();
|
||||
assert!(!path.absolute.exists());
|
||||
let error = stage
|
||||
.publish_new(&|| {
|
||||
// A different publisher creates the destination after the
|
||||
// planner's existence check but before the publication syscall.
|
||||
std::fs::write(&path.absolute, b"other publisher").unwrap();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
|
||||
assert_eq!(std::fs::read(&path.absolute).unwrap(), b"other publisher");
|
||||
|
||||
let cancelled = files.resolve("cancelled.flac").unwrap();
|
||||
let mut stage = cancelled.stage().unwrap();
|
||||
stage.file.write_all(b"cancelled audio").unwrap();
|
||||
assert_eq!(
|
||||
stage
|
||||
.publish_new(&|| Err("cancelled".into()))
|
||||
.unwrap_err()
|
||||
.to_string(),
|
||||
"cancelled"
|
||||
);
|
||||
assert!(!cancelled.absolute.exists());
|
||||
|
||||
path.remove().unwrap();
|
||||
let mut stage = path.stage().unwrap();
|
||||
stage.file.write_all(b"complete audio").unwrap();
|
||||
stage.publish_new(&|| Ok(())).unwrap();
|
||||
assert_eq!(std::fs::read(&path.absolute).unwrap(), b"complete audio");
|
||||
assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn stage_from_links_same_filesystem_source_and_survives_source_cleanup() {
|
||||
use cap_std::fs::MetadataExt;
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let files = Arc::new(FileRegistry::default())
|
||||
.extension(root.path())
|
||||
.unwrap();
|
||||
let source_path = files.resolve("source.bin").unwrap();
|
||||
std::fs::write(&source_path.absolute, b"source audio").unwrap();
|
||||
let source = source_path.open(OpenOptions::new().read(true)).unwrap();
|
||||
let destination = files.resolve("destination.bin").unwrap();
|
||||
let (stage, promoted) = destination.stage_from(&source_path, &source).unwrap();
|
||||
assert!(promoted);
|
||||
let source_metadata = source.metadata().unwrap();
|
||||
let stage_metadata = stage.file.metadata().unwrap();
|
||||
assert_eq!(source_metadata.dev(), stage_metadata.dev());
|
||||
assert_eq!(source_metadata.ino(), stage_metadata.ino());
|
||||
|
||||
stage.publish_new(&|| Ok(())).unwrap();
|
||||
drop(source);
|
||||
source_path.remove().unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read(&destination.absolute).unwrap(),
|
||||
b"source audio"
|
||||
);
|
||||
assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn stage_from_copies_open_source_after_path_replacement() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let files = Arc::new(FileRegistry::default())
|
||||
.extension(root.path())
|
||||
.unwrap();
|
||||
let source_path = files.resolve("source.bin").unwrap();
|
||||
std::fs::write(&source_path.absolute, b"original audio").unwrap();
|
||||
let mut source = source_path.open(OpenOptions::new().read(true)).unwrap();
|
||||
std::fs::rename(&source_path.absolute, root.path().join("source.old")).unwrap();
|
||||
std::fs::write(&source_path.absolute, b"replacement audio").unwrap();
|
||||
let destination = files.resolve("destination.bin").unwrap();
|
||||
let (mut stage, promoted) = destination.stage_from(&source_path, &source).unwrap();
|
||||
assert!(!promoted);
|
||||
source.seek(SeekFrom::Start(0)).unwrap();
|
||||
std::io::copy(&mut source, &mut stage.file).unwrap();
|
||||
stage.publish_new(&|| Ok(())).unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read(&destination.absolute).unwrap(),
|
||||
b"original audio"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn stage_from_copies_open_source_after_path_becomes_symlink() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let files = Arc::new(FileRegistry::default())
|
||||
.extension(root.path())
|
||||
.unwrap();
|
||||
let source_path = files.resolve("source.bin").unwrap();
|
||||
std::fs::write(&source_path.absolute, b"original audio").unwrap();
|
||||
let mut source = source_path.open(OpenOptions::new().read(true)).unwrap();
|
||||
std::fs::rename(&source_path.absolute, root.path().join("source.old")).unwrap();
|
||||
symlink("source.old", &source_path.absolute).unwrap();
|
||||
let destination = files.resolve("destination.bin").unwrap();
|
||||
let (mut stage, promoted) = destination.stage_from(&source_path, &source).unwrap();
|
||||
assert!(!promoted);
|
||||
source.seek(SeekFrom::Start(0)).unwrap();
|
||||
std::io::copy(&mut source, &mut stage.file).unwrap();
|
||||
stage.publish_new(&|| Ok(())).unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read(&destination.absolute).unwrap(),
|
||||
b"original audio"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn stage_from_preserves_existing_destination_and_cleans_cancelled_stage() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let files = Arc::new(FileRegistry::default())
|
||||
.extension(root.path())
|
||||
.unwrap();
|
||||
let source_path = files.resolve("source.bin").unwrap();
|
||||
std::fs::write(&source_path.absolute, b"source audio").unwrap();
|
||||
let source = source_path.open(OpenOptions::new().read(true)).unwrap();
|
||||
let destination = files.resolve("destination.bin").unwrap();
|
||||
std::fs::write(&destination.absolute, b"existing audio").unwrap();
|
||||
|
||||
let (stage, promoted) = destination.stage_from(&source_path, &source).unwrap();
|
||||
assert!(promoted);
|
||||
let error = stage.publish_new(&|| Ok(())).unwrap_err();
|
||||
assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
|
||||
assert_eq!(
|
||||
std::fs::read(&destination.absolute).unwrap(),
|
||||
b"existing audio"
|
||||
);
|
||||
assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 2);
|
||||
|
||||
std::fs::remove_file(&destination.absolute).unwrap();
|
||||
let (stage, promoted) = destination.stage_from(&source_path, &source).unwrap();
|
||||
assert!(promoted);
|
||||
let error = stage.publish_new(&|| Err("cancelled".into())).unwrap_err();
|
||||
assert_eq!(error.to_string(), "cancelled");
|
||||
assert!(!destination.absolute.exists());
|
||||
assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 1);
|
||||
}
|
||||
Reference in new issue
Block a user