Compare commits
5 Commits
0c590cfa82
...
5e3b33570d
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e3b33570d | |||
| 2ced756cc0 | |||
| 1c4017ae0c | |||
| 251a7a26d5 | |||
| 6462c83a21 |
@@ -58,8 +58,8 @@ Please also note that as of now, you need to have [ffmpeg](https://ffmpeg.org/)
|
|||||||
- [x] chapter progress bar in footer
|
- [x] chapter progress bar in footer
|
||||||
- [x] add a control to jump 30s earlier/later
|
- [x] add a control to jump 30s earlier/later
|
||||||
- [x] add control to go to the previous/next chapter
|
- [x] add control to go to the previous/next chapter
|
||||||
|
- [x] save/resume playback of a book from the last position, regardless of which device was used previously
|
||||||
- [ ] mark a book as finished or unfinished
|
- [ ] mark a book as finished or unfinished
|
||||||
- [ ] save/resume playback of a book from the last position, regardless of which device was used previously
|
|
||||||
- [ ] search the marketplace for books
|
- [ ] search the marketplace for books
|
||||||
- [ ] add a book in your wishlist
|
- [ ] add a book in your wishlist
|
||||||
- [ ] get your listening stats from Audible
|
- [ ] get your listening stats from Audible
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ class Auditui(App):
|
|||||||
self.download_manager = (
|
self.download_manager = (
|
||||||
DownloadManager(auth, client) if auth and client else None
|
DownloadManager(auth, client) if auth and client else None
|
||||||
)
|
)
|
||||||
self.playback = PlaybackController(self.update_status)
|
self.playback = PlaybackController(
|
||||||
|
self.update_status, self.library_client)
|
||||||
|
|
||||||
self.all_items: list[dict] = []
|
self.all_items: list[dict] = []
|
||||||
self.current_items: list[dict] = []
|
self.current_items: list[dict] = []
|
||||||
@@ -91,6 +92,7 @@ class Auditui(App):
|
|||||||
|
|
||||||
self.set_interval(1.0, self._check_playback_status)
|
self.set_interval(1.0, self._check_playback_status)
|
||||||
self.set_interval(0.5, self._update_progress)
|
self.set_interval(0.5, self._update_progress)
|
||||||
|
self.set_interval(30.0, self._save_position_periodically)
|
||||||
|
|
||||||
def on_unmount(self) -> None:
|
def on_unmount(self) -> None:
|
||||||
"""Clean up on app exit."""
|
"""Clean up on app exit."""
|
||||||
@@ -375,6 +377,10 @@ class Auditui(App):
|
|||||||
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
|
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
|
||||||
return f"{minutes:02d}:{secs:02d}"
|
return f"{minutes:02d}:{secs:02d}"
|
||||||
|
|
||||||
|
def _save_position_periodically(self) -> None:
|
||||||
|
"""Periodically save playback position."""
|
||||||
|
self.playback.update_position_if_needed()
|
||||||
|
|
||||||
@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."""
|
||||||
|
|||||||
@@ -29,9 +29,11 @@ class DownloadManager:
|
|||||||
self.cache_dir = cache_dir
|
self.cache_dir = cache_dir
|
||||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
self.chunk_size = chunk_size
|
self.chunk_size = chunk_size
|
||||||
self._http_client = httpx.Client(auth=auth, timeout=30.0, follow_redirects=True)
|
self._http_client = httpx.Client(
|
||||||
|
auth=auth, timeout=30.0, follow_redirects=True)
|
||||||
self._download_client = httpx.Client(
|
self._download_client = httpx.Client(
|
||||||
timeout=httpx.Timeout(connect=30.0, read=None, write=30.0, pool=30.0),
|
timeout=httpx.Timeout(connect=30.0, read=None,
|
||||||
|
write=30.0, pool=30.0),
|
||||||
follow_redirects=True,
|
follow_redirects=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ class LibraryClient:
|
|||||||
if not authors and "authors" in item:
|
if not authors and "authors" in item:
|
||||||
authors = item.get("authors", [])
|
authors = item.get("authors", [])
|
||||||
|
|
||||||
author_names = [a.get("name", "") for a in authors if isinstance(a, dict)]
|
author_names = [a.get("name", "")
|
||||||
|
for a in authors if isinstance(a, dict)]
|
||||||
return ", ".join(author_names) or "Unknown"
|
return ", ".join(author_names) or "Unknown"
|
||||||
|
|
||||||
def extract_runtime_minutes(self, item: dict) -> int | None:
|
def extract_runtime_minutes(self, item: dict) -> int | None:
|
||||||
@@ -127,9 +128,84 @@ class LibraryClient:
|
|||||||
percent_complete = listening_status.get("percent_complete", 0)
|
percent_complete = listening_status.get("percent_complete", 0)
|
||||||
|
|
||||||
return bool(is_finished_flag) or (
|
return bool(is_finished_flag) or (
|
||||||
isinstance(percent_complete, (int, float)) and percent_complete >= 100
|
isinstance(percent_complete, (int, float)
|
||||||
|
) and percent_complete >= 100
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_last_position(self, asin: str) -> float | None:
|
||||||
|
"""Get the last playback position for a book in seconds."""
|
||||||
|
try:
|
||||||
|
response = self.client.get(
|
||||||
|
path="1.0/annotations/lastpositions",
|
||||||
|
asins=asin,
|
||||||
|
)
|
||||||
|
annotations = response.get("asin_last_position_heard_annots", [])
|
||||||
|
|
||||||
|
for annot in annotations:
|
||||||
|
if annot.get("asin") != asin:
|
||||||
|
continue
|
||||||
|
|
||||||
|
last_position_heard = annot.get("last_position_heard", {})
|
||||||
|
if not isinstance(last_position_heard, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if last_position_heard.get("status") == "DoesNotExist":
|
||||||
|
return None
|
||||||
|
|
||||||
|
position_ms = last_position_heard.get("position_ms")
|
||||||
|
if position_ms is not None:
|
||||||
|
return float(position_ms) / 1000.0
|
||||||
|
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_content_reference(self, asin: str) -> dict | None:
|
||||||
|
"""Get content reference data including ACR and version."""
|
||||||
|
try:
|
||||||
|
response = self.client.get(
|
||||||
|
path=f"1.0/content/{asin}/metadata",
|
||||||
|
response_groups="content_reference",
|
||||||
|
)
|
||||||
|
content_metadata = response.get("content_metadata", {})
|
||||||
|
content_reference = content_metadata.get("content_reference", {})
|
||||||
|
if isinstance(content_reference, dict):
|
||||||
|
return content_reference
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
content_ref = self._get_content_reference(asin)
|
||||||
|
if not content_ref:
|
||||||
|
return False
|
||||||
|
|
||||||
|
acr = content_ref.get("acr")
|
||||||
|
if not acr:
|
||||||
|
return False
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"acr": acr,
|
||||||
|
"asin": asin,
|
||||||
|
"position_ms": int(position_seconds * 1000),
|
||||||
|
}
|
||||||
|
|
||||||
|
if version := content_ref.get("version"):
|
||||||
|
body["version"] = version
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.client.put(
|
||||||
|
path=f"1.0/lastpositions/{asin}",
|
||||||
|
body=body,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
@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
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from pathlib import Path
|
|||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
from .downloads import DownloadManager
|
from .downloads import DownloadManager
|
||||||
|
from .library import LibraryClient
|
||||||
|
|
||||||
StatusCallback = Callable[[str], None]
|
StatusCallback = Callable[[str], None]
|
||||||
|
|
||||||
@@ -19,8 +20,9 @@ StatusCallback = Callable[[str], None]
|
|||||||
class PlaybackController:
|
class PlaybackController:
|
||||||
"""Manage playback through ffplay."""
|
"""Manage playback through ffplay."""
|
||||||
|
|
||||||
def __init__(self, notify: StatusCallback) -> None:
|
def __init__(self, notify: StatusCallback, library_client: LibraryClient | None = None) -> None:
|
||||||
self.notify = notify
|
self.notify = notify
|
||||||
|
self.library_client = library_client
|
||||||
self.playback_process: subprocess.Popen | None = None
|
self.playback_process: subprocess.Popen | None = None
|
||||||
self.is_playing = False
|
self.is_playing = False
|
||||||
self.is_paused = False
|
self.is_paused = False
|
||||||
@@ -33,6 +35,8 @@ class PlaybackController:
|
|||||||
self.chapters: list[dict] = []
|
self.chapters: list[dict] = []
|
||||||
self.seek_offset: float = 0.0
|
self.seek_offset: float = 0.0
|
||||||
self.activation_hex: str | None = None
|
self.activation_hex: str | None = None
|
||||||
|
self.last_save_time: float = 0.0
|
||||||
|
self.position_save_interval: float = 30.0
|
||||||
|
|
||||||
def start(
|
def start(
|
||||||
self,
|
self,
|
||||||
@@ -98,6 +102,8 @@ class PlaybackController:
|
|||||||
if self.playback_process is None:
|
if self.playback_process is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
self._save_current_position()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if self.playback_process.poll() is None:
|
if self.playback_process.poll() is None:
|
||||||
self.playback_process.terminate()
|
self.playback_process.terminate()
|
||||||
@@ -161,6 +167,7 @@ class PlaybackController:
|
|||||||
self.chapters = []
|
self.chapters = []
|
||||||
self.seek_offset = 0.0
|
self.seek_offset = 0.0
|
||||||
self.activation_hex = None
|
self.activation_hex = None
|
||||||
|
self.last_save_time = 0.0
|
||||||
|
|
||||||
def _validate_playback_state(self, require_paused: bool) -> bool:
|
def _validate_playback_state(self, require_paused: bool) -> bool:
|
||||||
"""Validate playback state before pause/resume operations."""
|
"""Validate playback state before pause/resume operations."""
|
||||||
@@ -230,9 +237,21 @@ class PlaybackController:
|
|||||||
notify("Failed to get activation bytes")
|
notify("Failed to get activation bytes")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
start_position = 0.0
|
||||||
|
if self.library_client:
|
||||||
|
try:
|
||||||
|
last_position = self.library_client.get_last_position(asin)
|
||||||
|
if last_position is not None and last_position > 0:
|
||||||
|
start_position = last_position
|
||||||
|
notify(
|
||||||
|
f"Resuming from {self._format_position(start_position)}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
notify(f"Starting playback of {local_path.name}...")
|
notify(f"Starting playback of {local_path.name}...")
|
||||||
self.current_asin = asin
|
self.current_asin = asin
|
||||||
return self.start(local_path, activation_hex, notify)
|
self.last_save_time = time.time()
|
||||||
|
return self.start(local_path, activation_hex, notify, start_position)
|
||||||
|
|
||||||
def toggle_playback(self) -> bool:
|
def toggle_playback(self) -> bool:
|
||||||
"""Toggle pause/resume state. Returns True if action was taken."""
|
"""Toggle pause/resume state. Returns True if action was taken."""
|
||||||
@@ -475,3 +494,42 @@ class PlaybackController:
|
|||||||
def seek_to_previous_chapter(self) -> bool:
|
def seek_to_previous_chapter(self) -> bool:
|
||||||
"""Seek to the previous chapter. Returns True if action was taken."""
|
"""Seek to the previous chapter. Returns True if action was taken."""
|
||||||
return self.seek_to_chapter("previous")
|
return self.seek_to_chapter("previous")
|
||||||
|
|
||||||
|
def _save_current_position(self) -> None:
|
||||||
|
"""Save the current playback position to Audible."""
|
||||||
|
if not (self.library_client and self.current_asin and self.is_playing):
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.playback_start_time is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
current_position = self.seek_offset + self._get_current_elapsed()
|
||||||
|
if current_position <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.library_client.save_last_position(
|
||||||
|
self.current_asin, current_position)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _format_position(self, seconds: float) -> str:
|
||||||
|
"""Format position in seconds as HH:MM:SS or MM:SS."""
|
||||||
|
total_seconds = int(seconds)
|
||||||
|
hours = total_seconds // 3600
|
||||||
|
minutes = (total_seconds % 3600) // 60
|
||||||
|
secs = total_seconds % 60
|
||||||
|
|
||||||
|
if hours > 0:
|
||||||
|
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
|
||||||
|
return f"{minutes:02d}:{secs:02d}"
|
||||||
|
|
||||||
|
def update_position_if_needed(self) -> None:
|
||||||
|
"""Periodically save position if enough time has passed."""
|
||||||
|
if not (self.is_playing and self.library_client and self.current_asin):
|
||||||
|
return
|
||||||
|
|
||||||
|
current_time = time.time()
|
||||||
|
if current_time - self.last_save_time >= self.position_save_interval:
|
||||||
|
self._save_current_position()
|
||||||
|
self.last_save_time = current_time
|
||||||
|
|||||||
Reference in New Issue
Block a user