Compare commits
3 Commits
b9f147c3b3
...
e663401151
| Author | SHA1 | Date | |
|---|---|---|---|
| e663401151 | |||
| 78dc8ed4a0 | |||
| 2d31c8d7a2 |
35
README.md
35
README.md
@@ -26,22 +26,23 @@ Please also note that as of now, you need to have [ffmpeg](https://ffmpeg.org/)
|
|||||||
|
|
||||||
### Bindings
|
### Bindings
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
| ------------ | -------------------------- |
|
| ------------ | --------------------------- |
|
||||||
| `?` | Show help screen |
|
| `?` | Show help screen |
|
||||||
| `n` | Sort by name |
|
| `n` | Sort by name |
|
||||||
| `p` | Sort by progress |
|
| `p` | Sort by progress |
|
||||||
| `a` | Show all/unfinished |
|
| `a` | Show all/unfinished |
|
||||||
| `enter` | Play the selected book |
|
| `enter` | Play the selected book |
|
||||||
| `space` | Pause/resume the playback |
|
| `space` | Pause/resume the playback |
|
||||||
| `left` | Seek backward 30 seconds |
|
| `left` | Seek backward 30 seconds |
|
||||||
| `right` | Seek forward 30 seconds |
|
| `right` | Seek forward 30 seconds |
|
||||||
| `ctrl+left` | Go to the previous chapter |
|
| `ctrl+left` | Go to the previous chapter |
|
||||||
| `ctrl+right` | Go to the next chapter |
|
| `ctrl+right` | Go to the next chapter |
|
||||||
| `up` | Increase playback speed |
|
| `up` | Increase playback speed |
|
||||||
| `down` | Decrease playback speed |
|
| `down` | Decrease playback speed |
|
||||||
| `d` | Download/delete from cache |
|
| `f` | Mark as finished/unfinished |
|
||||||
| `q` | Quit the application |
|
| `d` | Download/delete from cache |
|
||||||
|
| `q` | Quit the application |
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
@@ -57,7 +58,7 @@ Please also note that as of now, you need to have [ffmpeg](https://ffmpeg.org/)
|
|||||||
- [x] download/remove a book in the cache without having to play it
|
- [x] download/remove a book in the cache without having to play it
|
||||||
- [x] add a help screen with all the keybindings
|
- [x] add a help screen with all the keybindings
|
||||||
- [x] increase/decrease reading speed
|
- [x] increase/decrease reading speed
|
||||||
- [ ] mark a book as finished or unfinished
|
- [x] mark a book as finished or unfinished
|
||||||
- [ ] get your stats in a separated pane
|
- [ ] get your stats in a separated pane
|
||||||
- [ ] filter books on views
|
- [ ] filter books on views
|
||||||
- [ ] search in your book library
|
- [ ] search in your book library
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ class Auditui(App):
|
|||||||
("ctrl+right", "next_chapter", "Next chapter"),
|
("ctrl+right", "next_chapter", "Next chapter"),
|
||||||
("up", "increase_speed", "Increase speed"),
|
("up", "increase_speed", "Increase speed"),
|
||||||
("down", "decrease_speed", "Decrease speed"),
|
("down", "decrease_speed", "Decrease speed"),
|
||||||
|
("f", "toggle_finished", "Mark finished/unfinished"),
|
||||||
("d", "toggle_download", "Download/Delete"),
|
("d", "toggle_download", "Download/Delete"),
|
||||||
("q", "quit", "Quit"),
|
("q", "quit", "Quit"),
|
||||||
]
|
]
|
||||||
@@ -310,6 +311,60 @@ class Auditui(App):
|
|||||||
if not self.playback.decrease_speed():
|
if not self.playback.decrease_speed():
|
||||||
self._no_playback_message()
|
self._no_playback_message()
|
||||||
|
|
||||||
|
def action_toggle_finished(self) -> None:
|
||||||
|
"""Toggle finished/unfinished status for the selected book."""
|
||||||
|
if not self.library_client:
|
||||||
|
self.update_status("Library client not available")
|
||||||
|
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
|
||||||
|
|
||||||
|
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_finished_async(asin)
|
||||||
|
|
||||||
|
@work(exclusive=True, thread=True)
|
||||||
|
def _toggle_finished_async(self, asin: str) -> None:
|
||||||
|
"""Toggle finished/unfinished status asynchronously."""
|
||||||
|
if not self.library_client:
|
||||||
|
return
|
||||||
|
|
||||||
|
selected_item = None
|
||||||
|
for item in self.current_items:
|
||||||
|
if self.library_client.extract_asin(item) == asin:
|
||||||
|
selected_item = item
|
||||||
|
break
|
||||||
|
|
||||||
|
if not selected_item:
|
||||||
|
return
|
||||||
|
|
||||||
|
is_currently_finished = self.library_client.is_finished(selected_item)
|
||||||
|
|
||||||
|
if is_currently_finished:
|
||||||
|
success = self.library_client.mark_as_unfinished(
|
||||||
|
asin, selected_item)
|
||||||
|
message = "Marked as unfinished" if success else "Failed to mark as unfinished"
|
||||||
|
else:
|
||||||
|
success = self.library_client.mark_as_finished(asin, selected_item)
|
||||||
|
message = "Marked as finished" if success else "Failed to mark as finished"
|
||||||
|
|
||||||
|
self.call_from_thread(self.update_status, message)
|
||||||
|
if success:
|
||||||
|
self.call_from_thread(self.fetch_library)
|
||||||
|
|
||||||
def _no_playback_message(self) -> None:
|
def _no_playback_message(self) -> None:
|
||||||
"""Show message when no playback is active."""
|
"""Show message when no playback is active."""
|
||||||
self.update_status("No playback active. Press Enter to play a book.")
|
self.update_status("No playback active. Press Enter to play a book.")
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class LibraryClient:
|
|||||||
|
|
||||||
def __init__(self, client: audible.Client) -> None:
|
def __init__(self, client: audible.Client) -> None:
|
||||||
self.client = client
|
self.client = client
|
||||||
|
self._saved_positions: dict[str, float] = {}
|
||||||
|
|
||||||
def fetch_all_items(self, on_progress: ProgressCallback | None = None) -> list:
|
def fetch_all_items(self, on_progress: ProgressCallback | None = None) -> list:
|
||||||
"""Fetch all library items from the API."""
|
"""Fetch all library items from the API."""
|
||||||
@@ -175,9 +176,9 @@ class LibraryClient:
|
|||||||
except (OSError, ValueError, KeyError):
|
except (OSError, ValueError, KeyError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def save_last_position(self, asin: str, position_seconds: float) -> bool:
|
def _update_position(self, asin: str, position_seconds: float) -> bool:
|
||||||
"""Save the last playback position for a book."""
|
"""Update the playback position for a book."""
|
||||||
if position_seconds <= 0:
|
if position_seconds < 0:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
content_ref = self._get_content_reference(asin)
|
content_ref = self._get_content_reference(asin)
|
||||||
@@ -206,6 +207,12 @@ class LibraryClient:
|
|||||||
except (OSError, ValueError, KeyError):
|
except (OSError, ValueError, KeyError):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def save_last_position(self, asin: str, position_seconds: float) -> bool:
|
||||||
|
"""Save the last playback position for a book."""
|
||||||
|
if position_seconds <= 0:
|
||||||
|
return False
|
||||||
|
return self._update_position(asin, position_seconds)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def format_duration(
|
def format_duration(
|
||||||
value: int | None, unit: str = "minutes", default_none: str | None = None
|
value: int | None, unit: str = "minutes", default_none: str | None = None
|
||||||
@@ -228,6 +235,62 @@ class LibraryClient:
|
|||||||
|
|
||||||
return " ".join(parts) if parts else default_none
|
return " ".join(parts) if parts else default_none
|
||||||
|
|
||||||
|
def _get_total_duration(self, asin: str, item: dict | None = None) -> float | None:
|
||||||
|
"""Get total duration in seconds, trying item data first, then API."""
|
||||||
|
if item:
|
||||||
|
duration = self._get_total_duration_from_item(item)
|
||||||
|
if duration:
|
||||||
|
return duration
|
||||||
|
return self._get_total_duration_from_api(asin)
|
||||||
|
|
||||||
|
def mark_as_finished(self, asin: str, item: dict | None = None) -> bool:
|
||||||
|
"""Mark a book as finished (100% complete) by setting position near end."""
|
||||||
|
current_position = self.get_last_position(asin)
|
||||||
|
if current_position and current_position > 0:
|
||||||
|
self._saved_positions[asin] = current_position
|
||||||
|
|
||||||
|
total_duration_seconds = self._get_total_duration(asin, item)
|
||||||
|
if total_duration_seconds and total_duration_seconds > 0:
|
||||||
|
position_seconds = max(0, total_duration_seconds - 10)
|
||||||
|
else:
|
||||||
|
position_seconds = 999999
|
||||||
|
|
||||||
|
return self._update_position(asin, position_seconds)
|
||||||
|
|
||||||
|
def _get_total_duration_from_api(self, asin: str) -> float | None:
|
||||||
|
"""Get total duration in seconds from API."""
|
||||||
|
try:
|
||||||
|
response = self.client.get(
|
||||||
|
path=f"1.0/content/{asin}/metadata",
|
||||||
|
response_groups="runtime",
|
||||||
|
)
|
||||||
|
content_metadata = response.get("content_metadata", {})
|
||||||
|
runtime = content_metadata.get("runtime", {})
|
||||||
|
if isinstance(runtime, dict):
|
||||||
|
runtime_ms = runtime.get("runtime_ms")
|
||||||
|
if runtime_ms:
|
||||||
|
return float(runtime_ms) / 1000.0
|
||||||
|
return None
|
||||||
|
except (OSError, ValueError, KeyError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def mark_as_unfinished(self, asin: str, item: dict | None = None) -> bool:
|
||||||
|
"""Mark a book as unfinished by restoring saved position."""
|
||||||
|
saved_position = self._saved_positions.pop(asin, None)
|
||||||
|
if saved_position is None:
|
||||||
|
saved_position = self.get_last_position(asin)
|
||||||
|
if saved_position is None or saved_position <= 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return self._update_position(asin, saved_position)
|
||||||
|
|
||||||
|
def _get_total_duration_from_item(self, item: dict) -> float | None:
|
||||||
|
"""Get total duration in seconds from library item data."""
|
||||||
|
runtime_minutes = self.extract_runtime_minutes(item)
|
||||||
|
if runtime_minutes:
|
||||||
|
return float(runtime_minutes * 60)
|
||||||
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def format_time(seconds: float) -> str:
|
def format_time(seconds: float) -> str:
|
||||||
"""Format seconds as HH:MM:SS or MM:SS."""
|
"""Format seconds as HH:MM:SS or MM:SS."""
|
||||||
|
|||||||
Reference in New Issue
Block a user