Compare commits
4 Commits
cf6164c438
...
1474302d7e
| Author | SHA1 | Date | |
|---|---|---|---|
| 1474302d7e | |||
| eeecaaf42e | |||
| f359dee194 | |||
| 1e2655670d |
@@ -10,7 +10,7 @@ from textual.events import Key
|
|||||||
from textual.widgets import DataTable, Footer, Header, ProgressBar, Static
|
from textual.widgets import DataTable, Footer, Header, ProgressBar, Static
|
||||||
from textual.worker import get_current_worker
|
from textual.worker import get_current_worker
|
||||||
|
|
||||||
from .constants import *
|
from .constants import PROGRESS_COLUMN_INDEX, SEEK_SECONDS, TABLE_CSS, TABLE_COLUMNS
|
||||||
from .downloads import DownloadManager
|
from .downloads import DownloadManager
|
||||||
from .library import LibraryClient
|
from .library import LibraryClient
|
||||||
from .playback import PlaybackController
|
from .playback import PlaybackController
|
||||||
@@ -33,13 +33,14 @@ class Auditui(App):
|
|||||||
BINDINGS = [
|
BINDINGS = [
|
||||||
("n", "sort", "Sort by name"),
|
("n", "sort", "Sort by name"),
|
||||||
("p", "sort_by_progress", "Sort by progress"),
|
("p", "sort_by_progress", "Sort by progress"),
|
||||||
("a", "show_all", "All/unfinished"),
|
("a", "show_all", "All/Unfinished"),
|
||||||
("enter", "play_selected", "Play"),
|
("enter", "play_selected", "Play"),
|
||||||
("space", "toggle_playback", "Pause/Resume"),
|
("space", "toggle_playback", "Pause/Resume"),
|
||||||
("left", "seek_backward", "-30s"),
|
("left", "seek_backward", "-30s"),
|
||||||
("right", "seek_forward", "+30s"),
|
("right", "seek_forward", "+30s"),
|
||||||
("ctrl+left", "previous_chapter", "Previous chapter"),
|
("ctrl+left", "previous_chapter", "Previous chapter"),
|
||||||
("ctrl+right", "next_chapter", "Next chapter"),
|
("ctrl+right", "next_chapter", "Next chapter"),
|
||||||
|
("d", "toggle_download", "Download/Delete"),
|
||||||
("q", "quit", "Quit"),
|
("q", "quit", "Quit"),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -172,14 +173,20 @@ class Auditui(App):
|
|||||||
return
|
return
|
||||||
|
|
||||||
for item in items:
|
for item in items:
|
||||||
title, author, runtime, progress = format_item_as_row(
|
title, author, runtime, progress, downloaded = format_item_as_row(
|
||||||
item, self.library_client)
|
item, self.library_client, self.download_manager)
|
||||||
table.add_row(title, author, runtime, progress, key=title)
|
table.add_row(title, author, runtime,
|
||||||
|
progress, downloaded, key=title)
|
||||||
|
|
||||||
self.current_items = items
|
self.current_items = items
|
||||||
mode = "all" if self.show_all_mode else "unfinished"
|
mode = "all" if self.show_all_mode else "unfinished"
|
||||||
self.update_status(f"Showing {len(items)} books ({mode})")
|
self.update_status(f"Showing {len(items)} books ({mode})")
|
||||||
|
|
||||||
|
def _refresh_table(self) -> None:
|
||||||
|
"""Refresh the table with current items."""
|
||||||
|
if self.current_items:
|
||||||
|
self._populate_table(self.current_items)
|
||||||
|
|
||||||
def show_all(self) -> None:
|
def show_all(self) -> None:
|
||||||
"""Display all books in the table."""
|
"""Display all books in the table."""
|
||||||
if not self.all_items:
|
if not self.all_items:
|
||||||
@@ -331,6 +338,51 @@ class Auditui(App):
|
|||||||
"""Periodically save playback position."""
|
"""Periodically save playback position."""
|
||||||
self.playback.update_position_if_needed()
|
self.playback.update_position_if_needed()
|
||||||
|
|
||||||
|
def action_toggle_download(self) -> None:
|
||||||
|
"""Toggle download/remove for the selected book."""
|
||||||
|
if not self.download_manager:
|
||||||
|
self.update_status(
|
||||||
|
"Not authenticated. Please restart and authenticate.")
|
||||||
|
return
|
||||||
|
|
||||||
|
table = self.query_one(DataTable)
|
||||||
|
if table.row_count == 0:
|
||||||
|
self.update_status("No books available")
|
||||||
|
return
|
||||||
|
|
||||||
|
cursor_row = table.cursor_row
|
||||||
|
if cursor_row >= len(self.current_items):
|
||||||
|
self.update_status("Invalid selection")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.library_client:
|
||||||
|
self.update_status("Library client not available")
|
||||||
|
return
|
||||||
|
|
||||||
|
selected_item = self.current_items[cursor_row]
|
||||||
|
asin = self.library_client.extract_asin(selected_item)
|
||||||
|
|
||||||
|
if not asin:
|
||||||
|
self.update_status("Could not get ASIN for selected book")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._toggle_download_async(asin)
|
||||||
|
|
||||||
|
@work(exclusive=True, thread=True)
|
||||||
|
def _toggle_download_async(self, asin: str) -> None:
|
||||||
|
"""Toggle download/remove asynchronously."""
|
||||||
|
if not self.download_manager:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.download_manager.is_cached(asin):
|
||||||
|
self.download_manager.remove_cached(
|
||||||
|
asin, self._thread_status_update)
|
||||||
|
else:
|
||||||
|
self.download_manager.get_or_download(
|
||||||
|
asin, self._thread_status_update)
|
||||||
|
|
||||||
|
self.call_from_thread(self._refresh_table)
|
||||||
|
|
||||||
@work(exclusive=True, thread=True)
|
@work(exclusive=True, thread=True)
|
||||||
def _start_playback_async(self, asin: str) -> None:
|
def _start_playback_async(self, asin: str) -> None:
|
||||||
"""Start playback asynchronously."""
|
"""Start playback asynchronously."""
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ DEFAULT_CODEC = "LC_128_44100_stereo"
|
|||||||
MIN_FILE_SIZE = 1024 * 1024
|
MIN_FILE_SIZE = 1024 * 1024
|
||||||
DEFAULT_CHUNK_SIZE = 8192
|
DEFAULT_CHUNK_SIZE = 8192
|
||||||
|
|
||||||
TABLE_COLUMNS = ("Title", "Author", "Length", "Progress")
|
TABLE_COLUMNS = ("Title", "Author", "Length", "Progress", "Downloaded")
|
||||||
|
|
||||||
AUTHOR_NAME_MAX_LENGTH = 40
|
AUTHOR_NAME_MAX_LENGTH = 40
|
||||||
AUTHOR_NAME_DISPLAY_LENGTH = 37
|
AUTHOR_NAME_DISPLAY_LENGTH = 37
|
||||||
|
|||||||
@@ -84,6 +84,37 @@ class DownloadManager:
|
|||||||
except (OSError, ValueError, KeyError, AttributeError):
|
except (OSError, ValueError, KeyError, AttributeError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def get_cached_path(self, asin: str) -> Path | None:
|
||||||
|
"""Get the cached file path for a book if it exists."""
|
||||||
|
title = self._get_name_from_asin(asin) or asin
|
||||||
|
safe_title = self._sanitize_filename(title)
|
||||||
|
local_path = self.cache_dir / f"{safe_title}.aax"
|
||||||
|
if local_path.exists() and local_path.stat().st_size >= MIN_FILE_SIZE:
|
||||||
|
return local_path
|
||||||
|
return None
|
||||||
|
|
||||||
|
def is_cached(self, asin: str) -> bool:
|
||||||
|
"""Check if a book is already cached."""
|
||||||
|
return self.get_cached_path(asin) is not None
|
||||||
|
|
||||||
|
def remove_cached(self, asin: str, notify: StatusCallback | None = None) -> bool:
|
||||||
|
"""Remove a cached book file."""
|
||||||
|
cached_path = self.get_cached_path(asin)
|
||||||
|
if not cached_path:
|
||||||
|
if notify:
|
||||||
|
notify("Book is not cached")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
cached_path.unlink()
|
||||||
|
if notify:
|
||||||
|
notify(f"Removed from cache: {cached_path.name}")
|
||||||
|
return True
|
||||||
|
except OSError as exc:
|
||||||
|
if notify:
|
||||||
|
notify(f"Failed to remove cache: {exc}")
|
||||||
|
return False
|
||||||
|
|
||||||
def _validate_download_url(self, url: str) -> bool:
|
def _validate_download_url(self, url: str) -> bool:
|
||||||
"""Validate that the URL is a valid HTTP/HTTPS URL."""
|
"""Validate that the URL is a valid HTTP/HTTPS URL."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Utils for table operations."""
|
"""Utils for table operations."""
|
||||||
|
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from typing import Callable
|
from typing import TYPE_CHECKING, Callable
|
||||||
|
|
||||||
from .constants import (
|
from .constants import (
|
||||||
AUTHOR_NAME_DISPLAY_LENGTH,
|
AUTHOR_NAME_DISPLAY_LENGTH,
|
||||||
@@ -9,6 +9,9 @@ from .constants import (
|
|||||||
PROGRESS_COLUMN_INDEX,
|
PROGRESS_COLUMN_INDEX,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .downloads import DownloadManager
|
||||||
|
|
||||||
|
|
||||||
def create_title_sort_key(reverse: bool = False) -> tuple[Callable, bool]:
|
def create_title_sort_key(reverse: bool = False) -> tuple[Callable, bool]:
|
||||||
"""Create a sort key function for sorting by title."""
|
"""Create a sort key function for sorting by title."""
|
||||||
@@ -43,11 +46,11 @@ def truncate_author_name(author_names: str) -> str:
|
|||||||
return author_names
|
return author_names
|
||||||
|
|
||||||
|
|
||||||
def format_item_as_row(item: dict, library_client) -> tuple[str, str, str, str]:
|
def format_item_as_row(item: dict, library_client, download_manager: "DownloadManager | None" = None) -> tuple[str, str, str, str, str]:
|
||||||
"""Format a library item into table row data.
|
"""Format a library item into table row data.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (title, author, runtime, progress) strings
|
Tuple of (title, author, runtime, progress, downloaded) strings
|
||||||
"""
|
"""
|
||||||
title = library_client.extract_title(item)
|
title = library_client.extract_title(item)
|
||||||
|
|
||||||
@@ -67,7 +70,13 @@ def format_item_as_row(item: dict, library_client) -> tuple[str, str, str, str]:
|
|||||||
else "0%"
|
else "0%"
|
||||||
)
|
)
|
||||||
|
|
||||||
return (title, author_display, runtime_str, progress_str)
|
downloaded_str = ""
|
||||||
|
if download_manager:
|
||||||
|
asin = library_client.extract_asin(item)
|
||||||
|
if asin and download_manager.is_cached(asin):
|
||||||
|
downloaded_str = "✓"
|
||||||
|
|
||||||
|
return (title, author_display, runtime_str, progress_str, downloaded_str)
|
||||||
|
|
||||||
|
|
||||||
def filter_unfinished_items(items: list[dict], library_client) -> list[dict]:
|
def filter_unfinished_items(items: list[dict], library_client) -> list[dict]:
|
||||||
|
|||||||
Reference in New Issue
Block a user