Added marks

This commit is contained in:
Magnus Root 2026-08-21 10:49:40 +03:00
parent 8457ccc448
commit c5c202384b
5 changed files with 393 additions and 5 deletions

View file

@ -54,6 +54,15 @@ impl AudiobookStatus {
AudiobookStatus::Finished => "Finished",
}
}
/// Однобуквенная метка для компактного отображения рядом с книгой в списке.
pub fn letter(self) -> char {
match self {
AudiobookStatus::New => 'N',
AudiobookStatus::Listening => 'L',
AudiobookStatus::Finished => 'E',
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@ -106,6 +115,22 @@ impl Audiobook {
pub struct AudiobookLibrary {
pub watched_roots: Vec<PathBuf>,
pub books: Vec<Audiobook>,
/// Книги, которые пользователь явно удалил из библиотеки (`d`) — при пересканировании
/// такие книги не должны появляться заново, даже если их папка физически всё ещё
/// существует внутри отслеживаемого корня. Храним не только пути (для сверки при
/// сканировании), но и последние известные название/автора — чтобы было что показать
/// в списке "восстановить удалённое" (`R`), не заставляя гадать по одним путям.
/// `#[serde(default)]` — чтобы уже сохранённые на диске файлы без этого поля (версии
/// до этой правки) продолжали читаться нормально, просто с пустым списком удалённых.
#[serde(default)]
pub removed_books: Vec<RemovedBook>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RemovedBook {
pub folders: Vec<PathBuf>,
pub title: String,
pub author: String,
}
pub struct AudiobookStore {
@ -213,6 +238,46 @@ impl AudiobookStore {
self.save(&lib)?;
Ok(lib)
}
/// Удаляет книгу из библиотеки — не трогает файлы на диске, только перестаёт её
/// отслеживать. Запоминает набор папок книги как исключение, чтобы при следующем
/// пересканировании она не появилась заново (её папка всё ещё может физически
/// существовать внутри отслеживаемого корня — иначе рескан бы её тут же вернул).
pub fn remove_book(&self, book_id: &str) -> anyhow::Result<AudiobookLibrary> {
let mut lib = self.load();
if let Some(book) = lib.books.iter().find(|b| b.id == book_id) {
lib.removed_books.push(RemovedBook {
folders: book.folders.clone(),
title: book.title.clone(),
author: book.author.clone(),
});
}
lib.books.retain(|b| b.id != book_id);
self.save(&lib)?;
Ok(lib)
}
/// Возвращает ранее удалённую книгу (`d`) обратно в библиотеку — убирает её из списка
/// удалённых и сразу пересканирует, чтобы книга реально появилась (с актуальными
/// файлами/длительностью, а не просто "вернуть как было" — папка могла и измениться).
/// Если папка книги с тех пор физически пропала с диска — книга не появится, это
/// ожидаемо (как и для любой другой пропавшей папки).
pub fn restore_book(&self, folders: &[std::path::PathBuf]) -> anyhow::Result<AudiobookLibrary> {
let mut lib = self.load();
lib.removed_books.retain(|r| r.folders != folders);
rescan(&mut lib)?;
self.save(&lib)?;
Ok(lib)
}
/// Полностью очищает библиотеку аудиокниг: все книги, все отслеживаемые папки и все
/// исключения — назад к чистому состоянию. Не трогает файлы на диске — только текущее
/// состояние приложения (как и очистка обычной библиотеки/отписка от подкастов).
pub fn clear_all(&self) -> anyhow::Result<AudiobookLibrary> {
let lib = AudiobookLibrary::default();
self.save(&lib)?;
Ok(lib)
}
}
/// Промежуточное представление "свежесканированной" книги — без id/прогресса, которые
@ -235,6 +300,9 @@ fn rescan(lib: &mut AudiobookLibrary) -> anyhow::Result<()> {
let mut merged = Vec::new();
for disc in discovered {
if lib.removed_books.iter().any(|r| r.folders == disc.folders) {
continue; // явно удалено пользователем — не появляется заново без restore_book
}
if let Some(existing) = lib.books.iter().find(|b| b.folders == disc.folders) {
let mut updated = existing.clone();
updated.files = disc.files;
@ -568,6 +636,13 @@ mod tests {
assert_eq!(finished.status(), AudiobookStatus::Finished);
}
#[test]
fn status_letters_are_distinct_and_match_the_requested_scheme() {
assert_eq!(AudiobookStatus::New.letter(), 'N');
assert_eq!(AudiobookStatus::Listening.letter(), 'L');
assert_eq!(AudiobookStatus::Finished.letter(), 'E');
}
#[test]
fn idempotent_rescan_does_not_duplicate_and_preserves_progress() {
let dir = tempfile::tempdir().unwrap();
@ -627,6 +702,85 @@ mod tests {
assert!(lib2.books[0].manual_metadata);
}
#[test]
fn removed_book_stays_gone_after_rescan() {
let dir = tempfile::tempdir().unwrap();
let book_dir = dir.path().join("Author").join("Book");
fs::create_dir_all(&book_dir).unwrap();
touch_mp3(&book_dir.join("01.mp3"));
let store_path = dir.path().join("audiobooks.json");
let store = AudiobookStore::new(store_path);
let lib1 = store.add_root_and_rescan(dir.path()).unwrap();
assert_eq!(lib1.books.len(), 1);
let id = lib1.books[0].id.clone();
let lib2 = store.remove_book(&id).unwrap();
assert_eq!(lib2.books.len(), 0, "removed book should disappear immediately");
// Ключевая проверка: папка книги ФИЗИЧЕСКИ всё ещё на месте (никто её не удалял
// с диска), а корень всё ещё отслеживается — наивное пересканирование вернуло бы
// книгу обратно, если бы не список исключений.
let lib3 = store.rescan_all().unwrap();
assert_eq!(lib3.books.len(), 0, "removed book must not reappear after rescan");
}
#[test]
fn restore_book_brings_it_back_with_metadata_for_display() {
let dir = tempfile::tempdir().unwrap();
let book_dir = dir.path().join("Author").join("Book");
fs::create_dir_all(&book_dir).unwrap();
touch_mp3(&book_dir.join("01.mp3"));
let store_path = dir.path().join("audiobooks.json");
let store = AudiobookStore::new(store_path);
let lib1 = store.add_root_and_rescan(dir.path()).unwrap();
let folders = lib1.books[0].folders.clone();
let lib2 = store.remove_book(&lib1.books[0].id).unwrap();
assert_eq!(lib2.books.len(), 0);
assert_eq!(lib2.removed_books.len(), 1, "removed book should be listed for potential restore");
assert_eq!(lib2.removed_books[0].title, "Book");
assert_eq!(lib2.removed_books[0].author, "Author");
let lib3 = store.restore_book(&folders).unwrap();
assert_eq!(lib3.books.len(), 1, "restored book should reappear");
assert_eq!(lib3.removed_books.len(), 0, "no longer in the removed list once restored");
assert_eq!(lib3.books[0].title, "Book");
// И повторное обычное пересканирование теперь снова находит книгу нормально —
// restore_book не оставляет её в каком-то особом "полу-удалённом" состоянии.
let lib4 = store.rescan_all().unwrap();
assert_eq!(lib4.books.len(), 1);
}
#[test]
fn clear_all_resets_everything_but_does_not_touch_disk_files() {
let dir = tempfile::tempdir().unwrap();
let book_dir = dir.path().join("Author").join("Book");
fs::create_dir_all(&book_dir).unwrap();
touch_mp3(&book_dir.join("01.mp3"));
let store_path = dir.path().join("audiobooks.json");
let store = AudiobookStore::new(store_path);
let lib1 = store.add_root_and_rescan(dir.path()).unwrap();
assert_eq!(lib1.books.len(), 1);
assert_eq!(lib1.watched_roots.len(), 1);
let lib2 = store.clear_all().unwrap();
assert!(lib2.books.is_empty());
assert!(lib2.watched_roots.is_empty());
assert!(lib2.removed_books.is_empty());
// Файл на диске никто не трогал — очистка была только в состоянии приложения.
assert!(book_dir.join("01.mp3").exists());
// И повторное добавление той же папки заново находит книгу — clear_all не оставил
// её в "исключениях" навсегда.
let lib3 = store.add_root_and_rescan(dir.path()).unwrap();
assert_eq!(lib3.books.len(), 1, "re-adding after clear_all should find the book again");
}
/// Реальный аудиофайл (не фиктивные байты) + настоящий cue-лист рядом — проверяет, что
/// главы реально извлекаются, а не просто "не падает". Нужен ffmpeg в PATH.
#[test]
@ -667,4 +821,4 @@ mod tests {
// И длительность файла реально прочиталась (не фиктивный ноль).
assert!(discovered[0].total_duration >= Duration::from_secs(5));
}
}
}

View file

@ -13,8 +13,28 @@ struct BiquadCoeffs {
}
impl BiquadCoeffs {
/// Тождественный фильтр (полностью прозрачный, не меняет сигнал) — используется вместо
/// настоящего биквада для полос, чья частота на/выше частоты Найквиста (sample_rate/2):
/// там физически нет никакого содержимого сигнала, которое можно было бы поднять/срезать,
/// а сама формула RBJ cookbook в этой области даёт численно нестабильный фильтр —
/// коэффициенты получаются конечными, но при многократном рекурсивном применении
/// (обычное дело за много секунд воспроизведения) внутреннее состояние фильтра
/// расходится и переполняется в NaN, портя весь дальнейший звук. Реальный кейс: полоса
/// 16кГц (и отчасти 12кГц) при частоте дискретизации 24000Гц (частый случай для очень
/// низкобитрейтных 64кбит аудиокниг) — Найквист там всего 12000Гц.
fn identity() -> Self {
BiquadCoeffs { b0: 1.0, b1: 0.0, b2: 0.0, a1: 0.0, a2: 0.0 }
}
/// RBJ Audio EQ Cookbook: peaking EQ.
fn peaking(sample_rate: f32, freq: f32, gain_db: f32, q: f32) -> Self {
// Небольшой запас (не ровно 0.5, а 0.49) — вплотную к самой границе Найквиста
// отклик фильтра становится всё более острым/численно капризным даже формально
// оставаясь "ниже" границы, безопаснее отступить чуть раньше.
if freq >= sample_rate * 0.49 {
return Self::identity();
}
let a = 10f32.powf(gain_db / 40.0);
let w0 = 2.0 * std::f32::consts::PI * freq / sample_rate;
let alpha = w0.sin() / (2.0 * q);
@ -113,3 +133,66 @@ impl Equalizer {
}
}
}
#[cfg(test)]
mod stability_tests {
use super::*;
/// Реальный найденный баг: EQ_FREQS доходит до 16000Гц, но при частоте
/// дискретизации ниже 32000Гц (например, 24000Гц — частый случай для очень
/// низкобитрейтных аудиокниг типа 64кбит) частота Найквиста (sample_rate/2) —
/// всего 12000Гц, то есть полоса 16000Гц оказывается ВЫШЕ Найквиста, а полоса
/// 12000Гц — ровно на границе. Коэффициенты биквада для такой полосы получаются
/// конечными числами (сама формула не даёт NaN/inf на этапе вычисления
/// коэффициентов), но результирующий фильтр нестабилен — при многократном
/// рекурсивном применении (обычное дело за много секунд реального
/// воспроизведения) его внутреннее состояние (y1/y2) расходится и в какой-то
/// момент переполняется в бесконечность, откуда дальше всё становится NaN —
/// именно поэтому в реальном плеере это выглядит как "скрип в начале, потом
/// тишина", а не мгновенный сбой с первого сэмпла.
#[test]
fn eq_stays_finite_at_low_sample_rate_over_many_samples() {
let sample_rate = 24000.0f32;
let mut eq = Equalizer::new(sample_rate, 1);
// Реальный EQ используется с нулевым усилением по умолчанию (gains_db = [0.0; ...]),
// так что тест бьёт по сценарию "EQ включён, но пользователь ничего не подкручивал" —
// именно так это и происходит в реальном плеере при обычном прослушивании.
// Симулируем реальный сигнал (не тишину — тишина тривиально стабильна) на несколько
// секунд воспроизведения, достаточно много сэмплов, чтобы нестабильность успела
// разойтись, если она есть.
let mut buf: Vec<f32> = (0..sample_rate as usize * 5) // 5 секунд
.map(|i| {
let t = i as f32 / sample_rate;
(2.0 * std::f32::consts::PI * 300.0 * t).sin() * 0.3
})
.collect();
eq.process_interleaved(&mut buf);
let first_non_finite = buf.iter().position(|s| !s.is_finite());
assert!(
first_non_finite.is_none(),
"EQ produced a non-finite (NaN/inf) sample at index {:?} out of {} — filter is unstable at this sample rate",
first_non_finite,
buf.len()
);
}
/// То же самое, но на "безопасной" высокой частоте дискретизации (44100Гц, Найквист
/// 22050Гц — все полосы EQ_FREQS комфортно ниже) — должно быть стабильно всегда,
/// это тест-контроль, подтверждающий, что дело именно в частоте, а не в чём-то ещё.
#[test]
fn eq_stays_finite_at_normal_sample_rate_over_many_samples() {
let sample_rate = 44100.0f32;
let mut eq = Equalizer::new(sample_rate, 1);
let mut buf: Vec<f32> = (0..sample_rate as usize * 5)
.map(|i| {
let t = i as f32 / sample_rate;
(2.0 * std::f32::consts::PI * 300.0 * t).sin() * 0.3
})
.collect();
eq.process_interleaved(&mut buf);
assert!(buf.iter().all(|s| s.is_finite()), "should stay finite at a normal sample rate");
}
}

View file

@ -349,6 +349,9 @@ pub struct App {
pub audiobook_selected: usize,
/// Если Some(id) — раскрыта книга с этим id, показываем список файлов/глав.
pub audiobook_expanded: Option<String>,
/// Показываем список удалённых книг (`R`) вместо обычного списка — для восстановления.
pub audiobook_showing_removed: bool,
pub audiobook_removed_selected: usize,
pub audiobook_file_selected: usize,
pub audiobook_search_query: String,
/// id книги, отмеченной для объединения — следующая книга, на которой нажмут ту же
@ -539,6 +542,8 @@ impl App {
audiobook_filter: AudiobookFilter::All,
audiobook_selected: 0,
audiobook_expanded: None,
audiobook_showing_removed: false,
audiobook_removed_selected: 0,
audiobook_file_selected: 0,
audiobook_search_query: String::new(),
audiobook_merge_pending: None,
@ -1058,7 +1063,12 @@ impl App {
}
}
fn save_audiobook_progress_now(&mut self) {
/// Принудительно сохраняет прогресс аудиокниги прямо сейчас, не дожидаясь обычного
/// троттлинга в 5 секунд — вызывается на паузе и перед выходом из приложения, чтобы не
/// терять несколько последних секунд точности резюме в самых частых сценариях
/// "поставил на паузу и закрыл" / "просто закрыл во время прослушивания".
/// Безопасно вызывать всегда — тихо ничего не делает, если сейчас не играет аудиокнига.
pub fn save_audiobook_progress_now(&mut self) {
let Some(book_id) = self.current_audiobook_id.clone() else {
return;
};
@ -1141,6 +1151,72 @@ impl App {
}
}
/// Удаляет выбранную книгу из библиотеки аудиокниг — не трогает файлы на диске, только
/// перестаёт её отслеживать (не появится заново при следующем пересканировании).
pub fn remove_selected_audiobook(&mut self) {
let books = self.filtered_audiobooks();
let Some(book) = books.get(self.audiobook_selected) else {
return;
};
let id = book.id.clone();
let title = book.title.clone();
match self.audiobook_store.remove_book(&id) {
Ok(lib) => {
self.audiobooks = lib;
self.audiobook_selected = 0;
self.status_message = format!("Removed '{title}' from library (R to restore)");
}
Err(e) => {
self.status_message = format!("Failed to remove book: {e}");
}
}
}
/// Полностью очищает библиотеку аудиокниг (все книги + отслеживаемые папки) — не
/// трогает файлы на диске, только состояние приложения. Аналогично 'C' в обычной
/// библиотеке и отписке от всех подкастов.
pub fn clear_audiobook_library(&mut self) {
match self.audiobook_store.clear_all() {
Ok(lib) => {
self.audiobooks = lib;
self.audiobook_selected = 0;
self.audiobook_expanded = None;
self.status_message = "Audiobook library cleared".to_string();
}
Err(e) => {
self.status_message = format!("Failed to clear library: {e}");
}
}
}
pub fn toggle_removed_books_view(&mut self) {
self.audiobook_showing_removed = !self.audiobook_showing_removed;
self.audiobook_removed_selected = 0;
}
/// Восстанавливает выбранную в списке удалённых книгу обратно в библиотеку — сразу
/// пересканирует, чтобы она появилась с актуальными данными.
pub fn restore_selected_removed_book(&mut self) {
let Some(removed) = self.audiobooks.removed_books.get(self.audiobook_removed_selected) else {
return;
};
let folders = removed.folders.clone();
let title = removed.title.clone();
match self.audiobook_store.restore_book(&folders) {
Ok(lib) => {
self.audiobooks = lib;
self.audiobook_removed_selected = 0;
self.status_message = format!("Restored '{title}'");
if self.audiobooks.removed_books.is_empty() {
self.audiobook_showing_removed = false;
}
}
Err(e) => {
self.status_message = format!("Failed to restore book: {e}");
}
}
}
fn clear_smb_inputs(&mut self) {
self.smb_server.clear();
self.smb_share.clear();
@ -1791,6 +1867,11 @@ impl App {
if currently_playing {
self.paused_at = Some(std::time::Instant::now());
self.player.play_pause();
// Явно сохраняем прогресс прямо в момент паузы, не дожидаясь обычного троттлинга
// (Position-события, двигающие троттлинг, всё равно перестанут приходить, пока
// на паузе) — самый частый сценарий "поставил на паузу и закрыл" не должен терять
// несколько последних секунд точности.
self.save_audiobook_progress_now();
return;
}

View file

@ -88,6 +88,7 @@ fn event_loop<B: ratatui::backend::Backend>(
}
if app.should_quit {
app.save_audiobook_progress_now();
return Ok(());
}
}
@ -459,6 +460,10 @@ fn handle_audiobook_edit_author_key(app: &mut App, code: KeyCode) {
}
fn handle_audiobooks_key(app: &mut App, code: KeyCode) {
if app.audiobook_showing_removed {
handle_audiobook_removed_key(app, code);
return;
}
if app.audiobook_expanded.is_some() {
handle_audiobook_files_key(app, code);
return;
@ -479,6 +484,15 @@ fn handle_audiobooks_key(app: &mut App, code: KeyCode) {
KeyCode::Char('x') => {
app.toggle_merge_selected_audiobook();
}
KeyCode::Char('d') => {
app.remove_selected_audiobook();
}
KeyCode::Char('D') => {
app.clear_audiobook_library();
}
KeyCode::Char('R') => {
app.toggle_removed_books_view();
}
KeyCode::Char('p') => {
app.audiobook_filter = app.audiobook_filter.next();
app.audiobook_selected = 0;
@ -511,6 +525,30 @@ fn handle_audiobooks_key(app: &mut App, code: KeyCode) {
}
}
fn handle_audiobook_removed_key(app: &mut App, code: KeyCode) {
let len = app.audiobooks.removed_books.len();
match code {
KeyCode::Esc | KeyCode::Char('R') => {
app.audiobook_showing_removed = false;
app.audiobook_removed_selected = 0;
}
KeyCode::Down | KeyCode::Char('j') => {
if len > 0 {
app.audiobook_removed_selected = (app.audiobook_removed_selected + 1) % len;
}
}
KeyCode::Up | KeyCode::Char('k') => {
if len > 0 {
app.audiobook_removed_selected = (app.audiobook_removed_selected + len - 1) % len;
}
}
KeyCode::Enter => {
app.restore_selected_removed_book();
}
_ => {}
}
}
fn handle_audiobook_files_key(app: &mut App, code: KeyCode) {
let file_count = app
.audiobook_expanded

View file

@ -166,12 +166,15 @@ fn draw_help_popup(frame: &mut Frame, app: &App) {
}
}
Tab::Audiobooks => {
if app.audiobook_expanded.is_some() {
if app.audiobook_showing_removed {
lines.push(Line::from("Enter restore selected book Esc/R back to library"));
} else if app.audiobook_expanded.is_some() {
lines.push(Line::from("Enter play from selected file/chapter Esc back"));
} else {
lines.push(Line::from("Enter resume from saved position o browse files/chapters"));
lines.push(Line::from("/ search p cycle status filter L add folder f rescan"));
lines.push(Line::from("t edit title/author x mark/merge with another book"));
lines.push(Line::from("d remove book D clear entire library R view/restore removed"));
}
}
Tab::Stats => {
@ -628,6 +631,34 @@ fn draw_audiobooks(frame: &mut Frame, app: &App, area: Rect) {
return;
}
if app.audiobook_showing_removed {
if app.audiobooks.removed_books.is_empty() {
let p = Paragraph::new("No removed books")
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).title(" Removed books (? for help) "));
frame.render_widget(p, area);
return;
}
let items: Vec<ListItem> = app
.audiobooks
.removed_books
.iter()
.map(|r| ListItem::new(format!("{}{}", r.author, r.title)))
.collect();
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Removed books (Enter: restore, ? for help) "),
)
.highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD))
.highlight_symbol("> ");
let mut state = ratatui::widgets::ListState::default();
state.select(Some(app.audiobook_removed_selected));
frame.render_stateful_widget(list, area, &mut state);
return;
}
if let Some(book_id) = &app.audiobook_expanded {
let Some(book) = app.audiobooks.books.iter().find(|b| &b.id == book_id) else {
return;
@ -695,8 +726,9 @@ fn draw_audiobooks(frame: &mut Frame, app: &App, area: Rect) {
.map(|b| {
let pct = (b.progress_ratio() * 100.0).round() as u32;
let marked = app.audiobook_merge_pending.as_deref() == Some(b.id.as_str());
let marker = if marked { "* " } else { " " };
ListItem::new(format!("{marker}{}{} [{pct}%]", b.author, b.title))
let marker = if marked { "*" } else { " " };
let letter = b.status().letter();
ListItem::new(format!("{marker}[{letter}] {}{} ({pct}%)", b.author, b.title))
})
.collect();
let list = List::new(items)