diff --git a/rust_backend/crates/core/src/lyrics/payloads.rs b/rust_backend/crates/core/src/lyrics/payloads.rs index ca8728c8..a5bdf592 100644 --- a/rust_backend/crates/core/src/lyrics/payloads.rs +++ b/rust_backend/crates/core/src/lyrics/payloads.rs @@ -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 [ - "Error", - 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::>(), - ["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 = 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; diff --git a/rust_backend/crates/core/src/lyrics/payloads/tests.rs b/rust_backend/crates/core/src/lyrics/payloads/tests.rs new file mode 100644 index 00000000..145a95c9 --- /dev/null +++ b/rust_backend/crates/core/src/lyrics/payloads/tests.rs @@ -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 [ + "Error", + 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::>(), + ["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 = 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()); + } +} diff --git a/rust_backend/crates/core/src/media/hires/mod.rs b/rust_backend/crates/core/src/media/hires/mod.rs index 0ac6bea7..161d9b3d 100644 --- a/rust_backend/crates/core/src/media/hires/mod.rs +++ b/rust_backend/crates/core/src/media/hires/mod.rs @@ -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, - /// 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, -} - 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 { - 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>, - start_frame: u64, - frames: u64, - channel: u32, - check: &dyn Fn() -> Result<(), String>, -) -> Result { - 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 { - 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, diff --git a/rust_backend/crates/core/src/media/hires/reference.rs b/rust_backend/crates/core/src/media/hires/reference.rs new file mode 100644 index 00000000..28a792b9 --- /dev/null +++ b/rust_backend/crates/core/src/media/hires/reference.rs @@ -0,0 +1,146 @@ +//! Reference decoder retained for streaming parity tests. + +use super::*; + +#[derive(Default)] +pub(super) struct Window { + pub(super) signal: Vec, + /// 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, +} + +impl<'a> Source<'a> { + pub(super) fn read_window( + &mut self, + start_frame: u64, + frames: u64, + channel: u32, + check: &dyn Fn() -> Result<(), String>, + ) -> Result { + 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>, + start_frame: u64, + frames: u64, + channel: u32, + check: &dyn Fn() -> Result<(), String>, +) -> Result { + 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 { + 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) + } +} diff --git a/rust_backend/crates/core/src/tags/write.rs b/rust_backend/crates/core/src/tags/write.rs index 041f34a1..d856ad8b 100644 --- a/rust_backend/crates/core/src/tags/write.rs +++ b/rust_backend/crates/core/src/tags/write.rs @@ -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 = 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 { - static SPLIT: LazyLock = 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 = 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>, - bytes_read: Rc>, - } - - impl Read for CountingReader { - fn read(&mut self, buffer: &mut [u8]) -> io::Result { - 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 { - 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, 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, 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; diff --git a/rust_backend/crates/core/src/tags/write/artists.rs b/rust_backend/crates/core/src/tags/write/artists.rs new file mode 100644 index 00000000..11fcf943 --- /dev/null +++ b/rust_backend/crates/core/src/tags/write/artists.rs @@ -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 = 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 { + static SPLIT: LazyLock = 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 = 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 +} diff --git a/rust_backend/crates/core/src/tags/write/tests.rs b/rust_backend/crates/core/src/tags/write/tests.rs new file mode 100644 index 00000000..f8b50586 --- /dev/null +++ b/rust_backend/crates/core/src/tags/write/tests.rs @@ -0,0 +1,382 @@ +use super::*; +use std::cell::Cell; +use std::io; +use std::rc::Rc; + +struct CountingReader { + data: Cursor>, + bytes_read: Rc>, +} + +impl Read for CountingReader { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + 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 { + 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, 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, 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" + ); +} diff --git a/rust_backend/crates/extensions/src/files.rs b/rust_backend/crates/extensions/src/files.rs index 80f14d34..a1e508dd 100644 --- a/rust_backend/crates/extensions/src/files.rs +++ b/rust_backend/crates/extensions/src/files.rs @@ -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; diff --git a/rust_backend/crates/extensions/src/files/tests.rs b/rust_backend/crates/extensions/src/files/tests.rs new file mode 100644 index 00000000..24684679 --- /dev/null +++ b/rust_backend/crates/extensions/src/files/tests.rs @@ -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); +}