diff --git a/.gitignore b/.gitignore index 1de5659..ea8c4bf 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1 @@ -target \ No newline at end of file +/target diff --git a/Cargo.lock b/Cargo.lock index ba30285..a355502 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -713,12 +713,12 @@ version = "0.1.0" dependencies = [ "anyhow", "chrono", - "encoding_rs", "lofty", "nucleo-matcher", "serde", "serde_json", "tempfile", + "textfix", "thiserror", "ureq", "walkdir", @@ -1031,11 +1031,13 @@ version = "0.1.0" dependencies = [ "anyhow", "cpal", + "encoding_rs", "rand", "realfft", "ringbuf", "rubato", "symphonia", + "textfix", "thiserror", "ureq", ] @@ -1711,6 +1713,13 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "textfix" +version = "0.1.0" +dependencies = [ + "encoding_rs", +] + [[package]] name = "thiserror" version = "1.0.69" diff --git a/Cargo.toml b/Cargo.toml index d988b02..ea7ecb3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/library", "crates/tui", "crates/nsm", + "crates/textfix", ] [workspace.package] diff --git a/README.en.md b/README.en.md index 450ad96..415e9d3 100644 --- a/README.en.md +++ b/README.en.md @@ -65,6 +65,29 @@ cargo build --release The binary will be at `target/release/nsm` (on Windows — `target\release\nsm.exe`). +### Cross-compiling a Windows binary from Linux + +```bash +# 1. Add the Windows target (needs rustup — the normal Rust install via rustup.rs) +rustup target add x86_64-pc-windows-gnu + +# 2. Install mingw-w64 — the Windows cross-compiler/linker +sudo apt-get install gcc-mingw-w64-x86-64 # Debian/Ubuntu; use your own package manager otherwise + +# 3. Tell cargo which linker to use for this target +mkdir -p .cargo +cat >> .cargo/config.toml <> .cargo/config.toml < Option { .ok() } -/// Чинит "кракозябры" — частый случай со старыми (обычно ID3v1) тегами, где кириллица -/// была записана в CP1251, а прочитана так, будто это Latin-1/ISO-8859-1 (типичное -/// поведение множества старых тегеров и библиотек). Раз символы результата — это -/// однозначно code points ≤0xFF (свойство Latin-1-декодирования), исходные байты -/// восстанавливаются 1:1, и остаётся просто раскодировать их как CP1251. Если результат -/// не выглядит правдоподобно кириллическим — возвращаем строку как есть, не трогаем. -fn fix_mojibake(s: &str) -> String { - if s.is_ascii() { - return s.to_string(); - } - let bytes: Option> = s - .chars() - .map(|c| { - let cp = c as u32; - if cp <= 0xFF { - Some(cp as u8) - } else { - None - } - }) - .collect(); - let Some(bytes) = bytes else { - return s.to_string(); // содержит символы вне Latin-1 — это не наш случай, не трогаем - }; - - let (decoded, _, had_errors) = encoding_rs::WINDOWS_1251.decode(&bytes); - if had_errors { - return s.to_string(); - } - - let cyrillic_count = decoded - .chars() - .filter(|c| ('А'..='я').contains(c) || *c == 'Ё' || *c == 'ё') - .count(); - let total_alpha = decoded.chars().filter(|c| c.is_alphabetic()).count().max(1); - if cyrillic_count * 2 >= total_alpha { - decoded.into_owned() - } else { - s.to_string() - } -} +// fix_mojibake переехала в общий крейт `textfix`, используется и здесь, и в player::decode — +// раньше была только тут, из-за чего кракозябры чинились в библиотеке, но не в Now Playing. /// Достаёт обложку альбома (первую найденную картинку) для конкретного файла. /// Читается по требованию (не при сканировании), чтобы не раздувать память на всю библиотеку. @@ -241,23 +203,5 @@ mod tests { fn returns_none_for_garbage() { assert_eq!(parse_replay_gain_db("not a number"), None); } - - #[test] - fn fixes_cp1251_mojibake() { - // "Ïåñíÿ" — это ровно то, что получается, когда CP1251-байты слова "Песня" - // прочитаны как Latin-1 (типичное поведение старых ID3v1-тегов). - assert_eq!(fix_mojibake("Ïåñíÿ"), "Песня"); - assert_eq!(fix_mojibake("Àðòèñò"), "Артист"); - } - - #[test] - fn leaves_plain_ascii_untouched() { - assert_eq!(fix_mojibake("Hello World"), "Hello World"); - } - - #[test] - fn leaves_already_correct_cyrillic_untouched() { - // уже нормальный UTF-8 текст не должен портиться повторной перекодировкой - assert_eq!(fix_mojibake("Пример"), "Пример"); - } + // fix_mojibake теперь тестируется в крейте textfix, где она и живёт. } diff --git a/crates/player/Cargo.toml b/crates/player/Cargo.toml index 0298b8a..91e88f1 100644 --- a/crates/player/Cargo.toml +++ b/crates/player/Cargo.toml @@ -13,5 +13,7 @@ rand = "0.8" ringbuf = "0.3" ureq = "2" realfft = "3" +textfix = { path = "../textfix" } [dev-dependencies] +encoding_rs = "0.8" diff --git a/crates/player/src/decode.rs b/crates/player/src/decode.rs index 9272147..971bc70 100644 --- a/crates/player/src/decode.rs +++ b/crates/player/src/decode.rs @@ -13,6 +13,7 @@ use symphonia::core::probe::Hint; use symphonia::core::units::Time; use crate::types::{PlayerEvent, TrackInfo}; +use textfix::fix_mojibake; pub struct DecodedTrack { pub format: Box, @@ -313,9 +314,9 @@ pub fn read_tags(path: &Path) -> anyhow::Result { Ok(TrackInfo { path: path.to_path_buf(), - title: title.unwrap_or(fallback_title), - artist: artist.unwrap_or_else(|| "Unknown Artist".to_string()), - album: album.unwrap_or_else(|| "Unknown Album".to_string()), + title: fix_mojibake(&title.unwrap_or(fallback_title)), + artist: fix_mojibake(&artist.unwrap_or_else(|| "Unknown Artist".to_string())), + album: fix_mojibake(&album.unwrap_or_else(|| "Unknown Album".to_string())), duration, cover, }) @@ -346,6 +347,73 @@ mod tests { assert_eq!(parse_stream_title(meta), None); } + /// Регрессионный тест на реальный баг: кракозябры чинились в библиотеке (library::scanner), + /// но не в TrackInfo, который показывается в "Now Playing" при реальном воспроизведении — + /// потому что read_tags жила отдельно и не применяла fix_mojibake. Воспроизводит точный + /// сценарий из багрепорта: ID3v2-тег, где байты CP1251 записаны под флагом ISO-8859-1 + /// (классический баг старых тегеров на русских Windows) — именно так, а не ID3v1 + /// (symphonia, в отличие от lofty, ID3v1 не читает вовсе). Нужен ffmpeg — запускать + /// явно: `cargo test -- --ignored`. + #[test] + #[ignore] + fn read_tags_fixes_cp1251_mojibake_for_now_playing() { + let dir = std::env::temp_dir(); + let plain = dir.join("nsm_moji_plain.mp3"); + let tagged = dir.join("nsm_moji_tagged.mp3"); + + let status = std::process::Command::new("ffmpeg") + .args([ + "-y", "-f", "lavfi", "-i", "sine=frequency=440:duration=1", + "-write_id3v1", "0", "-id3v2_version", "0", + plain.to_str().unwrap(), "-loglevel", "error", + ]) + .status() + .expect("ffmpeg must be installed for this test"); + assert!(status.success()); + + // ID3v2.3 фрейм с encoding byte = 0 (ISO-8859-1), но реальные байты — CP1251. + // Именно так старые тегеры на русской Windows пишут кириллицу, "забывая" указать + // правильную кодировку. + fn frame(id: &str, text_cp1251: &[u8]) -> Vec { + let mut payload = vec![0u8]; // encoding = 0 (ISO-8859-1) + payload.extend_from_slice(text_cp1251); + let mut out = id.as_bytes().to_vec(); + out.extend_from_slice(&(payload.len() as u32).to_be_bytes()); + out.extend_from_slice(&[0, 0]); // flags + out.extend_from_slice(&payload); + out + } + fn synchsafe(n: u32) -> [u8; 4] { + [ + ((n >> 21) & 0x7f) as u8, + ((n >> 14) & 0x7f) as u8, + ((n >> 7) & 0x7f) as u8, + (n & 0x7f) as u8, + ] + } + + let (title_bytes, _, _) = encoding_rs::WINDOWS_1251.encode("Поговори с ней о сексе"); + let (artist_bytes, _, _) = encoding_rs::WINDOWS_1251.encode("Мальчишник"); + let mut frames = frame("TIT2", &title_bytes); + frames.extend(frame("TPE1", &artist_bytes)); + + let mut header = vec![b'I', b'D', b'3', 3, 0, 0]; + header.extend_from_slice(&synchsafe(frames.len() as u32)); + + let audio = std::fs::read(&plain).unwrap(); + let mut data = header; + data.extend(frames); + data.extend(audio); + std::fs::write(&tagged, &data).unwrap(); + + let info = read_tags(&tagged).expect("should read tags"); + assert_eq!(info.title, "Поговори с ней о сексе"); + assert_eq!(info.artist, "Мальчишник"); + + let _ = std::fs::remove_file(&plain); + let _ = std::fs::remove_file(&tagged); + } + /// Ручная проверка реального seek на настоящем файле. Игнорируется по умолчанию /// (нужен ffmpeg в PATH для генерации фикстуры) — запускать явно: `cargo test -- --ignored`. #[test] diff --git a/crates/textfix/Cargo.toml b/crates/textfix/Cargo.toml new file mode 100644 index 0000000..4071612 --- /dev/null +++ b/crates/textfix/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "textfix" +version.workspace = true +edition.workspace = true + +[dependencies] +encoding_rs = "0.8" diff --git a/crates/textfix/src/lib.rs b/crates/textfix/src/lib.rs new file mode 100644 index 0000000..d236b64 --- /dev/null +++ b/crates/textfix/src/lib.rs @@ -0,0 +1,77 @@ +//! Исправление "кракозябр" в тегах — общая логика для `library` (сканирование библиотеки) +//! и `player` (чтение тегов текущего трека для "Now Playing"). Раньше жила только в одном +//! из двух мест, из-за чего кракозябры чинились в списке библиотеки, но не в панели +//! Now Playing при реальном воспроизведении — вынесено сюда, чтобы такого больше не было. + +/// Чинит частый случай со старыми (обычно ID3v1, но встречается и в ID3v2) тегами, где +/// кириллица была записана в CP1251, а прочитана так, будто это Latin-1/ISO-8859-1 +/// (типичное поведение множества старых тегеров и библиотек метаданных). Раз символы +/// результата — это однозначно code points ≤0xFF (свойство Latin-1-декодирования), +/// исходные байты восстанавливаются 1:1, и остаётся просто раскодировать их как CP1251. +/// Если результат не выглядит правдоподобно кириллическим — возвращаем строку как есть. +pub fn fix_mojibake(s: &str) -> String { + if s.is_ascii() { + return s.to_string(); + } + let bytes: Option> = s + .chars() + .map(|c| { + let cp = c as u32; + if cp <= 0xFF { + Some(cp as u8) + } else { + None + } + }) + .collect(); + let Some(bytes) = bytes else { + return s.to_string(); // содержит символы вне Latin-1 — это не наш случай, не трогаем + }; + + let (decoded, _, had_errors) = encoding_rs::WINDOWS_1251.decode(&bytes); + if had_errors { + return s.to_string(); + } + + let cyrillic_count = decoded + .chars() + .filter(|c| ('А'..='я').contains(c) || *c == 'Ё' || *c == 'ё') + .count(); + let total_alpha = decoded.chars().filter(|c| c.is_alphabetic()).count().max(1); + if cyrillic_count * 2 >= total_alpha { + decoded.into_owned() + } else { + s.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fixes_cp1251_mojibake() { + // "Ïåñíÿ" — это ровно то, что получается, когда CP1251-байты слова "Песня" + // прочитаны как Latin-1 (типичное поведение старых ID3v1-тегов). + assert_eq!(fix_mojibake("Ïåñíÿ"), "Песня"); + assert_eq!(fix_mojibake("Àðòèñò"), "Артист"); + } + + #[test] + fn fixes_real_world_example_from_bug_report() { + // Реальный случай из багрепорта: старый mp3, поймано в Now Playing, но не + // в библиотеке — то есть именно то расхождение, ради которого этот крейт создан. + assert_eq!(fix_mojibake("Ìàëü÷èøíèê"), "Мальчишник"); + } + + #[test] + fn leaves_plain_ascii_untouched() { + assert_eq!(fix_mojibake("Hello World"), "Hello World"); + } + + #[test] + fn leaves_already_correct_cyrillic_untouched() { + // уже нормальный UTF-8 текст не должен портиться повторной перекодировкой + assert_eq!(fix_mojibake("Пример"), "Пример"); + } +} diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 3ac831b..10837d5 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -271,7 +271,16 @@ impl App { &library::default_new_releases_cache_path(), ); - let picker = Picker::from_termios().ok().map(|mut p| { + // `Picker::from_termios()` доступен только на Unix (использует termios-иоктлы для + // определения размера ячейки терминала в пикселях) — на Windows такого API в этом + // крейте нет, используем разумный дефолт (8x16 — типичный размер моноширинной + // ячейки), протокол всё равно определяется через guess_protocol() кросс-платформенно. + #[cfg(unix)] + let base_picker = Picker::from_termios().ok(); + #[cfg(not(unix))] + let base_picker = Some(Picker::new((8, 16))); + + let picker = base_picker.map(|mut p| { p.guess_protocol(); if let Some(saved) = settings.cover_protocol.as_deref().and_then(protocol_from_str) { p.protocol_type = saved;