Some fixes
This commit is contained in:
parent
50f8b8cfba
commit
e6c694a740
12 changed files with 230 additions and 67 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1 +1 @@
|
|||
target
|
||||
/target
|
||||
|
|
|
|||
11
Cargo.lock
generated
11
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ members = [
|
|||
"crates/library",
|
||||
"crates/tui",
|
||||
"crates/nsm",
|
||||
"crates/textfix",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
|
|
|
|||
23
README.en.md
23
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 <<EOF
|
||||
[target.x86_64-pc-windows-gnu]
|
||||
linker = "x86_64-w64-mingw32-gcc"
|
||||
EOF
|
||||
|
||||
# 4. Build
|
||||
cargo build --release --target x86_64-pc-windows-gnu
|
||||
```
|
||||
The binary will be at `target/x86_64-pc-windows-gnu/release/nsm.exe`.
|
||||
|
||||
**Partially verified**: the linker itself (`mingw-w64`) was actually tested — compiled a test C program with it and got a genuine Windows PE executable. Couldn't verify a full end-to-end `nsm` build for the Windows target (that needs `rustup` to fetch the Windows std library, which this environment can't reach) — but along the way found and fixed a real bug: the code used a Unix-specific way to detect the terminal's cell size for cover art, which would have made the Windows build fail to compile at all. Non-Unix platforms now use a sensible default instead.
|
||||
|
||||
### Run
|
||||
|
||||
```bash
|
||||
|
|
|
|||
23
README.md
23
README.md
|
|
@ -78,6 +78,29 @@ cargo build --release
|
|||
|
||||
Бинарник появится в `target/release/nsm` (на Windows — `target\release\nsm.exe`).
|
||||
|
||||
### Сборка Windows-бинарника с Linux (кросс-компиляция)
|
||||
|
||||
```bash
|
||||
# 1. Добавить Windows-таргет (нужен rustup — обычная установка Rust с rustup.rs)
|
||||
rustup target add x86_64-pc-windows-gnu
|
||||
|
||||
# 2. Поставить mingw-w64 — кросс-компилятор/линкер под Windows
|
||||
sudo apt-get install gcc-mingw-w64-x86-64 # Debian/Ubuntu; на других — свой пакетный менеджер
|
||||
|
||||
# 3. Указать cargo, каким линкером пользоваться для этого таргета
|
||||
mkdir -p .cargo
|
||||
cat >> .cargo/config.toml <<EOF
|
||||
[target.x86_64-pc-windows-gnu]
|
||||
linker = "x86_64-w64-mingw32-gcc"
|
||||
EOF
|
||||
|
||||
# 4. Собрать
|
||||
cargo build --release --target x86_64-pc-windows-gnu
|
||||
```
|
||||
Бинарник появится в `target/x86_64-pc-windows-gnu/release/nsm.exe`.
|
||||
|
||||
**Проверено частично**: сам линкер (`mingw-w64`) реально протестирован — собрал им тестовую C-программу, получился настоящий Windows PE-исполняемый файл. Полную сборку `nsm` под Windows-таргет от начала до конца проверить не удалось (нужен `rustup` для загрузки Windows std-библиотеки, а в этом окружении к нему нет доступа) — но по ходу проверки нашёлся и уже исправлен реальный баг: код использовал Unix-специфичный способ определения размера ячейки терминала для обложки, из-за чего сборка под Windows не прошла бы вообще. Теперь на не-Unix платформах используется разумное значение по умолчанию.
|
||||
|
||||
### Запуск
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ lofty = "0.19"
|
|||
nucleo-matcher = "0.3"
|
||||
ureq = { version = "2", features = ["json"] }
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
|
||||
encoding_rs = "0.8"
|
||||
textfix = { path = "../textfix" }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ use walkdir::WalkDir;
|
|||
|
||||
use crate::cue;
|
||||
use crate::model::{is_audio_file, is_cue_file, Track};
|
||||
use textfix::fix_mojibake;
|
||||
|
||||
/// Рекурсивно обходит `root` и все вложенные папки. Сначала обрабатывает `.cue`-листы —
|
||||
/// каждый превращается в несколько "виртуальных" треков (диапазонов внутри одного
|
||||
|
|
@ -172,47 +173,8 @@ fn parse_replay_gain_db(raw: &str) -> Option<f32> {
|
|||
.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<Vec<u8>> = 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, где она и живёт.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,5 +13,7 @@ rand = "0.8"
|
|||
ringbuf = "0.3"
|
||||
ureq = "2"
|
||||
realfft = "3"
|
||||
textfix = { path = "../textfix" }
|
||||
|
||||
[dev-dependencies]
|
||||
encoding_rs = "0.8"
|
||||
|
|
|
|||
|
|
@ -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<dyn FormatReader>,
|
||||
|
|
@ -313,9 +314,9 @@ pub fn read_tags(path: &Path) -> anyhow::Result<TrackInfo> {
|
|||
|
||||
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<u8> {
|
||||
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]
|
||||
|
|
|
|||
7
crates/textfix/Cargo.toml
Normal file
7
crates/textfix/Cargo.toml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
[package]
|
||||
name = "textfix"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
encoding_rs = "0.8"
|
||||
77
crates/textfix/src/lib.rs
Normal file
77
crates/textfix/src/lib.rs
Normal file
|
|
@ -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<Vec<u8>> = 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("Пример"), "Пример");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue