Compare commits
4 Commits
bec7ba5ec0
...
e620ea8369
| Author | SHA1 | Date | |
|---|---|---|---|
| e620ea8369 | |||
| c1dd38fbe6 | |||
| fca7329ba1 | |||
| 8fdd517933 |
@@ -41,6 +41,7 @@ Please also note that as of now, you need to have [ffmpeg](https://ffmpeg.org/)
|
|||||||
| `f` | Mark as finished/unfinished |
|
| `f` | Mark as finished/unfinished |
|
||||||
| `d` | Download/delete from cache |
|
| `d` | Download/delete from cache |
|
||||||
| `s` | Show stats screen |
|
| `s` | Show stats screen |
|
||||||
|
| `/` | Filter library |
|
||||||
| `q` | Quit the application |
|
| `q` | Quit the application |
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
@@ -60,7 +61,7 @@ Please also note that as of now, you need to have [ffmpeg](https://ffmpeg.org/)
|
|||||||
- [x] mark a book as finished or unfinished
|
- [x] mark a book as finished or unfinished
|
||||||
- [x] make ui responsive
|
- [x] make ui responsive
|
||||||
- [x] get your stats in a separated pane
|
- [x] get your stats in a separated pane
|
||||||
- [ ] search/filter within your library
|
- [x] search/filter within your library
|
||||||
- [ ] installation setup
|
- [ ] installation setup
|
||||||
|
|
||||||
## Auth / credentials
|
## Auth / credentials
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ from .table_utils import (
|
|||||||
filter_unfinished_items,
|
filter_unfinished_items,
|
||||||
format_item_as_row,
|
format_item_as_row,
|
||||||
)
|
)
|
||||||
from .ui import HelpScreen, StatsScreen
|
from .ui import FilterScreen, HelpScreen, StatsScreen
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from textual.widgets._data_table import ColumnKey
|
from textual.widgets._data_table import ColumnKey
|
||||||
@@ -42,6 +42,8 @@ class Auditui(App):
|
|||||||
BINDINGS = [
|
BINDINGS = [
|
||||||
("?", "show_help", "Help"),
|
("?", "show_help", "Help"),
|
||||||
("s", "show_stats", "Stats"),
|
("s", "show_stats", "Stats"),
|
||||||
|
("/", "filter", "Filter"),
|
||||||
|
("escape", "clear_filter", "Clear filter"),
|
||||||
("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"),
|
||||||
@@ -73,7 +75,9 @@ class Auditui(App):
|
|||||||
|
|
||||||
self.all_items: list[dict] = []
|
self.all_items: list[dict] = []
|
||||||
self.current_items: list[dict] = []
|
self.current_items: list[dict] = []
|
||||||
|
self._search_text_cache: dict[int, str] = {}
|
||||||
self.show_all_mode = False
|
self.show_all_mode = False
|
||||||
|
self.filter_text = ""
|
||||||
self.title_sort_reverse = False
|
self.title_sort_reverse = False
|
||||||
self.progress_sort_reverse = False
|
self.progress_sort_reverse = False
|
||||||
self.title_column_key: ColumnKey | None = None
|
self.title_column_key: ColumnKey | None = None
|
||||||
@@ -220,6 +224,8 @@ class Auditui(App):
|
|||||||
def on_library_loaded(self, items: list[dict]) -> None:
|
def on_library_loaded(self, items: list[dict]) -> None:
|
||||||
"""Handle successful library load."""
|
"""Handle successful library load."""
|
||||||
self.all_items = items
|
self.all_items = items
|
||||||
|
self._search_text_cache.clear()
|
||||||
|
self._prime_search_cache(items)
|
||||||
self.update_status(f"Loaded {len(items)} books")
|
self.update_status(f"Loaded {len(items)} books")
|
||||||
self.show_unfinished()
|
self.show_unfinished()
|
||||||
|
|
||||||
@@ -256,17 +262,14 @@ class Auditui(App):
|
|||||||
if not self.all_items:
|
if not self.all_items:
|
||||||
return
|
return
|
||||||
self.show_all_mode = True
|
self.show_all_mode = True
|
||||||
self._populate_table(self.all_items)
|
self._refresh_filtered_view()
|
||||||
|
|
||||||
def show_unfinished(self) -> None:
|
def show_unfinished(self) -> None:
|
||||||
"""Display only unfinished books in the table."""
|
"""Display only unfinished books in the table."""
|
||||||
if not self.all_items or not self.library_client:
|
if not self.all_items or not self.library_client:
|
||||||
return
|
return
|
||||||
|
|
||||||
self.show_all_mode = False
|
self.show_all_mode = False
|
||||||
unfinished_items = filter_unfinished_items(
|
self._refresh_filtered_view()
|
||||||
self.all_items, self.library_client)
|
|
||||||
self._populate_table(unfinished_items)
|
|
||||||
|
|
||||||
def action_sort(self) -> None:
|
def action_sort(self) -> None:
|
||||||
"""Sort table by title, toggling direction on each press."""
|
"""Sort table by title, toggling direction on each press."""
|
||||||
@@ -422,6 +425,80 @@ class Auditui(App):
|
|||||||
"""Show the stats screen with listening statistics."""
|
"""Show the stats screen with listening statistics."""
|
||||||
self.push_screen(StatsScreen())
|
self.push_screen(StatsScreen())
|
||||||
|
|
||||||
|
def action_filter(self) -> None:
|
||||||
|
"""Show the filter screen to search the library."""
|
||||||
|
self.push_screen(
|
||||||
|
FilterScreen(
|
||||||
|
self.filter_text,
|
||||||
|
on_change=self._apply_filter,
|
||||||
|
),
|
||||||
|
self._apply_filter,
|
||||||
|
)
|
||||||
|
|
||||||
|
def action_clear_filter(self) -> None:
|
||||||
|
"""Clear the current filter if active."""
|
||||||
|
if self.filter_text:
|
||||||
|
self.filter_text = ""
|
||||||
|
self._refresh_filtered_view()
|
||||||
|
self.update_status("Filter cleared")
|
||||||
|
|
||||||
|
def _apply_filter(self, filter_text: str) -> None:
|
||||||
|
"""Apply the filter to the library."""
|
||||||
|
self.filter_text = filter_text
|
||||||
|
self._refresh_filtered_view()
|
||||||
|
|
||||||
|
def _refresh_filtered_view(self) -> None:
|
||||||
|
"""Refresh the table with current filter and view mode."""
|
||||||
|
if not self.all_items:
|
||||||
|
return
|
||||||
|
|
||||||
|
items = self.all_items
|
||||||
|
|
||||||
|
if self.filter_text:
|
||||||
|
filter_lower = self.filter_text.lower()
|
||||||
|
items = [
|
||||||
|
item for item in items
|
||||||
|
if filter_lower in self._get_search_text(item)
|
||||||
|
]
|
||||||
|
self._populate_table(items)
|
||||||
|
self.update_status(
|
||||||
|
f"Filter: '{self.filter_text}' ({len(items)} books)")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.show_all_mode and self.library_client:
|
||||||
|
items = filter_unfinished_items(items, self.library_client)
|
||||||
|
|
||||||
|
self._populate_table(items)
|
||||||
|
|
||||||
|
def _get_search_text(self, item: dict) -> str:
|
||||||
|
"""Return cached search text for filtering."""
|
||||||
|
cache_key = id(item)
|
||||||
|
cached = self._search_text_cache.get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
title = ""
|
||||||
|
authors = ""
|
||||||
|
if self.library_client:
|
||||||
|
title = self.library_client.extract_title(item)
|
||||||
|
authors = self.library_client.extract_authors(item)
|
||||||
|
else:
|
||||||
|
title = item.get("title", "")
|
||||||
|
authors = ", ".join(
|
||||||
|
a.get("name", "")
|
||||||
|
for a in item.get("authors", [])
|
||||||
|
if isinstance(a, dict) and a.get("name")
|
||||||
|
)
|
||||||
|
|
||||||
|
search_text = f"{title} {authors}".lower()
|
||||||
|
self._search_text_cache[cache_key] = search_text
|
||||||
|
return search_text
|
||||||
|
|
||||||
|
def _prime_search_cache(self, items: list[dict]) -> None:
|
||||||
|
"""Precompute search text for a list of items."""
|
||||||
|
for item in items:
|
||||||
|
self._get_search_text(item)
|
||||||
|
|
||||||
def _check_playback_status(self) -> None:
|
def _check_playback_status(self) -> None:
|
||||||
"""Check if playback process has finished and update state accordingly."""
|
"""Check if playback process has finished and update state accordingly."""
|
||||||
message = self.playback.check_status()
|
message = self.playback.check_status()
|
||||||
|
|||||||
@@ -233,4 +233,45 @@ StatsScreen .help_list > ListItem > Label {
|
|||||||
margin-top: 1;
|
margin-top: 1;
|
||||||
border-top: solid #4b5165;
|
border-top: solid #4b5165;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FilterScreen {
|
||||||
|
align: center middle;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
FilterScreen Static {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
#filter_container {
|
||||||
|
width: 60;
|
||||||
|
height: auto;
|
||||||
|
background: #181a2a;
|
||||||
|
border: heavy #7aa2f7;
|
||||||
|
padding: 1 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#filter_title {
|
||||||
|
width: 100%;
|
||||||
|
height: 2;
|
||||||
|
text-align: center;
|
||||||
|
text-style: bold;
|
||||||
|
color: #7aa2f7;
|
||||||
|
content-align: center middle;
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#filter_input {
|
||||||
|
width: 100%;
|
||||||
|
margin: 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#filter_footer {
|
||||||
|
width: 100%;
|
||||||
|
height: 2;
|
||||||
|
text-align: center;
|
||||||
|
content-align: center middle;
|
||||||
|
color: #bac2de;
|
||||||
|
margin-top: 1;
|
||||||
|
}
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from typing import Any, Protocol, TYPE_CHECKING, cast
|
from typing import Any, Callable, Protocol, TYPE_CHECKING, cast
|
||||||
|
|
||||||
from textual.app import ComposeResult
|
from textual.app import ComposeResult
|
||||||
from textual.containers import Container, Horizontal, Vertical
|
from textual.containers import Container, Horizontal, Vertical
|
||||||
from textual.screen import ModalScreen
|
from textual.screen import ModalScreen
|
||||||
from textual.widgets import Label, ListItem, ListView, Static
|
from textual.timer import Timer
|
||||||
|
from textual.widgets import Input, Label, ListItem, ListView, Static
|
||||||
|
|
||||||
from .constants import AUTH_PATH, CONFIG_PATH
|
from .constants import AUTH_PATH, CONFIG_PATH
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ KEY_COLOR = "#f9e2af"
|
|||||||
DESC_COLOR = "#cdd6f4"
|
DESC_COLOR = "#cdd6f4"
|
||||||
|
|
||||||
|
|
||||||
class AppContextMixin:
|
class AppContextMixin(ModalScreen):
|
||||||
"""Mixin to provide a typed app accessor."""
|
"""Mixin to provide a typed app accessor."""
|
||||||
|
|
||||||
def _app(self) -> _AppContext:
|
def _app(self) -> _AppContext:
|
||||||
@@ -299,6 +300,7 @@ class StatsScreen(AppContextMixin, ModalScreen):
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
def _get_email_from_config(self, app: _AppContext) -> str | None:
|
def _get_email_from_config(self, app: _AppContext) -> str | None:
|
||||||
"""Extract email from the config file."""
|
"""Extract email from the config file."""
|
||||||
@@ -313,6 +315,7 @@ class StatsScreen(AppContextMixin, ModalScreen):
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
def _get_email_from_auth_file(self, app: _AppContext) -> str | None:
|
def _get_email_from_auth_file(self, app: _AppContext) -> str | None:
|
||||||
"""Extract email from the auth file."""
|
"""Extract email from the auth file."""
|
||||||
@@ -328,6 +331,7 @@ class StatsScreen(AppContextMixin, ModalScreen):
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
def _get_email_from_account_info(self, app: _AppContext) -> str | None:
|
def _get_email_from_account_info(self, app: _AppContext) -> str | None:
|
||||||
"""Extract email from the account info API."""
|
"""Extract email from the account info API."""
|
||||||
@@ -352,6 +356,7 @@ class StatsScreen(AppContextMixin, ModalScreen):
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
def _first_email(self, *values: str | None) -> str | None:
|
def _first_email(self, *values: str | None) -> str | None:
|
||||||
"""Return the first non-empty, non-Unknown email value."""
|
"""Return the first non-empty, non-Unknown email value."""
|
||||||
@@ -509,3 +514,59 @@ class StatsScreen(AppContextMixin, ModalScreen):
|
|||||||
|
|
||||||
async def action_dismiss(self, result: Any | None = None) -> None:
|
async def action_dismiss(self, result: Any | None = None) -> None:
|
||||||
await self.dismiss(result)
|
await self.dismiss(result)
|
||||||
|
|
||||||
|
|
||||||
|
class FilterScreen(ModalScreen[str]):
|
||||||
|
"""Filter screen for searching the library."""
|
||||||
|
|
||||||
|
BINDINGS = [("escape", "cancel", "Cancel")]
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
initial_filter: str = "",
|
||||||
|
on_change: Callable[[str], None] | None = None,
|
||||||
|
debounce_seconds: float = 0.2,
|
||||||
|
) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._initial_filter = initial_filter
|
||||||
|
self._on_change = on_change
|
||||||
|
self._debounce_seconds = debounce_seconds
|
||||||
|
self._debounce_timer: Timer | None = None
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Container(id="filter_container"):
|
||||||
|
yield Static("Filter Library", id="filter_title")
|
||||||
|
yield Input(
|
||||||
|
value=self._initial_filter,
|
||||||
|
placeholder="Type to filter by title or author...",
|
||||||
|
id="filter_input",
|
||||||
|
)
|
||||||
|
yield Static(
|
||||||
|
f"Press [bold {KEY_COLOR}]Enter[/] to apply, "
|
||||||
|
f"[bold {KEY_COLOR}]Escape[/] to clear",
|
||||||
|
id="filter_footer",
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self.query_one("#filter_input", Input).focus()
|
||||||
|
|
||||||
|
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||||
|
self.dismiss(event.value)
|
||||||
|
|
||||||
|
def on_input_changed(self, event: Input.Changed) -> None:
|
||||||
|
if not self._on_change:
|
||||||
|
return
|
||||||
|
if self._debounce_timer:
|
||||||
|
self._debounce_timer.stop()
|
||||||
|
value = event.value
|
||||||
|
self._debounce_timer = self.set_timer(
|
||||||
|
self._debounce_seconds,
|
||||||
|
lambda: self._on_change(value),
|
||||||
|
)
|
||||||
|
|
||||||
|
def action_cancel(self) -> None:
|
||||||
|
self.dismiss("")
|
||||||
|
|
||||||
|
def on_unmount(self) -> None:
|
||||||
|
if self._debounce_timer:
|
||||||
|
self._debounce_timer.stop()
|
||||||
|
|||||||
Reference in New Issue
Block a user