New smb
This commit is contained in:
parent
359cd63834
commit
c2ac57d727
17 changed files with 2790 additions and 80 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,2 +1 @@
|
||||||
/target
|
/target
|
||||||
Cargo.lock
|
|
||||||
|
|
|
||||||
2105
Cargo.lock
generated
2105
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -6,6 +6,7 @@ members = [
|
||||||
"crates/tui",
|
"crates/tui",
|
||||||
"crates/nsm",
|
"crates/nsm",
|
||||||
"crates/textfix",
|
"crates/textfix",
|
||||||
|
"crates/smbfs",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|
|
||||||
12
README.en.md
12
README.en.md
|
|
@ -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
|
- **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
|
- **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)
|
- **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
|
- **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"
|
- **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
|
- **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.
|
**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).
|
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
|
## 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)
|
- **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
|
- **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
|
- 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
|
- Multi-file cue sheets (a rare case) are only supported using the first `FILE` entry in the sheet
|
||||||
|
|
|
||||||
21
README.md
21
README.md
|
|
@ -16,7 +16,7 @@
|
||||||
- **Затухание в конце трека**: последние 4 секунды перед концом плавно уводятся в тишину — включается клавишей `F`, по умолчанию выключено
|
- **Затухание в конце трека**: последние 4 секунды перед концом плавно уводятся в тишину — включается клавишей `F`, по умолчанию выключено
|
||||||
- **Выравнивание громкости**: тег ReplayGain в приоритете (если есть в файле), иначе — адаптивный левеллер (AGC), подстраивающий громкость на лету — клавиша `G`, по умолчанию выключено
|
- **Выравнивание громкости**: тег ReplayGain в приоритете (если есть в файле), иначе — адаптивный левеллер (AGC), подстраивающий громкость на лету — клавиша `G`, по умолчанию выключено
|
||||||
- **Интернет-радио**: свои станции по URL, встроенный список (SomaFM, Radio Paradise), поиск станций через открытый [Radio Browser API](https://api.radio-browser.info) (с автопереключением между несколькими зеркалами, если одно недоступно), ICY-метаданные ("сейчас играет" прямо из потока)
|
- **Интернет-радио**: свои станции по 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) — не глобальный чарт, а именно "что вышло нового у тех, кого ты слушаешь"
|
- **Новые релизы**: проверка новых альбомов/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.
|
**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-плагин к нему — нет). Проверить и починить:
|
**Требует Rust 1.85+ (edition 2024)** — на момент написания в Ubuntu/Debian это отдельные версионированные пакеты (`rustc-1.91`/`cargo-1.91` и т.п.), а не то, что стоит по умолчанию (там обычно 1.75). Если у тебя старый `rustc` — либо поставь такие версионированные пакеты через `apt`, если они есть в твоём дистрибутиве, либо используй `rustup`.
|
||||||
```bash
|
|
||||||
# проверить руками, минуя nsm
|
|
||||||
gio mount smb://сервер/шара
|
|
||||||
|
|
||||||
# на ALT/Fedora-подобных — убедиться, что бэкенд установлен
|
**Честная оговорка по тестированию**: сама SMB-логика (подключение, рекурсивный листинг папок, чтение файлов с точным `seek`, декодирование аудио через SMB) проверена на реальном Samba-сервере и работает. Но в песочнице, где это разрабатывалось, не было настоящей звуковой карты — `cpal` (аудио-библиотека) там гарантированно падает при старте, и в этом конкретном состоянии (плюс активный поток аудио-движка) обнаружилось зависание при листинге SMB-папки. Причина не докопана до конца (что-то на стыке ALSA/cpal и tokio-реактора при **неудачной** попытке cpal открыть устройство) — и не исключено, что это специфично именно для отсутствия звуковой карты, а не проявится там, где `cpal` открывает поток нормально. Само SMB-подключение теперь работает в фоновом потоке, так что даже если это зависание всё-таки проявится — интерфейс не замёрзнет целиком, зависнет только конкретная попытка подключения. Если у тебя это тоже случится — дай знать, будем разбираться дальше уже на работающем звуке, что сильно сузит круг подозреваемых.
|
||||||
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`, как описано выше.
|
|
||||||
|
|
||||||
Для интернет-радио (**SomaFM**, **Radio Paradise** и т.д.) и MusicBrainz дополнительных системных зависимостей не нужно ни на одной ОС — TLS обеспечивает `rustls` (чистый Rust, без OpenSSL).
|
Для интернет-радио (**SomaFM**, **Radio Paradise** и т.д.) и MusicBrainz дополнительных системных зависимостей не нужно ни на одной ОС — TLS обеспечивает `rustls` (чистый Rust, без OpenSSL).
|
||||||
|
|
||||||
|
|
@ -245,9 +236,9 @@ crates/
|
||||||
|
|
||||||
- Минимальный размер терминала — 70×18; на меньшем показывается предупреждение вместо сломанного интерфейса
|
- Минимальный размер терминала — 70×18; на меньшем показывается предупреждение вместо сломанного интерфейса
|
||||||
|
|
||||||
- **Собиралось и тестировалось только на Linux** — сборки под macOS/Windows не проверялись; SMB-фича на них принципиально не будет работать (см. выше)
|
- **Собиралось и тестировалось только на Linux** — сборки под macOS/Windows не проверялись (SMB теперь на чистом Rust и в теории кросс-платформенна, но не тестировалась вне Linux)
|
||||||
|
|
||||||
- **Opus не декодируется** (см. раздел «Форматы»)
|
- **Opus не декодируется** (см. раздел «Форматы»)
|
||||||
- **Плейлисты не хранят диапазоны cue-треков** — добавление cue-трека в плейлист сохраняет файл целиком, а не конкретный виртуальный трек
|
- **Плейлисты не хранят диапазоны cue-треков** — добавление cue-трека в плейлист сохраняет файл целиком, а не конкретный виртуальный трек
|
||||||
- Подключение к SMB — блокирующий вызов в UI-потоке; интерфейс на секунду-другую подвиснет во время операции (поиск радиостанций и проверка новых релизов уже работают в фоне и не блокируют)
|
- Подключение к SMB, поиск радиостанций и проверка новых релизов — все работают в фоне и не блокируют интерфейс
|
||||||
- Мульти-файловые cue-листы (редкость) поддерживаются только по первому `FILE` в листе
|
- Мульти-файловые cue-листы (редкость) поддерживаются только по первому `FILE` в листе
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ nucleo-matcher = "0.3"
|
||||||
ureq = { version = "2", features = ["json"] }
|
ureq = { version = "2", features = ["json"] }
|
||||||
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
|
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
|
||||||
textfix = { path = "../textfix" }
|
textfix = { path = "../textfix" }
|
||||||
|
smbfs = { path = "../smbfs" }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ pub mod model;
|
||||||
pub mod new_releases;
|
pub mod new_releases;
|
||||||
pub mod playlist;
|
pub mod playlist;
|
||||||
pub mod radio;
|
pub mod radio;
|
||||||
mod scanner;
|
pub mod scanner;
|
||||||
mod search;
|
mod search;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod smb;
|
pub mod smb;
|
||||||
|
|
@ -41,6 +41,22 @@ impl Library {
|
||||||
/// не удваивало треки.
|
/// не удваивало треки.
|
||||||
pub fn add_root(&mut self, root: &Path) -> anyhow::Result<usize> {
|
pub fn add_root(&mut self, root: &Path) -> anyhow::Result<usize> {
|
||||||
let new_tracks = scanner::scan(root)?;
|
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
|
let existing: std::collections::HashSet<(PathBuf, Option<std::time::Duration>)> = self
|
||||||
.tracks
|
.tracks
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -54,7 +70,7 @@ impl Library {
|
||||||
added += 1;
|
added += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(added)
|
added
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Полностью очищает библиотеку (не трогает файлы на диске — только текущий сеанс).
|
/// Полностью очищает библиотеку (не трогает файлы на диске — только текущий сеанс).
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,27 @@ use crate::cue;
|
||||||
use crate::model::{is_audio_file, is_cue_file, Track};
|
use crate::model::{is_audio_file, is_cue_file, Track};
|
||||||
use textfix::fix_mojibake;
|
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`-листы —
|
/// Рекурсивно обходит `root` и все вложенные папки. Сначала обрабатывает `.cue`-листы —
|
||||||
/// каждый превращается в несколько "виртуальных" треков (диапазонов внутри одного
|
/// каждый превращается в несколько "виртуальных" треков (диапазонов внутри одного
|
||||||
/// физического файла); файл, на который ссылается успешно распарсенный cue, после этого
|
/// физического файла); файл, на который ссылается успешно распарсенный cue, после этого
|
||||||
|
|
@ -99,10 +120,23 @@ pub fn scan(root: &Path) -> anyhow::Result<Vec<Track>> {
|
||||||
Ok(tracks)
|
Ok(tracks)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Читает теги одного файла в модель Track. Отсутствующие поля заполняются
|
/// Читает теги одного локального файла в модель Track. Отсутствующие поля заполняются
|
||||||
/// разумными значениями по умолчанию (имя файла как title, "Unknown Artist"/"Unknown Album").
|
/// разумными значениями по умолчанию (имя файла как title, "Unknown Artist"/"Unknown Album").
|
||||||
pub fn read_track(path: &Path) -> anyhow::Result<Track> {
|
pub fn read_track(path: &Path) -> anyhow::Result<Track> {
|
||||||
let tagged = Probe::open(path)?.read()?;
|
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 properties = tagged.properties();
|
||||||
let duration = Some(properties.duration()).filter(|d| *d != Duration::ZERO);
|
let duration = Some(properties.duration()).filter(|d| *d != Duration::ZERO);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,16 +46,27 @@ fn find_mount_path(base: &Path, server: &str, share: &str) -> anyhow::Result<Pat
|
||||||
/// Монтирует SMB-шару через `gio mount` (использует системный gvfs — тот же механизм,
|
/// Монтирует SMB-шару через `gio mount` (использует системный gvfs — тот же механизм,
|
||||||
/// которым пользуются Nautilus/Dolphin/GNOME Files). Требует установленного пакета gvfs
|
/// которым пользуются Nautilus/Dolphin/GNOME Files). Требует установленного пакета gvfs
|
||||||
/// (обычно `gvfs-backends` или аналог) в системе.
|
/// (обычно `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(
|
pub fn mount_share(
|
||||||
server: &str,
|
server: &str,
|
||||||
share: &str,
|
share: &str,
|
||||||
username: Option<&str>,
|
username: Option<&str>,
|
||||||
password: Option<&str>,
|
password: Option<&str>,
|
||||||
) -> anyhow::Result<SmbShareInfo> {
|
) -> anyhow::Result<SmbShareInfo> {
|
||||||
let url = match username {
|
let server = server.trim().trim_matches('/');
|
||||||
Some(u) => format!("smb://{u}@{server}/{share}"),
|
let share = share.trim().trim_matches('/');
|
||||||
None => format!("smb://{server}/{share}"),
|
let url = build_smb_url(server, share, username);
|
||||||
};
|
|
||||||
|
|
||||||
let mut child = Command::new("gio")
|
let mut child = Command::new("gio")
|
||||||
.arg("mount")
|
.arg("mount")
|
||||||
|
|
@ -152,6 +163,32 @@ pub fn unmount_share(server: &str, share: &str) -> anyhow::Result<()> {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn finds_matching_mount_dir_by_server_and_share() {
|
fn finds_matching_mount_dir_by_server_and_share() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ ringbuf = "0.3"
|
||||||
ureq = "2"
|
ureq = "2"
|
||||||
realfft = "3"
|
realfft = "3"
|
||||||
textfix = { path = "../textfix" }
|
textfix = { path = "../textfix" }
|
||||||
|
smbfs = { path = "../smbfs" }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
encoding_rs = "0.8"
|
encoding_rs = "0.8"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::Read;
|
use std::io::{Read, Seek, SeekFrom};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::mpsc::Sender;
|
use std::sync::mpsc::Sender;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
@ -7,7 +7,7 @@ use std::time::Duration;
|
||||||
use symphonia::core::audio::{SampleBuffer, SignalSpec};
|
use symphonia::core::audio::{SampleBuffer, SignalSpec};
|
||||||
use symphonia::core::codecs::{Decoder, DecoderOptions};
|
use symphonia::core::codecs::{Decoder, DecoderOptions};
|
||||||
use symphonia::core::formats::{FormatOptions, FormatReader, SeekMode, SeekTo};
|
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::meta::MetadataOptions;
|
||||||
use symphonia::core::probe::Hint;
|
use symphonia::core::probe::Hint;
|
||||||
use symphonia::core::units::Time;
|
use symphonia::core::units::Time;
|
||||||
|
|
@ -15,6 +15,46 @@ use symphonia::core::units::Time;
|
||||||
use crate::types::{PlayerEvent, TrackInfo};
|
use crate::types::{PlayerEvent, TrackInfo};
|
||||||
use textfix::fix_mojibake;
|
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 struct DecodedTrack {
|
||||||
pub format: Box<dyn FormatReader>,
|
pub format: Box<dyn FormatReader>,
|
||||||
pub decoder: Box<dyn Decoder>,
|
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> {
|
pub fn open_track(path: &Path) -> anyhow::Result<DecodedTrack> {
|
||||||
let file = File::open(path)?;
|
let source = open_media_source(path)?;
|
||||||
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
let mss = MediaSourceStream::new(source, Default::default());
|
||||||
|
|
||||||
let mut hint = Hint::new();
|
let mut hint = Hint::new();
|
||||||
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
|
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> {
|
pub fn read_tags(path: &Path) -> anyhow::Result<TrackInfo> {
|
||||||
use symphonia::core::meta::StandardTagKey;
|
use symphonia::core::meta::StandardTagKey;
|
||||||
|
|
||||||
let file = File::open(path)?;
|
let source = open_media_source(path)?;
|
||||||
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
let mss = MediaSourceStream::new(source, Default::default());
|
||||||
let mut hint = Hint::new();
|
let mut hint = Hint::new();
|
||||||
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
|
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
|
||||||
hint.with_extension(ext);
|
hint.with_extension(ext);
|
||||||
|
|
@ -347,6 +387,34 @@ mod tests {
|
||||||
assert_eq!(parse_stream_title(meta), None);
|
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),
|
/// Регрессионный тест на реальный баг: кракозябры чинились в библиотеке (library::scanner),
|
||||||
/// но не в TrackInfo, который показывается в "Now Playing" при реальном воспроизведении —
|
/// но не в TrackInfo, который показывается в "Now Playing" при реальном воспроизведении —
|
||||||
/// потому что read_tags жила отдельно и не применяла fix_mojibake. Воспроизводит точный
|
/// потому что read_tags жила отдельно и не применяла fix_mojibake. Воспроизводит точный
|
||||||
|
|
|
||||||
11
crates/smbfs/Cargo.toml
Normal file
11
crates/smbfs/Cargo.toml
Normal 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
319
crates/smbfs/src/lib.rs
Normal 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")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ edition.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
player = { path = "../player" }
|
player = { path = "../player" }
|
||||||
library = { path = "../library" }
|
library = { path = "../library" }
|
||||||
|
smbfs = { path = "../smbfs" }
|
||||||
ratatui = "0.28"
|
ratatui = "0.28"
|
||||||
crossterm = "0.28"
|
crossterm = "0.28"
|
||||||
ratatui-image = { version = "1", default-features = false, features = ["crossterm", "rustix"] }
|
ratatui-image = { version = "1", default-features = false, features = ["crossterm", "rustix"] }
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
pub fn protocol_to_str(p: ProtocolType) -> &'static str {
|
||||||
match p {
|
match p {
|
||||||
ProtocolType::Halfblocks => "halfblocks",
|
ProtocolType::Halfblocks => "halfblocks",
|
||||||
|
|
@ -232,6 +246,10 @@ pub struct App {
|
||||||
pub station_query: String,
|
pub station_query: String,
|
||||||
pub station_search_in_progress: bool,
|
pub station_search_in_progress: bool,
|
||||||
station_search_rx: Option<std::sync::mpsc::Receiver<anyhow::Result<Vec<Station>>>>,
|
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 ---
|
// --- Now playing / transport ---
|
||||||
pub now_playing: Option<NowPlaying>,
|
pub now_playing: Option<NowPlaying>,
|
||||||
|
|
@ -268,6 +286,11 @@ pub struct App {
|
||||||
pub right_panel_view: RightPanelView,
|
pub right_panel_view: RightPanelView,
|
||||||
/// Текущие значения полос спектра (0..1), обновляются событиями от плеера.
|
/// Текущие значения полос спектра (0..1), обновляются событиями от плеера.
|
||||||
pub spectrum: Vec<f32>,
|
pub spectrum: Vec<f32>,
|
||||||
|
/// Скользящее среднее басовой энергии — для адаптивного "детектора удара баса"
|
||||||
|
/// (Noise Marines реагируют на ВСПЛЕСК относительно недавнего среднего, а не на
|
||||||
|
/// фиксированный порог, который у одних треков никогда не пробивается, а у других
|
||||||
|
/// пробит всегда).
|
||||||
|
pub bass_ema: f32,
|
||||||
/// Если true — выбранный right_panel_view занимает весь экран.
|
/// Если true — выбранный right_panel_view занимает весь экран.
|
||||||
pub fullscreen: bool,
|
pub fullscreen: bool,
|
||||||
|
|
||||||
|
|
@ -356,6 +379,8 @@ impl App {
|
||||||
station_query: String::new(),
|
station_query: String::new(),
|
||||||
station_search_in_progress: false,
|
station_search_in_progress: false,
|
||||||
station_search_rx: None,
|
station_search_rx: None,
|
||||||
|
smb_connect_rx: None,
|
||||||
|
smb_connecting: false,
|
||||||
now_playing: None,
|
now_playing: None,
|
||||||
speed: Speed::default(),
|
speed: Speed::default(),
|
||||||
shuffle: false,
|
shuffle: false,
|
||||||
|
|
@ -378,6 +403,7 @@ impl App {
|
||||||
last_cover_bytes: None,
|
last_cover_bytes: None,
|
||||||
right_panel_view: RightPanelView::Cover,
|
right_panel_view: RightPanelView::Cover,
|
||||||
spectrum: vec![0.0; player::SPECTRUM_BANDS],
|
spectrum: vec![0.0; player::SPECTRUM_BANDS],
|
||||||
|
bass_ema: 0.0,
|
||||||
fullscreen: false,
|
fullscreen: false,
|
||||||
status_message: format!("nsm — loaded from {}", root.display()),
|
status_message: format!("nsm — loaded from {}", root.display()),
|
||||||
};
|
};
|
||||||
|
|
@ -455,6 +481,9 @@ impl App {
|
||||||
/// "guest" — gvfs почти никогда не подключается по-настоящему без логина вообще,
|
/// "guest" — gvfs почти никогда не подключается по-настоящему без логина вообще,
|
||||||
/// даже если шара разрешает гостевой доступ (`guest ok = yes` в конфиге Samba),
|
/// даже если шара разрешает гостевой доступ (`guest ok = yes` в конфиге Samba),
|
||||||
/// а "guest" — общепринятое соглашение, которое такие шары понимают.
|
/// а "guest" — общепринятое соглашение, которое такие шары понимают.
|
||||||
|
/// Запускает подключение+сканирование SMB-шары в фоновом потоке — сеть (или сам сервер)
|
||||||
|
/// может быть медленной или зависнуть, это не должно морозить весь интерфейс (как раньше,
|
||||||
|
/// когда это было синхронным вызовом прямо в обработчике клавиши).
|
||||||
pub fn connect_smb_share(&mut self) {
|
pub fn connect_smb_share(&mut self) {
|
||||||
let server = self.smb_server.trim().to_string();
|
let server = self.smb_server.trim().to_string();
|
||||||
let share = self.smb_share.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 password = (!self.smb_password.is_empty()).then(|| self.smb_password.clone());
|
||||||
|
|
||||||
let (username, password, anonymous) = if username_input.is_empty() {
|
let (username, password, anonymous) = if username_input.is_empty() {
|
||||||
(Some("guest".to_string()), None, true)
|
(None, None, true) // smbfs::connect сама подставляет "guest" для анонимного входа
|
||||||
} else {
|
} else {
|
||||||
(Some(username_input.to_string()), password, false)
|
(Some(username_input.to_string()), password, false)
|
||||||
};
|
};
|
||||||
|
|
@ -478,30 +507,47 @@ impl App {
|
||||||
} else {
|
} else {
|
||||||
format!("Connecting to smb://{server}/{share}…")
|
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) {
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
Ok(path)
|
self.smb_connect_rx = Some(rx);
|
||||||
} else {
|
std::thread::spawn(move || {
|
||||||
library::smb::mount_share(&server, &share, username.as_deref(), password.as_deref())
|
// Свой Rust SMB-клиент (smbfs) — не зависит от системного gio/gvfs и его багов.
|
||||||
.map(|info| info.mount_path)
|
// 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 rx.try_recv() {
|
||||||
match mount_path {
|
Ok((server, share, Ok(tracks))) => {
|
||||||
Ok(path) => match self.library.add_root(&path) {
|
let added = self.library.merge_tracks(tracks);
|
||||||
Ok(added) => {
|
self.status_message = format!("Connected to \\\\{server}\\{share} — added {added} track(s)");
|
||||||
self.status_message =
|
self.rebuild_library_view();
|
||||||
format!("Connected to \\\\{server}\\{share} — added {added} track(s)");
|
self.smb_connecting = false;
|
||||||
self.rebuild_library_view();
|
self.smb_connect_rx = None;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Ok((_, _, Err(e))) => {
|
||||||
self.status_message = format!("Mounted, but scanning failed: {e}");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Err(e) => {
|
|
||||||
self.status_message = format!("SMB connect failed: {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) {
|
fn clear_smb_inputs(&mut self) {
|
||||||
|
|
@ -594,6 +640,14 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spectrum(bands) => {
|
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;
|
self.spectrum = bands;
|
||||||
}
|
}
|
||||||
QueueFinished => {
|
QueueFinished => {
|
||||||
|
|
@ -1122,3 +1176,44 @@ impl App {
|
||||||
self.fullscreen = !self.fullscreen;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,7 @@ fn event_loop<B: ratatui::backend::Backend>(
|
||||||
app.drain_player_events();
|
app.drain_player_events();
|
||||||
app.poll_new_releases();
|
app.poll_new_releases();
|
||||||
app.poll_station_search();
|
app.poll_station_search();
|
||||||
|
app.poll_smb_connect();
|
||||||
last_tick = Instant::now();
|
last_tick = Instant::now();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -543,7 +543,8 @@ fn draw_now_playing(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||||
.constraints([
|
.constraints([
|
||||||
Constraint::Length(12), // обложка + инфо
|
Constraint::Length(12), // обложка + инфо
|
||||||
Constraint::Length(1), // транспорт (speed/shuffle/repeat)
|
Constraint::Length(1), // транспорт (speed/shuffle/repeat)
|
||||||
Constraint::Min(0), // эквалайзер
|
Constraint::Percentage(50), // эквалайзер
|
||||||
|
Constraint::Percentage(50), // танцующие Шумовые Десантники
|
||||||
])
|
])
|
||||||
.split(area);
|
.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_track_info(frame, app, top[1]);
|
||||||
draw_transport(frame, app, chunks[1]);
|
draw_transport(frame, app, chunks[1]);
|
||||||
draw_eq(frame, app, chunks[2]);
|
draw_eq(frame, app, chunks[2]);
|
||||||
|
draw_noise_marines(frame, app, chunks[3]);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_track_info(frame: &mut Frame, app: &App, area: Rect) {
|
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);
|
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) {
|
fn draw_eq(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue