Some fixes

This commit is contained in:
Magnus Root 2026-07-13 09:31:20 +03:00
parent 50f8b8cfba
commit e6c694a740
12 changed files with 230 additions and 67 deletions

2
.gitignore vendored
View file

@ -1 +1 @@
target
/target

11
Cargo.lock generated
View file

@ -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"

View file

@ -5,6 +5,7 @@ members = [
"crates/library",
"crates/tui",
"crates/nsm",
"crates/textfix",
]
[workspace.package]

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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, где она и живёт.
}

View file

@ -13,5 +13,7 @@ rand = "0.8"
ringbuf = "0.3"
ureq = "2"
realfft = "3"
textfix = { path = "../textfix" }
[dev-dependencies]
encoding_rs = "0.8"

View file

@ -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]

View 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
View 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("Пример"), "Пример");
}
}

View file

@ -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;