From c5c202384bb51a015544776a07f220e07986c678 Mon Sep 17 00:00:00 2001 From: Magnus Root Date: Fri, 21 Aug 2026 10:49:40 +0300 Subject: [PATCH] Added marks --- crates/library/src/audiobook.rs | 156 +++++++++++++++++++++++++++++++- crates/player/src/eq.rs | 83 +++++++++++++++++ crates/tui/src/app.rs | 83 ++++++++++++++++- crates/tui/src/lib.rs | 38 ++++++++ crates/tui/src/ui.rs | 38 +++++++- 5 files changed, 393 insertions(+), 5 deletions(-) diff --git a/crates/library/src/audiobook.rs b/crates/library/src/audiobook.rs index 1304b08..9fb1e90 100644 --- a/crates/library/src/audiobook.rs +++ b/crates/library/src/audiobook.rs @@ -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, pub books: Vec, + /// Книги, которые пользователь явно удалил из библиотеки (`d`) — при пересканировании + /// такие книги не должны появляться заново, даже если их папка физически всё ещё + /// существует внутри отслеживаемого корня. Храним не только пути (для сверки при + /// сканировании), но и последние известные название/автора — чтобы было что показать + /// в списке "восстановить удалённое" (`R`), не заставляя гадать по одним путям. + /// `#[serde(default)]` — чтобы уже сохранённые на диске файлы без этого поля (версии + /// до этой правки) продолжали читаться нормально, просто с пустым списком удалённых. + #[serde(default)] + pub removed_books: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RemovedBook { + pub folders: Vec, + 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 { + 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 { + 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 { + 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)); } -} +} \ No newline at end of file diff --git a/crates/player/src/eq.rs b/crates/player/src/eq.rs index 1517a66..09f60e3 100644 --- a/crates/player/src/eq.rs +++ b/crates/player/src/eq.rs @@ -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 = (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 = (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"); + } +} \ No newline at end of file diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 0d556dd..7d6396c 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -349,6 +349,9 @@ pub struct App { pub audiobook_selected: usize, /// Если Some(id) — раскрыта книга с этим id, показываем список файлов/глав. pub audiobook_expanded: Option, + /// Показываем список удалённых книг (`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; } diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index a5b9d31..97c7cef 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -88,6 +88,7 @@ fn event_loop( } 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 diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs index 414260b..960a86a 100644 --- a/crates/tui/src/ui.rs +++ b/crates/tui/src/ui.rs @@ -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 = 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)