Added marks
This commit is contained in:
parent
8457ccc448
commit
c5c202384b
5 changed files with 393 additions and 5 deletions
|
|
@ -54,6 +54,15 @@ impl AudiobookStatus {
|
||||||
AudiobookStatus::Finished => "Finished",
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
|
@ -106,6 +115,22 @@ impl Audiobook {
|
||||||
pub struct AudiobookLibrary {
|
pub struct AudiobookLibrary {
|
||||||
pub watched_roots: Vec<PathBuf>,
|
pub watched_roots: Vec<PathBuf>,
|
||||||
pub books: Vec<Audiobook>,
|
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 {
|
pub struct AudiobookStore {
|
||||||
|
|
@ -213,6 +238,46 @@ impl AudiobookStore {
|
||||||
self.save(&lib)?;
|
self.save(&lib)?;
|
||||||
Ok(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/прогресса, которые
|
/// Промежуточное представление "свежесканированной" книги — без id/прогресса, которые
|
||||||
|
|
@ -235,6 +300,9 @@ fn rescan(lib: &mut AudiobookLibrary) -> anyhow::Result<()> {
|
||||||
|
|
||||||
let mut merged = Vec::new();
|
let mut merged = Vec::new();
|
||||||
for disc in discovered {
|
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) {
|
if let Some(existing) = lib.books.iter().find(|b| b.folders == disc.folders) {
|
||||||
let mut updated = existing.clone();
|
let mut updated = existing.clone();
|
||||||
updated.files = disc.files;
|
updated.files = disc.files;
|
||||||
|
|
@ -568,6 +636,13 @@ mod tests {
|
||||||
assert_eq!(finished.status(), AudiobookStatus::Finished);
|
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]
|
#[test]
|
||||||
fn idempotent_rescan_does_not_duplicate_and_preserves_progress() {
|
fn idempotent_rescan_does_not_duplicate_and_preserves_progress() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|
@ -627,6 +702,85 @@ mod tests {
|
||||||
assert!(lib2.books[0].manual_metadata);
|
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-лист рядом — проверяет, что
|
/// Реальный аудиофайл (не фиктивные байты) + настоящий cue-лист рядом — проверяет, что
|
||||||
/// главы реально извлекаются, а не просто "не падает". Нужен ffmpeg в PATH.
|
/// главы реально извлекаются, а не просто "не падает". Нужен ffmpeg в PATH.
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -667,4 +821,4 @@ mod tests {
|
||||||
// И длительность файла реально прочиталась (не фиктивный ноль).
|
// И длительность файла реально прочиталась (не фиктивный ноль).
|
||||||
assert!(discovered[0].total_duration >= Duration::from_secs(5));
|
assert!(discovered[0].total_duration >= Duration::from_secs(5));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -13,8 +13,28 @@ struct BiquadCoeffs {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl 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.
|
/// RBJ Audio EQ Cookbook: peaking EQ.
|
||||||
fn peaking(sample_rate: f32, freq: f32, gain_db: f32, q: f32) -> Self {
|
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 a = 10f32.powf(gain_db / 40.0);
|
||||||
let w0 = 2.0 * std::f32::consts::PI * freq / sample_rate;
|
let w0 = 2.0 * std::f32::consts::PI * freq / sample_rate;
|
||||||
let alpha = w0.sin() / (2.0 * q);
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -349,6 +349,9 @@ pub struct App {
|
||||||
pub audiobook_selected: usize,
|
pub audiobook_selected: usize,
|
||||||
/// Если Some(id) — раскрыта книга с этим id, показываем список файлов/глав.
|
/// Если Some(id) — раскрыта книга с этим id, показываем список файлов/глав.
|
||||||
pub audiobook_expanded: Option<String>,
|
pub audiobook_expanded: Option<String>,
|
||||||
|
/// Показываем список удалённых книг (`R`) вместо обычного списка — для восстановления.
|
||||||
|
pub audiobook_showing_removed: bool,
|
||||||
|
pub audiobook_removed_selected: usize,
|
||||||
pub audiobook_file_selected: usize,
|
pub audiobook_file_selected: usize,
|
||||||
pub audiobook_search_query: String,
|
pub audiobook_search_query: String,
|
||||||
/// id книги, отмеченной для объединения — следующая книга, на которой нажмут ту же
|
/// id книги, отмеченной для объединения — следующая книга, на которой нажмут ту же
|
||||||
|
|
@ -539,6 +542,8 @@ impl App {
|
||||||
audiobook_filter: AudiobookFilter::All,
|
audiobook_filter: AudiobookFilter::All,
|
||||||
audiobook_selected: 0,
|
audiobook_selected: 0,
|
||||||
audiobook_expanded: None,
|
audiobook_expanded: None,
|
||||||
|
audiobook_showing_removed: false,
|
||||||
|
audiobook_removed_selected: 0,
|
||||||
audiobook_file_selected: 0,
|
audiobook_file_selected: 0,
|
||||||
audiobook_search_query: String::new(),
|
audiobook_search_query: String::new(),
|
||||||
audiobook_merge_pending: None,
|
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 {
|
let Some(book_id) = self.current_audiobook_id.clone() else {
|
||||||
return;
|
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) {
|
fn clear_smb_inputs(&mut self) {
|
||||||
self.smb_server.clear();
|
self.smb_server.clear();
|
||||||
self.smb_share.clear();
|
self.smb_share.clear();
|
||||||
|
|
@ -1791,6 +1867,11 @@ impl App {
|
||||||
if currently_playing {
|
if currently_playing {
|
||||||
self.paused_at = Some(std::time::Instant::now());
|
self.paused_at = Some(std::time::Instant::now());
|
||||||
self.player.play_pause();
|
self.player.play_pause();
|
||||||
|
// Явно сохраняем прогресс прямо в момент паузы, не дожидаясь обычного троттлинга
|
||||||
|
// (Position-события, двигающие троттлинг, всё равно перестанут приходить, пока
|
||||||
|
// на паузе) — самый частый сценарий "поставил на паузу и закрыл" не должен терять
|
||||||
|
// несколько последних секунд точности.
|
||||||
|
self.save_audiobook_progress_now();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,7 @@ fn event_loop<B: ratatui::backend::Backend>(
|
||||||
}
|
}
|
||||||
|
|
||||||
if app.should_quit {
|
if app.should_quit {
|
||||||
|
app.save_audiobook_progress_now();
|
||||||
return Ok(());
|
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) {
|
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() {
|
if app.audiobook_expanded.is_some() {
|
||||||
handle_audiobook_files_key(app, code);
|
handle_audiobook_files_key(app, code);
|
||||||
return;
|
return;
|
||||||
|
|
@ -479,6 +484,15 @@ fn handle_audiobooks_key(app: &mut App, code: KeyCode) {
|
||||||
KeyCode::Char('x') => {
|
KeyCode::Char('x') => {
|
||||||
app.toggle_merge_selected_audiobook();
|
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') => {
|
KeyCode::Char('p') => {
|
||||||
app.audiobook_filter = app.audiobook_filter.next();
|
app.audiobook_filter = app.audiobook_filter.next();
|
||||||
app.audiobook_selected = 0;
|
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) {
|
fn handle_audiobook_files_key(app: &mut App, code: KeyCode) {
|
||||||
let file_count = app
|
let file_count = app
|
||||||
.audiobook_expanded
|
.audiobook_expanded
|
||||||
|
|
|
||||||
|
|
@ -166,12 +166,15 @@ fn draw_help_popup(frame: &mut Frame, app: &App) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Tab::Audiobooks => {
|
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"));
|
lines.push(Line::from("Enter play from selected file/chapter Esc back"));
|
||||||
} else {
|
} else {
|
||||||
lines.push(Line::from("Enter resume from saved position o browse files/chapters"));
|
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("/ 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("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 => {
|
Tab::Stats => {
|
||||||
|
|
@ -628,6 +631,34 @@ fn draw_audiobooks(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
return;
|
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 {
|
if let Some(book_id) = &app.audiobook_expanded {
|
||||||
let Some(book) = app.audiobooks.books.iter().find(|b| &b.id == book_id) else {
|
let Some(book) = app.audiobooks.books.iter().find(|b| &b.id == book_id) else {
|
||||||
return;
|
return;
|
||||||
|
|
@ -695,8 +726,9 @@ fn draw_audiobooks(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
.map(|b| {
|
.map(|b| {
|
||||||
let pct = (b.progress_ratio() * 100.0).round() as u32;
|
let pct = (b.progress_ratio() * 100.0).round() as u32;
|
||||||
let marked = app.audiobook_merge_pending.as_deref() == Some(b.id.as_str());
|
let marked = app.audiobook_merge_pending.as_deref() == Some(b.id.as_str());
|
||||||
let marker = if marked { "* " } else { " " };
|
let marker = if marked { "*" } else { " " };
|
||||||
ListItem::new(format!("{marker}{} — {} [{pct}%]", b.author, b.title))
|
let letter = b.status().letter();
|
||||||
|
ListItem::new(format!("{marker}[{letter}] {} — {} ({pct}%)", b.author, b.title))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let list = List::new(items)
|
let list = List::new(items)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue