This commit is contained in:
Magnus Root 2026-07-25 21:00:32 +03:00
parent 359cd63834
commit c2ac57d727
17 changed files with 2790 additions and 80 deletions

1
.gitignore vendored
View file

@ -1,2 +1 @@
/target
Cargo.lock

2105
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

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

View file

@ -16,7 +16,7 @@ A terminal music player written in Rust — something like [cmus](https://cmus.g
- **Fade-out at track end**: the last 4 seconds smoothly fade to silence — toggle with `F`, off by default
- **Loudness normalization**: ReplayGain tag takes priority if present in the file, otherwise an adaptive leveler (AGC) adjusts volume on the fly — toggle with `G`, off by default
- **Internet radio**: add your own stations by URL, a curated built-in list (SomaFM, Radio Paradise), station search via the open [Radio Browser API](https://api.radio-browser.info) (auto-fails over between several mirrors if one is down), ICY metadata ("now playing" straight from the stream)
- **SMB shares**: connect to network folders (including anonymous/guest access) via the system's gvfs — no SMB protocol implemented inside the player itself
- **SMB shares**: connect to network folders (including anonymous/guest access) — a pure-Rust SMB client (the `smb` crate), no system dependency on `gio`/gvfs
- **Listening history**: asks on first launch whether to remember what you play; stats (artist/track/date) as a chart — hourly for today, daily for the last week/month; can be toggled on/off anytime, resettable with confirmation
- **New releases**: checks for new albums/EPs/singles **from artists already in your library**, via the open [MusicBrainz](https://musicbrainz.org) database (no API keys) — not a global chart, but specifically "what's new from the artists you actually listen to"
- **Keyboard layout independent**: hotkeys work the same on both English and Russian (or other Cyrillic) keyboard layouts
@ -51,7 +51,11 @@ xcode-select --install
**Windows** *(untested — this project has only been built and tested on Linux)* — no separate audio dependency needed either (`cpal` uses WASAPI); a normal Rust install via [rustup](https://rustup.rs) (MSVC toolchain) should be enough. For cover art to render well you'll want a modern terminal with true color support — Windows Terminal is recommended; the image protocols (kitty/sixel/iTerm2) are unlikely to be supported there, so cover art will fall back to block-graphics rendering.
**The SMB feature (`c` in Library) only works on Linux** — it's built entirely on `gio`/gvfs (the GNOME stack), which doesn't exist on macOS or Windows. On those systems, trying to connect an SMB share through nsm will just return a clear error ("is gvfs installed?") rather than crash — network shares there are normally mounted through Finder/Explorer instead.
**The SMB feature (`c` in Library)** is implemented with its own pure-Rust SMB client (the [`smb`](https://crates.io/crates/smb) crate) — no dependency on the system's `gio`/gvfs stack or its per-distro quirks. Connecting and scanning a share run on a background thread — the interface doesn't freeze even on a slow network.
**Requires Rust 1.85+ (edition 2024)** — at the time of writing, on Ubuntu/Debian this means separate versioned packages (`rustc-1.91`/`cargo-1.91` and similar) rather than the default (usually 1.75). If your `rustc` is older, either install versioned packages like these via `apt` if your distro has them, or use `rustup`.
**Honest testing caveat**: the core SMB logic (connecting, recursive directory listing, reading files with accurate `seek`, decoding audio over SMB) was verified against a real Samba server and works. But the sandbox this was developed in has no real sound card — `cpal` (the audio library) reliably fails to start there, and in that specific state (plus an active audio-engine thread) a hang was found when listing an SMB directory. The root cause wasn't fully tracked down (something at the intersection of ALSA/cpal and tokio's reactor, specifically on a **failed** cpal device-open attempt) — it's possible this is specific to the no-sound-card condition and won't occur where cpal opens a stream successfully. The SMB connection now runs on a background thread regardless, so even if this hang does occur, the whole interface won't freeze — only that one connection attempt will. If you do hit this, let us know — with working audio to test against, it'll narrow things down a lot faster.
No extra system dependencies are needed on any OS for internet radio (**SomaFM**, **Radio Paradise**, etc.) or MusicBrainz — TLS is handled by `rustls` (pure Rust, no OpenSSL).
@ -230,10 +234,10 @@ Toggle with `G`. If the file has a ReplayGain tag (`REPLAYGAIN_TRACK_GAIN` and e
## Known limitations
- **Only built and tested on Linux** — macOS/Windows builds haven't been verified; the SMB feature fundamentally won't work there (see above)
- **Only built and tested on Linux** — macOS/Windows builds haven't been verified (SMB is now pure Rust and in theory cross-platform, but untested outside Linux)
- **Opus is not decoded** (see "Supported formats" above)
- **Playlists don't store cue track ranges** — adding a cue track to a playlist saves the whole underlying file, not the specific virtual track
- Minimum terminal size is 70×18; below that a warning is shown instead of a broken layout
- Connecting to SMB shares is a blocking call on the UI thread; the interface will briefly freeze during the operation (radio search and new-releases checks already run in the background and don't block)
- Connecting to SMB shares, radio search, and new-releases checks all run in the background and don't block the interface
- Multi-file cue sheets (a rare case) are only supported using the first `FILE` entry in the sheet

View file

@ -16,7 +16,7 @@
- **Затухание в конце трека**: последние 4 секунды перед концом плавно уводятся в тишину — включается клавишей `F`, по умолчанию выключено
- **Выравнивание громкости**: тег ReplayGain в приоритете (если есть в файле), иначе — адаптивный левеллер (AGC), подстраивающий громкость на лету — клавиша `G`, по умолчанию выключено
- **Интернет-радио**: свои станции по URL, встроенный список (SomaFM, Radio Paradise), поиск станций через открытый [Radio Browser API](https://api.radio-browser.info) (с автопереключением между несколькими зеркалами, если одно недоступно), ICY-метаданные ("сейчас играет" прямо из потока)
- **SMB-шары**: подключение к сетевым папкам (в т.ч. анонимно/гостевой доступ) через системный gvfs — без реализации протокола внутри плеера
- **SMB-шары**: подключение к сетевым папкам (в т.ч. анонимно/гостевой доступ) — свой SMB-клиент на чистом Rust (крейт `smb`), без системных зависимостей вроде `gio`/gvfs
- **История прослушиваний**: при первом запуске спрашивает, запоминать ли прослушанное; статистика (артист/трек/дата) — диаграммой по часам за сегодня, по дням за неделю/месяц; включается/выключается в любой момент, сбрасывается с подтверждением
- **Новые релизы**: проверка новых альбомов/EP/синглов **у артистов, уже имеющихся в твоей библиотеке**, через открытую базу [MusicBrainz](https://musicbrainz.org) (без ключей API) — не глобальный чарт, а именно "что вышло нового у тех, кого ты слушаешь"
- **Раскладка**: хоткеи работают одинаково что на английской, что на русской раскладке
@ -51,20 +51,11 @@ xcode-select --install
**Windows** *(не проверено — собиралось и тестировалось только на Linux)* — отдельных аудио-зависимостей тоже не нужно (`cpal` использует WASAPI), достаточно обычной установки Rust через [rustup](https://rustup.rs) (MSVC toolchain). Для нормального отображения обложки в терминале нужен современный эмулятор с поддержкой true color — рекомендуется Windows Terminal; протоколы отображения картинок (kitty/sixel/iTerm2) там, скорее всего, не поддерживаются, так что обложка будет через block-графику как fallback.
**SMB-фича (`c` в Library) работает только на Linux** — она целиком построена на `gio`/gvfs (GNOME-стек), которого нет ни на macOS, ни на Windows. На этих системах при попытке подключить SMB-шару через nsm просто вернётся понятная ошибка ("is gvfs installed?"), без краша — сетевые папки там штатно монтируются через Finder/Проводник, не через nsm.
**SMB-фича (`c` в Library)** реализована собственным SMB-клиентом на чистом Rust (крейт [`smb`](https://crates.io/crates/smb)) — не зависит от системного `gio`/gvfs и его особенностей на конкретном дистрибутиве. Подключение и сканирование шары идут в фоновом потоке — интерфейс не подвисает даже если сеть медленная.
**Если получаешь ошибку "volume doesn't implement mount" / "том не поддерживает монтирование"** — это gvfs-специфичная ошибка, при том что сама SMB-шара доступна и работает. Обычно значит, что не хватает именно **SMB-бэкенда gvfs** (сам `gio` может быть установлен как чужая зависимость, а конкретно SMB-плагин к нему — нет). Проверить и починить:
```bash
# проверить руками, минуя nsm
gio mount smb://сервер/шара
**Требует Rust 1.85+ (edition 2024)** — на момент написания в Ubuntu/Debian это отдельные версионированные пакеты (`rustc-1.91`/`cargo-1.91` и т.п.), а не то, что стоит по умолчанию (там обычно 1.75). Если у тебя старый `rustc` — либо поставь такие версионированные пакеты через `apt`, если они есть в твоём дистрибутиве, либо используй `rustup`.
# на ALT/Fedora-подобных — убедиться, что бэкенд установлен
sudo apt-get install gvfs-backends # (или свой пакетный менеджер)
# проверить, что D-Bus сессия вообще есть (без неё gvfs не активируется)
echo $DBUS_SESSION_BUS_ADDRESS
```
Если это не помогло — причина может быть глубже, в согласовании версии протокола SMB между клиентом и сервером (см. `journalctl` на предмет строк вида `smbXcli_negprot` от `gvfsd`); в этом случае поможет `client min protocol` в `/etc/samba/smb.conf`, как описано выше.
**Честная оговорка по тестированию**: сама SMB-логика (подключение, рекурсивный листинг папок, чтение файлов с точным `seek`, декодирование аудио через SMB) проверена на реальном Samba-сервере и работает. Но в песочнице, где это разрабатывалось, не было настоящей звуковой карты — `cpal` (аудио-библиотека) там гарантированно падает при старте, и в этом конкретном состоянии (плюс активный поток аудио-движка) обнаружилось зависание при листинге SMB-папки. Причина не докопана до конца (что-то на стыке ALSA/cpal и tokio-реактора при **неудачной** попытке cpal открыть устройство) — и не исключено, что это специфично именно для отсутствия звуковой карты, а не проявится там, где `cpal` открывает поток нормально. Само SMB-подключение теперь работает в фоновом потоке, так что даже если это зависание всё-таки проявится — интерфейс не замёрзнет целиком, зависнет только конкретная попытка подключения. Если у тебя это тоже случится — дай знать, будем разбираться дальше уже на работающем звуке, что сильно сузит круг подозреваемых.
Для интернет-радио (**SomaFM**, **Radio Paradise** и т.д.) и MusicBrainz дополнительных системных зависимостей не нужно ни на одной ОС — TLS обеспечивает `rustls` (чистый Rust, без OpenSSL).
@ -245,9 +236,9 @@ crates/
- Минимальный размер терминала — 70×18; на меньшем показывается предупреждение вместо сломанного интерфейса
- **Собиралось и тестировалось только на Linux** — сборки под macOS/Windows не проверялись; SMB-фича на них принципиально не будет работать (см. выше)
- **Собиралось и тестировалось только на Linux** — сборки под macOS/Windows не проверялись (SMB теперь на чистом Rust и в теории кросс-платформенна, но не тестировалась вне Linux)
- **Opus не декодируется** (см. раздел «Форматы»)
- **Плейлисты не хранят диапазоны cue-треков** — добавление cue-трека в плейлист сохраняет файл целиком, а не конкретный виртуальный трек
- Подключение к SMB — блокирующий вызов в UI-потоке; интерфейс на секунду-другую подвиснет во время операции (поиск радиостанций и проверка новых релизов уже работают в фоне и не блокируют)
- Подключение к SMB, поиск радиостанций и проверка новых релизов — все работают в фоне и не блокируют интерфейс
- Мульти-файловые cue-листы (редкость) поддерживаются только по первому `FILE` в листе

View file

@ -14,6 +14,7 @@ nucleo-matcher = "0.3"
ureq = { version = "2", features = ["json"] }
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
textfix = { path = "../textfix" }
smbfs = { path = "../smbfs" }
[dev-dependencies]
tempfile = "3"

View file

@ -4,7 +4,7 @@ pub mod model;
pub mod new_releases;
pub mod playlist;
pub mod radio;
mod scanner;
pub mod scanner;
mod search;
pub mod settings;
pub mod smb;
@ -41,6 +41,22 @@ impl Library {
/// не удваивало треки.
pub fn add_root(&mut self, root: &Path) -> anyhow::Result<usize> {
let new_tracks = scanner::scan(root)?;
Ok(self.merge_tracks(new_tracks))
}
/// То же самое, что `add_root`, но для SMB-шары (нужно, чтобы `smbfs::connect` для этой
/// server+share уже был вызван ранее). Свой Rust SMB-клиент, без системных зависимостей
/// вроде gio/gvfs.
pub fn add_smb_root(&mut self, server: &str, share: &str, subpath: &str) -> anyhow::Result<usize> {
let new_tracks = scanner::scan_smb(server, share, subpath)?;
Ok(self.merge_tracks(new_tracks))
}
/// Добавляет треки, не дублируя уже имеющиеся (по паре путь+cue_start — актуально и для
/// обычных файлов, и для cue-треков, и для SMB-путей, поскольку все они хранятся в
/// одном и том же Track.path). Публичный — используется и когда сканирование SMB-шары
/// делается в фоновом потоке (сеть), а слияние с уже загруженной библиотекой — на UI-потоке.
pub fn merge_tracks(&mut self, new_tracks: Vec<Track>) -> usize {
let existing: std::collections::HashSet<(PathBuf, Option<std::time::Duration>)> = self
.tracks
.iter()
@ -54,7 +70,7 @@ impl Library {
added += 1;
}
}
Ok(added)
added
}
/// Полностью очищает библиотеку (не трогает файлы на диске — только текущий сеанс).

View file

@ -11,6 +11,27 @@ use crate::cue;
use crate::model::{is_audio_file, is_cue_file, Track};
use textfix::fix_mojibake;
/// Рекурсивно обходит SMB-шару (уже должно быть подключено через `smbfs::connect`) и строит
/// те же `Track`, что и `scan()` для локальной папки — просто источник файлов другой.
/// `subpath` — путь внутри шары, с которого начинать (пусто — весь корень шары).
pub fn scan_smb(server: &str, share: &str, subpath: &str) -> anyhow::Result<Vec<Track>> {
let files = smbfs::list_dir_recursive(server, share, subpath)?;
let mut tracks = Vec::new();
for rel_path in files {
let pseudo_path = smbfs::make_smb_path(server, share, &rel_path);
if !is_audio_file(&pseudo_path) {
continue;
}
let Ok(reader) = smbfs::open_read(server, share, &rel_path) else {
continue; // не удалось открыть конкретный файл — пропускаем, не валим весь скан
};
if let Ok(track) = read_track_smb(&pseudo_path, reader) {
tracks.push(track);
}
}
Ok(tracks)
}
/// Рекурсивно обходит `root` и все вложенные папки. Сначала обрабатывает `.cue`-листы —
/// каждый превращается в несколько "виртуальных" треков (диапазонов внутри одного
/// физического файла); файл, на который ссылается успешно распарсенный cue, после этого
@ -99,10 +120,23 @@ pub fn scan(root: &Path) -> anyhow::Result<Vec<Track>> {
Ok(tracks)
}
/// Читает теги одного файла в модель Track. Отсутствующие поля заполняются
/// Читает теги одного локального файла в модель Track. Отсутствующие поля заполняются
/// разумными значениями по умолчанию (имя файла как title, "Unknown Artist"/"Unknown Album").
pub fn read_track(path: &Path) -> anyhow::Result<Track> {
let tagged = Probe::open(path)?.read()?;
track_from_tagged(path, &tagged)
}
/// То же самое, но для файла на SMB-шаре — через уже открытый `smbfs::SmbFile` (Read+Seek),
/// а не через `std::fs::File`. `pseudo_path` — это `smb://server/share/...`, используется
/// только для fallback-имени и как итоговый Track.path (реальное чтение уже произошло через
/// переданный reader).
pub fn read_track_smb(pseudo_path: &Path, reader: smbfs::SmbFile) -> anyhow::Result<Track> {
let tagged = Probe::new(reader).guess_file_type()?.read()?;
track_from_tagged(pseudo_path, &tagged)
}
fn track_from_tagged(path: &Path, tagged: &lofty::file::TaggedFile) -> anyhow::Result<Track> {
let properties = tagged.properties();
let duration = Some(properties.duration()).filter(|d| *d != Duration::ZERO);

View file

@ -46,16 +46,27 @@ fn find_mount_path(base: &Path, server: &str, share: &str) -> anyhow::Result<Pat
/// Монтирует SMB-шару через `gio mount` (использует системный gvfs — тот же механизм,
/// которым пользуются Nautilus/Dolphin/GNOME Files). Требует установленного пакета gvfs
/// (обычно `gvfs-backends` или аналог) в системе.
/// Строит smb:// URI, подчищая пробелы и слэши по краям — gio строже, чем KIO (Dolphin),
/// разбирает такие URI, и завершающий слэш после имени шары иногда даёт "Invalid argument"
/// там, где Dolphin тот же адрес принимает без вопросов.
fn build_smb_url(server: &str, share: &str, username: Option<&str>) -> String {
let server = server.trim().trim_matches('/');
let share = share.trim().trim_matches('/');
match username {
Some(u) => format!("smb://{u}@{server}/{share}"),
None => format!("smb://{server}/{share}"),
}
}
pub fn mount_share(
server: &str,
share: &str,
username: Option<&str>,
password: Option<&str>,
) -> anyhow::Result<SmbShareInfo> {
let url = match username {
Some(u) => format!("smb://{u}@{server}/{share}"),
None => format!("smb://{server}/{share}"),
};
let server = server.trim().trim_matches('/');
let share = share.trim().trim_matches('/');
let url = build_smb_url(server, share, username);
let mut child = Command::new("gio")
.arg("mount")
@ -152,6 +163,32 @@ pub fn unmount_share(server: &str, share: &str) -> anyhow::Result<()> {
mod tests {
use super::*;
#[test]
fn strips_trailing_slash_from_share() {
// Именно этот случай вызывал "Invalid argument" у gio, хотя Dolphin(KIO) с тем же
// адресом работал нормально — gio строже разбирает завершающий слэш после шары.
assert_eq!(
build_smb_url("192.168.100.61", "media/", Some("guest")),
"smb://guest@192.168.100.61/media"
);
}
#[test]
fn strips_leading_and_trailing_whitespace_and_slashes() {
assert_eq!(
build_smb_url(" 192.168.100.61 ", "/media/", Some("guest")),
"smb://guest@192.168.100.61/media"
);
}
#[test]
fn anonymous_url_has_no_username_prefix() {
assert_eq!(
build_smb_url("192.168.100.61", "media", None),
"smb://192.168.100.61/media"
);
}
#[test]
fn finds_matching_mount_dir_by_server_and_share() {
let dir = tempfile::tempdir().unwrap();

View file

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

View file

@ -1,5 +1,5 @@
use std::fs::File;
use std::io::Read;
use std::io::{Read, Seek, SeekFrom};
use std::path::Path;
use std::sync::mpsc::Sender;
use std::time::Duration;
@ -7,7 +7,7 @@ use std::time::Duration;
use symphonia::core::audio::{SampleBuffer, SignalSpec};
use symphonia::core::codecs::{Decoder, DecoderOptions};
use symphonia::core::formats::{FormatOptions, FormatReader, SeekMode, SeekTo};
use symphonia::core::io::{MediaSourceStream, ReadOnlySource};
use symphonia::core::io::{MediaSource, MediaSourceStream, ReadOnlySource};
use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Hint;
use symphonia::core::units::Time;
@ -15,6 +15,46 @@ use symphonia::core::units::Time;
use crate::types::{PlayerEvent, TrackInfo};
use textfix::fix_mojibake;
/// Оборачивает `smbfs::SmbFile` (Read+Seek) в symphonia-совместимый `MediaSource` —
/// позволяет декодировать аудио прямо с SMB-шары точно так же, как с локального файла,
/// без единого системного вызова вроде gio/gvfs.
struct SmbMediaSource {
inner: smbfs::SmbFile,
}
impl Read for SmbMediaSource {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.inner.read(buf)
}
}
impl Seek for SmbMediaSource {
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
self.inner.seek(pos)
}
}
impl MediaSource for SmbMediaSource {
fn is_seekable(&self) -> bool {
true // SMB read всегда по смещению — "перемотка" ничего не стоит
}
fn byte_len(&self) -> Option<u64> {
Some(self.inner.len())
}
}
/// Строит symphonia-источник (Box<dyn MediaSource>) для локального файла или SMB-пути —
/// единая точка входа, дальше `open_track`/`read_tags` работают одинаково для обоих случаев.
fn open_media_source(path: &Path) -> anyhow::Result<Box<dyn MediaSource>> {
if let Some(loc) = smbfs::parse_smb_path(path) {
let smb_file = smbfs::open_read(&loc.server, &loc.share, &loc.path)?;
Ok(Box::new(SmbMediaSource { inner: smb_file }))
} else {
Ok(Box::new(File::open(path)?))
}
}
pub struct DecodedTrack {
pub format: Box<dyn FormatReader>,
pub decoder: Box<dyn Decoder>,
@ -68,8 +108,8 @@ fn build_decoded(mss: MediaSourceStream, hint: Hint) -> anyhow::Result<DecodedTr
}
pub fn open_track(path: &Path) -> anyhow::Result<DecodedTrack> {
let file = File::open(path)?;
let mss = MediaSourceStream::new(Box::new(file), Default::default());
let source = open_media_source(path)?;
let mss = MediaSourceStream::new(source, Default::default());
let mut hint = Hint::new();
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
@ -255,8 +295,8 @@ pub fn next_packet_samples(track: &mut DecodedTrack) -> anyhow::Result<Option<Ve
pub fn read_tags(path: &Path) -> anyhow::Result<TrackInfo> {
use symphonia::core::meta::StandardTagKey;
let file = File::open(path)?;
let mss = MediaSourceStream::new(Box::new(file), Default::default());
let source = open_media_source(path)?;
let mss = MediaSourceStream::new(source, Default::default());
let mut hint = Hint::new();
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
hint.with_extension(ext);
@ -347,6 +387,34 @@ mod tests {
assert_eq!(parse_stream_title(meta), None);
}
/// Проверяет, что `open_track`/`next_packet_samples` реально декодируют аудио, читая его
/// байты со настоящей SMB-шары (через smbfs — чистый Rust клиент, без gio/gvfs), а не
/// только что теги читаются. Нужен локальный Samba-сервер с гостевой шарой `test`,
/// содержащей `Artist/Album/01.mp3` — поднимается вручную перед прогоном. Запускать
/// явно: `cargo test -- --ignored`.
#[test]
#[ignore]
fn open_track_decodes_real_audio_over_smb() {
smbfs::connect("127.0.0.1", "test", None, None)
.expect("connect failed — is the local samba test server running?");
let smb_path = smbfs::make_smb_path("127.0.0.1", "test", "Artist/Album/01.mp3");
let mut track = open_track(&smb_path).expect("open_track over SMB failed");
let mut total_samples = 0usize;
let mut packets = 0usize;
while let Some(samples) = next_packet_samples(&mut track).expect("decode failed") {
total_samples += samples.len();
packets += 1;
if packets >= 5 {
break; // достаточно для проверки — не декодируем весь файл
}
}
assert!(packets > 0, "should have decoded at least one packet");
assert!(total_samples > 0, "should have produced actual audio samples");
println!("decoded {packets} packets, {total_samples} samples total, over SMB");
}
/// Регрессионный тест на реальный баг: кракозябры чинились в библиотеке (library::scanner),
/// но не в TrackInfo, который показывается в "Now Playing" при реальном воспроизведении —
/// потому что read_tags жила отдельно и не применяла fix_mojibake. Воспроизводит точный

11
crates/smbfs/Cargo.toml Normal file
View file

@ -0,0 +1,11 @@
[package]
name = "smbfs"
version.workspace = true
edition = "2021"
[dependencies]
smb = "0.11"
smb-fscc = "0.11"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync"] }
futures-util = "0.3"
anyhow = "1"

319
crates/smbfs/src/lib.rs Normal file
View file

@ -0,0 +1,319 @@
//! Чистый Rust SMB-клиент (без системных зависимостей вроде gio/gvfs) — используется и
//! `library` (для сканирования шары), и `player` (для чтения аудио при воспроизведении).
//! Общается с сетью через крейт `smb` (async, поверх tokio), но наружу отдаёт синхронный
//! blocking-friendly интерфейс, поскольку остальной nsm — синхронный/поточный.
//!
//! Пути к файлам на SMB представляются как "псевдо-пути" вида `smb://server/share/path` —
//! это позволяет переиспользовать Track.path/QueueItem.path (обычный PathBuf) везде в
//! проекте без изменения модели данных: `player::decode` и `library::scanner` сами проверяют,
//! является ли путь таким псевдо-путём, и если да — работают через этот модуль вместо
//! `std::fs::File`.
use std::collections::HashMap;
use std::io::{self, Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
use futures_util::StreamExt;
use smb::resource::{Directory, FileCreateArgs};
use smb::{Client, ClientConfig, UncPath};
use smb_fscc::{FileAccessMask, FileIdBothDirectoryInformation, FileStandardInformation};
use tokio::runtime::Runtime;
const SCHEME: &str = "smb://";
/// Расположение файла/папки на SMB-шаре: сервер, имя шары, относительный путь внутри неё.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct SmbLocation {
pub server: String,
pub share: String,
/// Путь внутри шары, без ведущего слэша. Пусто для корня шары.
pub path: String,
}
/// true, если это наш "псевдо-путь" вида smb://server/share/path.
pub fn is_smb_path(path: &Path) -> bool {
path.to_str().map(|s| s.starts_with(SCHEME)).unwrap_or(false)
}
pub fn make_smb_path(server: &str, share: &str, relative: &str) -> PathBuf {
let relative = relative.trim_start_matches('/');
if relative.is_empty() {
PathBuf::from(format!("{SCHEME}{server}/{share}"))
} else {
PathBuf::from(format!("{SCHEME}{server}/{share}/{relative}"))
}
}
pub fn parse_smb_path(path: &Path) -> Option<SmbLocation> {
let s = path.to_str()?;
let rest = s.strip_prefix(SCHEME)?;
let mut parts = rest.splitn(3, '/');
let server = parts.next()?.to_string();
let share = parts.next()?.to_string();
let path = parts.next().unwrap_or("").to_string();
if server.is_empty() || share.is_empty() {
return None;
}
Some(SmbLocation { server, share, path })
}
// ---- Общий tokio runtime для всех blocking-вызовов ----
fn runtime() -> &'static Runtime {
static RT: OnceLock<Runtime> = OnceLock::new();
RT.get_or_init(|| {
tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.expect("failed to start tokio runtime for smbfs")
})
}
// ---- Реестр подключений: (server, share) -> уже аутентифицированный клиент ----
struct Connection {
client: Client,
share_root: UncPath,
}
fn registry() -> &'static Mutex<HashMap<(String, String), Arc<Connection>>> {
static REG: OnceLock<Mutex<HashMap<(String, String), Arc<Connection>>>> = OnceLock::new();
REG.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Подключается к SMB-шаре (или, если уже подключены к этой же server+share — переиспользует
/// существующее соединение) и регистрирует её в реестре. После этого `list_dir_recursive`/
/// `open_read` для этой server+share будут работать без повторного логина.
pub fn connect(
server: &str,
share: &str,
username: Option<&str>,
password: Option<&str>,
) -> anyhow::Result<()> {
let key = (server.to_string(), share.to_string());
if registry().lock().unwrap().contains_key(&key) {
return Ok(()); // уже подключены
}
// Анонимный вход — явно логин "guest", как и в gio-варианте: часть серверов не
// принимает буквально пустой логин, ожидая общепринятое имя гостевого пользователя.
let user = username.unwrap_or("guest").to_string();
let pass = password.unwrap_or("").to_string();
let server_owned = server.to_string();
let share_owned = share.to_string();
let connection = runtime().block_on(async move {
let mut config = ClientConfig::default();
// Без этого флага гостевые/анонимные сессии падают с "signing is required" — сервер
// требует подписи сообщений, а анонимной сессии нечем их подписать.
config.connection.allow_unsigned_guest_access = true;
let client = Client::new(config);
let share_root = UncPath::new(&server_owned)?.with_share(&share_owned)?;
client.share_connect(&share_root, &user, pass).await?;
anyhow::Ok(Connection { client, share_root })
})?;
registry().lock().unwrap().insert(key, Arc::new(connection));
Ok(())
}
pub fn is_connected(server: &str, share: &str) -> bool {
registry()
.lock()
.unwrap()
.contains_key(&(server.to_string(), share.to_string()))
}
fn get_connection(server: &str, share: &str) -> anyhow::Result<Arc<Connection>> {
registry()
.lock()
.unwrap()
.get(&(server.to_string(), share.to_string()))
.cloned()
.ok_or_else(|| {
anyhow::anyhow!("not connected to smb://{server}/{share} — call connect() first")
})
}
/// Рекурсивно обходит шару (или подпапку внутри неё) и возвращает относительные пути всех
/// найденных файлов (не папок).
pub fn list_dir_recursive(server: &str, share: &str, subpath: &str) -> anyhow::Result<Vec<String>> {
let conn = get_connection(server, share)?;
runtime().block_on(list_recursive_async(conn, subpath.to_string()))
}
fn list_recursive_async(
conn: Arc<Connection>,
subpath: String,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<Vec<String>>> + Send>> {
Box::pin(async move {
let dir_path = if subpath.is_empty() {
conn.share_root.clone()
} else {
conn.share_root.clone().with_path(&subpath)
};
let args =
FileCreateArgs::make_open_existing(FileAccessMask::new().with_generic_read(true));
let resource = conn.client.create_file(&dir_path, &args).await?;
let dir = Arc::new(resource.unwrap_dir());
let mut stream = Directory::query::<FileIdBothDirectoryInformation>(&dir, "*").await?;
let mut results = Vec::new();
let mut subdirs = Vec::new();
while let Some(entry) = stream.next().await {
let entry = entry?;
let name = entry.file_name.to_string();
if name == "." || name == ".." {
continue;
}
let rel = if subpath.is_empty() {
name.clone()
} else {
format!("{subpath}/{name}")
};
if entry.file_attributes.directory() {
subdirs.push(rel);
} else {
results.push(rel);
}
}
drop(stream);
for sub in subdirs {
let mut nested = list_recursive_async(conn.clone(), sub).await?;
results.append(&mut nested);
}
Ok(results)
})
}
/// Открывает файл на шаре для чтения. Требует, чтобы `connect()` для этой server+share уже
/// был вызван ранее.
pub fn open_read(server: &str, share: &str, subpath: &str) -> anyhow::Result<SmbFile> {
let conn = get_connection(server, share)?;
let subpath = subpath.to_string();
let (file, size) = runtime().block_on(async move {
let file_path = conn.share_root.clone().with_path(&subpath);
let args =
FileCreateArgs::make_open_existing(FileAccessMask::new().with_generic_read(true));
let resource = conn.client.create_file(&file_path, &args).await?;
let file = resource.unwrap_file();
let info: FileStandardInformation = file.query_info().await?;
anyhow::Ok((file, info.end_of_file))
})?;
Ok(SmbFile { file: Some(file), pos: 0, size })
}
/// Read+Seek обёртка над файлом на SMB-шаре — читается блоками через async API крейта `smb`,
/// но "перемотка" не требует реального сетевого запроса: SMB read всегда идёт по смещению,
/// так что seek — это просто обновление счётчика позиции на нашей стороне.
pub struct SmbFile {
file: Option<smb::resource::File>,
pos: u64,
size: u64,
}
impl SmbFile {
/// Полный размер файла в байтах — известен сразу после открытия (запрошен один раз
/// при `open_read`, не требует отдельного сетевого похода).
pub fn len(&self) -> u64 {
self.size
}
pub fn is_empty(&self) -> bool {
self.size == 0
}
}
impl Read for SmbFile {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let file = self
.file
.as_ref()
.ok_or_else(|| io::Error::new(io::ErrorKind::Other, "smb file already closed"))?;
let n = runtime()
.block_on(file.read_block(buf, self.pos, None, false))
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
self.pos += n as u64;
Ok(n)
}
}
impl Drop for SmbFile {
fn drop(&mut self) {
// У ResourceHandle крейта `smb` свой Drop, который асинхронно спавнит задачу
// закрытия хендла на сервере (tokio::spawn) — а это паникует с "no reactor running",
// если в текущем потоке в момент дропа нет активного tokio-контекста (у нас его нет,
// мы работаем через block_on только на время конкретных вызовов). Явно входим в
// контекст рантайма перед тем, как дать File реально задропаться.
if let Some(file) = self.file.take() {
let _guard = runtime().enter();
drop(file);
}
}
}
impl Seek for SmbFile {
fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {
let new_pos = match pos {
SeekFrom::Start(p) => p as i64,
SeekFrom::End(p) => self.size as i64 + p,
SeekFrom::Current(p) => self.pos as i64 + p,
};
if new_pos < 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"seek to negative position",
));
}
self.pos = new_pos as u64;
Ok(self.pos)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_smb_pseudo_path() {
let loc = parse_smb_path(Path::new("smb://192.168.1.1/media/Artist/song.mp3")).unwrap();
assert_eq!(loc.server, "192.168.1.1");
assert_eq!(loc.share, "media");
assert_eq!(loc.path, "Artist/song.mp3");
}
#[test]
fn parses_smb_path_at_share_root() {
let loc = parse_smb_path(Path::new("smb://server/share")).unwrap();
assert_eq!(loc.path, "");
}
#[test]
fn rejects_non_smb_paths() {
assert!(parse_smb_path(Path::new("/home/user/music/song.mp3")).is_none());
}
#[test]
fn round_trips_make_and_parse() {
let p = make_smb_path("192.168.1.1", "media", "Artist/Album/song.mp3");
assert_eq!(p.to_str().unwrap(), "smb://192.168.1.1/media/Artist/Album/song.mp3");
let loc = parse_smb_path(&p).unwrap();
assert_eq!(loc.server, "192.168.1.1");
assert_eq!(loc.share, "media");
assert_eq!(loc.path, "Artist/Album/song.mp3");
}
#[test]
fn detects_smb_paths_correctly() {
assert!(is_smb_path(Path::new("smb://server/share/file.mp3")));
assert!(!is_smb_path(Path::new("/local/path/file.mp3")));
}
}

View file

@ -7,6 +7,7 @@ edition.workspace = true
anyhow.workspace = true
player = { path = "../player" }
library = { path = "../library" }
smbfs = { path = "../smbfs" }
ratatui = "0.28"
crossterm = "0.28"
ratatui-image = { version = "1", default-features = false, features = ["crossterm", "rustix"] }

View file

@ -161,6 +161,20 @@ impl RightPanelView {
}
}
/// Обновляет экспоненциальное скользящее среднее басовой энергии на один новый отсчёт.
/// `alpha` — насколько быстро среднее "забывает" старые значения (0..1; больше = быстрее).
pub fn update_bass_ema(ema: f32, current: f32, alpha: f32) -> f32 {
ema * (1.0 - alpha) + current * alpha
}
/// "Удар баса" — текущее значение заметно выше недавнего среднего, а не просто выше
/// произвольной фиксированной константы. Так реакция подстраивается под конкретный трек:
/// и тихая акустика, и громкий метал будут давать срабатывания на СВОИХ собственных ударах,
/// а не либо никогда, либо постоянно.
pub fn is_bass_hit(current: f32, ema: f32) -> bool {
current > ema * 1.3 + 0.02
}
pub fn protocol_to_str(p: ProtocolType) -> &'static str {
match p {
ProtocolType::Halfblocks => "halfblocks",
@ -232,6 +246,10 @@ pub struct App {
pub station_query: String,
pub station_search_in_progress: bool,
station_search_rx: Option<std::sync::mpsc::Receiver<anyhow::Result<Vec<Station>>>>,
/// Фоновое подключение+сканирование SMB — сеть может быть медленной (или зависнуть),
/// не должна морозить весь интерфейс. Результат: (server, share, треки или ошибка).
smb_connect_rx: Option<std::sync::mpsc::Receiver<(String, String, anyhow::Result<Vec<library::Track>>)>>,
pub smb_connecting: bool,
// --- Now playing / transport ---
pub now_playing: Option<NowPlaying>,
@ -268,6 +286,11 @@ pub struct App {
pub right_panel_view: RightPanelView,
/// Текущие значения полос спектра (0..1), обновляются событиями от плеера.
pub spectrum: Vec<f32>,
/// Скользящее среднее басовой энергии — для адаптивного "детектора удара баса"
/// (Noise Marines реагируют на ВСПЛЕСК относительно недавнего среднего, а не на
/// фиксированный порог, который у одних треков никогда не пробивается, а у других
/// пробит всегда).
pub bass_ema: f32,
/// Если true — выбранный right_panel_view занимает весь экран.
pub fullscreen: bool,
@ -356,6 +379,8 @@ impl App {
station_query: String::new(),
station_search_in_progress: false,
station_search_rx: None,
smb_connect_rx: None,
smb_connecting: false,
now_playing: None,
speed: Speed::default(),
shuffle: false,
@ -378,6 +403,7 @@ impl App {
last_cover_bytes: None,
right_panel_view: RightPanelView::Cover,
spectrum: vec![0.0; player::SPECTRUM_BANDS],
bass_ema: 0.0,
fullscreen: false,
status_message: format!("nsm — loaded from {}", root.display()),
};
@ -455,6 +481,9 @@ impl App {
/// "guest" — gvfs почти никогда не подключается по-настоящему без логина вообще,
/// даже если шара разрешает гостевой доступ (`guest ok = yes` в конфиге Samba),
/// а "guest" — общепринятое соглашение, которое такие шары понимают.
/// Запускает подключение+сканирование SMB-шары в фоновом потоке — сеть (или сам сервер)
/// может быть медленной или зависнуть, это не должно морозить весь интерфейс (как раньше,
/// когда это было синхронным вызовом прямо в обработчике клавиши).
pub fn connect_smb_share(&mut self) {
let server = self.smb_server.trim().to_string();
let share = self.smb_share.trim().to_string();
@ -462,7 +491,7 @@ impl App {
let password = (!self.smb_password.is_empty()).then(|| self.smb_password.clone());
let (username, password, anonymous) = if username_input.is_empty() {
(Some("guest".to_string()), None, true)
(None, None, true) // smbfs::connect сама подставляет "guest" для анонимного входа
} else {
(Some(username_input.to_string()), password, false)
};
@ -478,30 +507,47 @@ impl App {
} else {
format!("Connecting to smb://{server}/{share}")
};
self.smb_connecting = true;
self.clear_smb_inputs();
let mount_path = if let Some(path) = library::smb::find_existing_mount(&server, &share) {
Ok(path)
} else {
library::smb::mount_share(&server, &share, username.as_deref(), password.as_deref())
.map(|info| info.mount_path)
let (tx, rx) = std::sync::mpsc::channel();
self.smb_connect_rx = Some(rx);
std::thread::spawn(move || {
// Свой Rust SMB-клиент (smbfs) — не зависит от системного gio/gvfs и его багов.
// connect() сама переиспользует уже открытое соединение, если оно уже было для
// этой server+share (не переспрашивает пароль каждый раз).
let result = smbfs::connect(&server, &share, username.as_deref(), password.as_deref())
.and_then(|()| library::scanner::scan_smb(&server, &share, ""));
let _ = tx.send((server, share, result));
});
}
/// Опрашивает фоновое подключение SMB — вызывать каждый тик, как и остальные фоновые задачи.
pub fn poll_smb_connect(&mut self) {
let Some(rx) = self.smb_connect_rx.as_ref() else {
return;
};
match mount_path {
Ok(path) => match self.library.add_root(&path) {
Ok(added) => {
self.status_message =
format!("Connected to \\\\{server}\\{share} — added {added} track(s)");
self.rebuild_library_view();
}
Err(e) => {
self.status_message = format!("Mounted, but scanning failed: {e}");
}
},
Err(e) => {
match rx.try_recv() {
Ok((server, share, Ok(tracks))) => {
let added = self.library.merge_tracks(tracks);
self.status_message = format!("Connected to \\\\{server}\\{share} — added {added} track(s)");
self.rebuild_library_view();
self.smb_connecting = false;
self.smb_connect_rx = None;
}
Ok((_, _, Err(e))) => {
self.status_message = format!("SMB connect failed: {e}");
self.smb_connecting = false;
self.smb_connect_rx = None;
}
Err(std::sync::mpsc::TryRecvError::Empty) => {
// ещё не готово — подождём следующего тика
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
self.smb_connecting = false;
self.smb_connect_rx = None;
}
}
self.clear_smb_inputs();
}
fn clear_smb_inputs(&mut self) {
@ -594,6 +640,14 @@ impl App {
}
}
Spectrum(bands) => {
// Скользящее среднее по нескольким нижним полосам — та же "басовая энергия",
// что использует детектор удара в draw_noise_marines (см. там комментарий,
// почему усредняем несколько полос, а не берём одну).
let n = bands.len().min(7);
if n > 0 {
let bass_now = bands[..n].iter().sum::<f32>() / n as f32;
self.bass_ema = update_bass_ema(self.bass_ema, bass_now, 0.15);
}
self.spectrum = bands;
}
QueueFinished => {
@ -1122,3 +1176,44 @@ impl App {
self.fullscreen = !self.fullscreen;
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Симулирует несколько "тихих" отсчётов (устанавливая ema), затем один резкий
/// всплеск — детектор должен среагировать на сам всплеск.
#[test]
fn detects_sudden_bass_hit_over_quiet_baseline() {
let mut ema = 0.0;
for _ in 0..20 {
ema = update_bass_ema(ema, 0.05, 0.15); // тихая "гитара" — база устаканивается низко
}
assert!(!is_bass_hit(0.05, ema), "ровный тихий уровень не должен считаться ударом");
// резкий удар баса поверх этой тихой базы
assert!(is_bass_hit(0.4, ema), "резкий всплеск над тихой базой должен сработать");
}
/// Трек, где бас стабильно громкий (тяжёлый метал) — после того, как ema устаканится
/// на этом уровне, ровный громкий бас НЕ должен постоянно триггерить "удар" на каждом
/// кадре — иначе десантники будут просто дёргаться без остановки, а не танцевать в такт.
#[test]
fn steady_loud_bass_does_not_trigger_constantly_once_settled() {
let mut ema = 0.0;
for _ in 0..50 {
ema = update_bass_ema(ema, 0.5, 0.15);
}
assert!(
!is_bass_hit(0.5, ema),
"устаканившийся ровный громкий бас не должен считаться 'ударом' каждый кадр"
);
}
#[test]
fn ema_moves_toward_target_but_not_instantly() {
let ema = update_bass_ema(0.0, 1.0, 0.15);
assert!(ema > 0.0 && ema < 1.0, "ema={ema}");
assert!((ema - 0.15).abs() < 0.001);
}
}

View file

@ -80,6 +80,7 @@ fn event_loop<B: ratatui::backend::Backend>(
app.drain_player_events();
app.poll_new_releases();
app.poll_station_search();
app.poll_smb_connect();
last_tick = Instant::now();
}

View file

@ -543,7 +543,8 @@ fn draw_now_playing(frame: &mut Frame, app: &mut App, area: Rect) {
.constraints([
Constraint::Length(12), // обложка + инфо
Constraint::Length(1), // транспорт (speed/shuffle/repeat)
Constraint::Min(0), // эквалайзер
Constraint::Percentage(50), // эквалайзер
Constraint::Percentage(50), // танцующие Шумовые Десантники
])
.split(area);
@ -580,6 +581,7 @@ fn draw_now_playing(frame: &mut Frame, app: &mut App, area: Rect) {
draw_track_info(frame, app, top[1]);
draw_transport(frame, app, chunks[1]);
draw_eq(frame, app, chunks[2]);
draw_noise_marines(frame, app, chunks[3]);
}
fn draw_track_info(frame: &mut Frame, app: &App, area: Rect) {
@ -643,6 +645,82 @@ fn draw_transport(frame: &mut Frame, app: &App, area: Rect) {
frame.render_widget(Paragraph::new(line), area);
}
/// Пасхалка: Шумовые Десантники (Noise Marines) из Warhammer 40000 — культовая "музыкальная"
/// фракция Хаоса, поклоняющаяся Слаанешу через звук и звуковое оружие (Doom Siren/Blastmaster).
/// Поза синхронизирована с басом ТЕКУЩЕГО трека через уже существующий спектроанализатор —
/// буквально пляшут под музыку, а не просто крутят произвольную анимацию.
fn draw_noise_marines(frame: &mut Frame, app: &App, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.title(" Noise Marines ");
let inner = block.inner(area);
frame.render_widget(block, area);
// Басовая энергия усредняется по нескольким нижним полосам спектра, а не берётся из
// одной-единственной (полоса 0 при размере FFT-окна 1024 — это всего один узкий бин
// ~43Гц, слишком шумно и не отражает реальный бас трека). "Удар баса" детектируется
// относительно недавнего среднего (bass_ema), а не фиксированным порогом — так реакция
// подстраивается под конкретный трек, а не пробивается либо всегда, либо никогда.
let n = app.spectrum.len().min(7);
let bass_now = if n > 0 {
app.spectrum[..n].iter().sum::<f32>() / n as f32
} else {
0.0
};
let pose_a = crate::app::is_bass_hit(bass_now, app.bass_ema);
// Тело и рог/сирена — в одной строке за раз, как в изначальной, подтверждённо рабочей
// версии (была проверена вживую на реальном терминале). Отдельные "гитарные" аксессуары
// с другими символами не прижились — рассинхронизировали ширину колонок в разных
// шрифтах/терминалах, поэтому вернулись к простому и надёжному.
const FRAME_A: [&str; 9] = [
" ▄██▄ ",
" ◢█◉◉█◣ ",
" █▀▀▀▀█ ",
"▟████████▙ ╭─◯ ))) ",
" █▓▓▓▓█ ",
" █▓▓▓▓█ ",
" ▐▓▓▓▓▌ ",
" █ █ ",
" ▟█▙▟█▙ ",
];
const FRAME_B: [&str; 9] = [
" ▄██▄ ",
" ◢█◉◉█◣ ",
" █▀▀▀▀█ ",
"▟████████▙ ",
" █▓▓▓▓█ ╲ ",
" █▓▓▓▓█ ╲ ",
" ▐▓▓▓▓▌╭─◯ ))) ",
" ▟█▙ █ ",
" █ ▟█▙ ",
];
let sprite = if pose_a { &FRAME_A } else { &FRAME_B };
const MARINE_WIDTH: usize = 24;
let count = ((inner.width as usize) / MARINE_WIDTH).clamp(1, 4);
let lines: Vec<Line> = sprite
.iter()
.map(|row| {
let mut s = String::new();
for _ in 0..count {
let visible_len = row.chars().count();
s.push_str(row);
s.push_str(&" ".repeat(MARINE_WIDTH.saturating_sub(visible_len)));
}
Line::from(s)
})
.collect();
frame.render_widget(
Paragraph::new(lines)
.style(Style::default().fg(Color::Magenta))
.alignment(Alignment::Center),
inner,
);
}
fn draw_eq(frame: &mut Frame, app: &App, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)