From f59b3794ea78bb23552ae298907ecd0e09ae56a6 Mon Sep 17 00:00:00 2001 From: th3r00t Date: Tue, 16 Sep 2025 03:09:08 -0400 Subject: [PATCH] Book Display --- .envrc | 1 + flake.nix | 7 + src/__main__.py | 34 ++++- src/libs/absapi.py | 316 ++++++++++++++++++++++++++++++++++++++++--- src/libs/terminal.py | 159 +++++++++++++++++++++- 5 files changed, 494 insertions(+), 23 deletions(-) diff --git a/.envrc b/.envrc index 3550a30..3b11770 100644 --- a/.envrc +++ b/.envrc @@ -1 +1,2 @@ use flake +dotenv diff --git a/flake.nix b/flake.nix index dffeaff..03cf62d 100644 --- a/flake.nix +++ b/flake.nix @@ -60,6 +60,13 @@ } venvVersionWarn + + if [ -f .env ]; then + set -a + source .env + set +a + echo "Loaded .env file" + fi ''; packages = with python.pkgs; [ diff --git a/src/__main__.py b/src/__main__.py index a99a7bd..b1ceee9 100755 --- a/src/__main__.py +++ b/src/__main__.py @@ -1,11 +1,35 @@ #!/usr/bin/env python3 +import curses +import argparse +import sys +from curses import wrapper from .libs.config import Config -from .libs.absapi import ABSApi, Endpoint, ABSResponse +from .libs.absapi import ABSApi, Endpoint, ABSResponse, Library +from .libs.terminal import Terminal, ExitTerminal + +parser = argparse.ArgumentParser() +_: argparse.Action = parser.add_argument("--debug", help="Debug the program", action="store_true") +args = parser.parse_args() config: Config = Config() - -def main() -> None: - pass +# stdscr: curses.window = curses.initscr() +def main(stdscr: curses.window) -> None: + terminal: Terminal = Terminal(stdscr) + Terminal.startup(terminal) if __name__ == "__main__": - main() + if args.debug: + _api = ABSApi() + _response: list[Library]|None = _api.get_libraries() + _id = _response[0].id if _response else "" + _books = _api.get_books(_id) + for book in _books if _books else []: + breakpoint() + print(book.media.metadata.title) + sys.exit(0) + try: + while True: + wrapper(main) + except ExitTerminal: + pass + diff --git a/src/libs/absapi.py b/src/libs/absapi.py index b96a16e..c5d1907 100644 --- a/src/libs/absapi.py +++ b/src/libs/absapi.py @@ -1,25 +1,176 @@ import urllib.request as req -# import json -# from typing import List, dict, Optional +import json +from typing import Any, Optional from enum import Enum - -from dataclasses import dataclass +from datetime import datetime +from dataclasses import dataclass, field from .config import Config + +@dataclass +class Folder: + addedAt: float + fullPath: str + id: str + libraryId: str + +@dataclass +class LibrarySettings: + audiobooksOnly: bool = False + autoScanCronExpression: str|None = None + coverAspectRatio: int = 1 + disableWatcher: bool = False + epubsAllowScriptedContent: bool = False + hideSingleBookSeries: bool = False + markAsFinishedPercentComplete: int|None = None + markAsFinishedTimeRemaining: int = 10 + podcastSearchRegion: str = "" + metadataPrecedence: list[str] = field(default_factory=lambda: [ + "folderStructure", + "audioMetatags", + "nfoFile", + "txtFiles", + "opfFile", + "absMetadata" + ]) + onlyShowLaterBooksInContinueSeries: bool = False + skipMatchingMediaWithAsin: bool = False + skipMatchingMediaWithIsbn: bool = False + +@dataclass +class Library: + createdAt: float + displayOrder: int + folders: list[Folder] + icon: str = "" + id: str = "" + lastScan: float = 0 + lastScanVersion: str = "" + lastUpdate: float = 0 + mediaType: str = "" + name: str = "" + provider: str = "" + settings: LibrarySettings = field(default_factory=LibrarySettings) + +@dataclass +class Metadata: + abridged: bool = False + asin: str|None = None + authorName: str = "" + authorNameLF: str = "" + description: str = "" + explicit: bool = False + genres: list[str] = field(default_factory=list) + isbn: str|None = None + language: str|None = None + narratorName: str = "" + publishedDate: Any|None = None + publishedYear: Any|None = None + publisher: str|None = None + seriesName: str = "" + subtitle: Any|None = None + title: str = "" + titleIgnorePrefix: str = "" + +@dataclass +class Media: + coverPath: str = "" + duration: int = 0 + id: str = "" + metadata: Metadata = field(default_factory=Metadata) + numAudioFiles: int = 0 + numChapters: int = 0 + numTracks: int = 0 + size: int = 0 + tags: list[str] = field(default_factory=list) + + +@dataclass +class Book: + addedAt: int = 0 + birthtimeMs: int = 0 + ctimeMs: int = 0 + folderId: str = "" + id: str = "" + ino: str = "" + isFile: bool = False + isInvalid: bool = False + isMissing: bool = False + libraryId: str = "" + media: Media = field(default_factory=Media) + mediaType: str = "" + mtimeMs: int = 0 + numFiles: int = 0 + oldLibraryItemId: str = "" + path: str = "" + relPath: str = "" + size: int = 0 + updatedAt: int = 0 + + class Endpoint(Enum): - BOOKS = "/api/v1/book" - AUTHORS = "/api/v1/author" - SERIES = "/api/v1/series" - COVERS = "/api/v1/cover" - GENRES = "/api/v1/genre" - LISTS = "/api/v1/list" - USERS = "/api/v1/user" - SETTINGS = "/api/v1/settings" - STATS = "/api/v1/stats" - PLAYBACK_POSITIONS = "/api/v1/playback-position" - REVIEWS = "/api/v1/review" - TAGS = "/api/v1/tag" - PLUGINS = "/api/v1/plugin" + """ + Enum for ABS API endpoints. + https://api.audiobookshelf.org/#endpoints + Parameters: + item_id (str, optional): The ID of the item. Default is None. + episode_id (str, optional): The ID of the episode. Default is None. + DELETEALLITEMS "/api/items/all"" + DELETEITEM DELETE lambda item_id: f"/api/items/{item_id}" + GETITEM GET lambda item_id: f"/api/items/{item_id}" + UPDATEITEM PATCH lambda item_id: f"/api/items/{item_id}/media" + PLAYITEM POST lambda item_id: f"/api/items/{item_id}/play" + PLAYEPISODE POST lambda item_id, episode_id: f"/api/items/{item_id}/play/{episode_id}"" + GETALLCOLLECTIONS GET "/api/collections" + GETCOLLECTION GET lambda item_id: f"/api/collections/{item_id}" + GETPLAYLISTS GET "/api/playlists" + GETPLAYLIST GET lambda item_id: f"/api/playlists/{item_id}" + GETMEDIAPROGRESS GET lambda item_id: f"/api/me/progress/{item_id}"" + UPDATEMEDIAPROGRESS PATCH lambda item_id: f"/api/me/progress/{item_id}" + UPDATEPODCASTPROGRESS PATCH lambda item_id, episode_id: f"/api/me/progress/{item_id}/{episode_id}" + GETITEMSINPROGRESS GET "/api/me/items-in-prorgess" + """ + STATUS = "/status" # GET + PING = "/ping" # GET + HEALTHCHECK = "/healthcheck" # GET + LIBRARY = lambda item_id = None: f"/api/libraries/{item_id if item_id is not None else ''}" # GET + """ + GET Get All Libraries https://api.audiobookshelf.org/#get-all-libraries + POST Create Library https://api.audiobookshelf.org/#libraries + GET LIBRARY Get Specified Library https://api.audiobookshelf.org/#get-a-library + GET LIBRARY/items Get Items https://api.audiobookshelf.org/#get-a-library-39-s-items + GET LIBRARY/episode-downloads Get Podcast Downloads https://api.audiobookshelf.org/#get-a-library-39-s-podcast-episode-downloads + GET LIBRARY/series Get Series https://api.audiobookshelf.org/#get-a-library-39-s-series + GET LIBRARY/collections Get Collections https://api.audiobookshelf.org/#get-a-library-39-s-collections + GET LIBRARY/playlists Get User Playlists https://api.audiobookshelf.org/#get-a-library-39-s-user-playlists + GET LIBRARY/personalized Get Personalized View https://api.audiobookshelf.org/#get-a-library-39-s-personalized-view + GET LIBRARY/filterdata Get Filter Data https://api.audiobookshelf.org/#get-a-library-39-s-filter-data + GET LIBRARY/search? Search https://api.audiobookshelf.org/#search-a-library + GET LIBRARY/stats Get Stats https://api.audiobookshelf.org/#get-a-library-39-s-stats + GET LIBRARY/authors Get Authors https://api.audiobookshelf.org/#get-a-library-39-s-authors + GET LIBRARY/matchall Get All Items + POST LIBRARY/scan Scan Folders + GET LIBRARYrecent-episodes Get Podcasts Newest Unfinished Episodes https://api.audiobookshelf.org/#get-a-library-39-s-recent-episodes + DELETE LIBRARY/issues Delete Items W/Issues https://api.audiobookshelf.org/#remove-a-library-39-s-items-with-issues + PATCH LIBRARY Update Library https://api.audiobookshelf.org/#update-a-library + DELETE LIBRARY Delete Library https://api.audiobookshelf.org/#delete-a-library + POST LIBRARY/order Reorder Libraries https://api.audiobookshelf.org/#reorder-library-list + """ + DELETEALLITEMS = "/api/items/all"# DELETE Delete all items from DB https://api.audiobookshelf.org/#library-items + DELETEITEM = lambda item_id: f"/api/items/{item_id}" # /api/items/ DELETE Delete Item https://api.audiobookshelf.org/#delete-a-library-item + GETITEM = lambda item_id: f"/api/items/{item_id}" # /api/items/ GET Get Item https://api.audiobookshelf.org/#get-a-library-item + UPDATEITEM = lambda item_id: f"/api/items/{item_id}/media" # PATCH Updates an items media & Return https://api.audiobookshelf.org/#update-a-library-item-39-s-media + PLAYITEM = lambda item_id: f"/api/items/{item_id}/play" # POST https://api.audiobookshelf.org/#play-a-library-item-or-podcast-episode + PLAYEPISODE = lambda item_id, episode_id: f"/api/items/{item_id}/play/{episode_id}" # POST https://api.audiobookshelf.org/#play-a-library-item-or-podcast-episode + GETALLCOLLECTIONS = "/api/collections" # GET Get All Collections https://api.audiobookshelf.org/#get-all-collections + GETCOLLECTION = lambda item_id: f"/api/collections/{item_id}" # GET Get Specified Collection https://api.audiobookshelf.org/#get-a-collectionA + GETPLAYLISTS = "/api/playlists" # GET Get All Playlists https://api.audiobookshelf.org/#get-all-user-playlists + GETPLAYLIST = lambda item_id: f"/api/playlists/{item_id}" # GET Get Specified Playlist https://api.audiobookshelf.org/#get-a-playlist + GETMEDIAPROGRESS = lambda item_id: f"/api/me/progress/{item_id}" # GET Gets progress for indicated library item id https://api.audiobookshelf.org/#get-a-media-progress + UPDATEMEDIAPROGRESS = lambda item_id: f"/api/me/progress/{item_id}" # PATCH Updates progress for indicated library item id https://api.audiobookshelf.org/#update-media-progress + UPDATEPODCASTPROGRESS = lambda item_id, episode_id: f"/api/me/progress/{item_id}/{episode_id}" # PATCH {libraryid} {EpisodeID} Updates progress for indicated podcast episode id https://api.audiobookshelf.org/#create-update-media-progress + GETITEMSINPROGRESS = "/api/me/items-in-prorgess" # GET Get Items in Progress https://api.audiobookshelf.org/#get-library-items-in-progress + @dataclass class ABSResponse: @@ -46,3 +197,134 @@ class ABSApi: ABSResponse: The response from the API. """ return f"{self.base_url}{endpoint.value}" + + def request(self, endpoint:str, method:str="GET", data:Any|None=None): + """ + Build a request for the ABS API. + + Args: + endpoint (Endpoint): The API endpoint to request. + method (str): The HTTP method to use. Default is "GET". + data (Any, optional): The data to send with the request. Default is None. + + Returns: + req.Request: The built request. + """ + url = f"{self.base_url}{endpoint}" + headers = { + "Authorization": f"Bearer {Config().abs_api_key}", + "Accept": "application/json", + "Content-Type": "application/json" + } + request: req.Request = req.Request(url=url, headers=headers, method=method, data=data) + return req.urlopen(request) + + def get_libraries(self) -> list[Library]|None: + """ + Get all libraries from the ABS API. + + Returns: + list[Library]: A list of libraries. + """ + endpoint = Endpoint.LIBRARY() + _r = self.request(endpoint) + libraries: list[Library] = [] + folders: list[Folder] = [] + if _r.status == 200: + data = _r.read().decode("utf-8") + j = json.loads(str(data)) + for lib in j["libraries"]: + for folder in lib["folders"]: + f = Folder( + addedAt=folder["addedAt"], + fullPath=folder["fullPath"], + id=folder["id"], + libraryId=folder["libraryId"] + ) + folders.append(f) + settings = LibrarySettings(**lib["settings"]) + library = Library( + createdAt=lib["createdAt"], + displayOrder=lib["displayOrder"], + folders=folders, + icon=lib["icon"], + id=lib["id"], + lastScan=lib["lastScan"], + lastScanVersion=lib["lastScanVersion"], + lastUpdate=lib["lastUpdate"], + mediaType=lib["mediaType"], + name=lib["name"], + provider=lib["provider"], + settings=settings + ) + libraries.append(library) + return libraries + + def get_books(self, library_id: str) -> list[Book]|None: + """ + Get all books from a specific library. + + Args: + library_id (str): The ID of the library. + Returns: + ABSResponse: The response from the API. + """ + endpoint = Endpoint.LIBRARY(library_id) + "/items" + _r = self.request(endpoint) + books: list[Book] = [] + if _r.status == 200: + data = _r.read().decode("utf-8") + j = json.loads(str(data)) + data = None + for book in j['results']: + _book: Book = Book() + _book.addedAt = book.get("addedAt", 0) + _book.birthtimeMs = book.get("birthtimeMs", 0) + _book.ctimeMs = book.get("ctimeMs", 0) + _book.folderId = book.get("folderId", "") + _book.id = book.get("id", "") + _book.ino = book.get("ino", "") + _book.isFile = book.get("isFile", False) + _book.isInvalid = book.get("isInvalid", False) + _book.isMissing = book.get("isMissing", False) + _book.libraryId = book.get("libraryId", "") + _media: Media = Media() + _media.coverPath = book["media"].get("coverPath", "") + _media.duration = book["media"].get("duration", 0) + _media.id = book["media"].get("id", "") + _metadata: Metadata = Metadata() + _metadata.abridged = book["media"]["metadata"].get("abridged", False) + _metadata.asin = book["media"]["metadata"].get("asin", None) + _metadata.authorName = book["media"]["metadata"].get("authorName", "") + _metadata.authorNameLF = book["media"]["metadata"].get("authorNameLF", "") + _metadata.description = book["media"]["metadata"].get("description", "") + _metadata.explicit = book["media"]["metadata"].get("explicit", False) + _metadata.genres = book["media"]["metadata"].get("genres", []) + _metadata.isbn = book["media"]["metadata"].get("isbn", None) + _metadata.language = book["media"]["metadata"].get("language", None) + _metadata.narratorName = book["media"]["metadata"].get("narratorName", "") + _metadata.publishedDate = book["media"]["metadata"].get("publishedDate", None) + _metadata.publishedYear = book["media"]["metadata"].get("publishedYear", None) + _metadata.publisher = book["media"]["metadata"].get("publisher", None) + _metadata.seriesName = book["media"]["metadata"].get("seriesName", "") + _metadata.subtitle = book["media"]["metadata"].get("subtitle", None) + _metadata.title = book["media"]["metadata"].get("title", "") + _metadata.titleIgnorePrefix = book["media"]["metadata"].get("titleIgnorePrefix", "") + _media.metadata = _metadata + _media.numAudioFiles = book["media"].get("numAudioFiles", 0) + _media.numChapters = book["media"].get("numChapters", 0) + _media.numTracks = book["media"].get("numTracks", 0) + _media.size = book["media"].get("size", 0) + _media.tags = book["media"].get("tags", []) + _book.media = _media + _book.mediaType = book.get("mediaType", "") + _book.mtimeMs = book.get("mtimeMs", 0) + _book.numFiles = book.get("numFiles", 0) + _book.oldLibraryItemId = book.get("oldLibraryItemId", "") + _book.path = book.get("path", "") + _book.relPath = book.get("relPath", "") + _book.size = book.get("size", 0) + _book.updatedAt = book.get("updatedAt", 0) + books.append(_book) + return books + return None diff --git a/src/libs/terminal.py b/src/libs/terminal.py index faad489..f148234 100644 --- a/src/libs/terminal.py +++ b/src/libs/terminal.py @@ -1,3 +1,160 @@ import curses +from .absapi import ABSApi, Library, Book, Media, Metadata +from typing import Optional -TERMINAL_HEIGHT, TERMINAL_WIDTH = curses.window().getmaxyx() +class ExitTerminal(Exception): + pass + +class Terminal: + def __init__(self, stdscr: curses.window, running: bool = True) -> None: + self.stdscr: curses.window = stdscr + self.running: bool = running + self.api: ABSApi = ABSApi() + self.height: int = self.stdscr.getmaxyx()[0] + self.width: int = self.stdscr.getmaxyx()[1] + self.hSep: str = "-" * self.width + self.title: str = "Audiobookshelf CLI" + self.main_content_begin_x: int = 0 + self.main_content_begin_y: int = 3 + self.main_content_height: int = self.height - 5 + self.main_content_width: int = self.width + self.main_menu: list[str] = [ + "F1: View Libraries", + "F2: Search Books", + "F3: Now Playing", + "F4: Settings", + "F5: Help", + "F6: Exit", + ] + self.active_window: str = "library" + self.playing: bool = False + + def alignC(self, text: str) -> int: + return (self.width // 2) - (len(text) // 2) + + def alignR(self, text: str) -> int: + return (self.height // 2) - (len(text) // 2) + + def startup(self) -> None: + self.stdscr.clear() + self.menu() + self.stdscr.refresh() + self.library_window() + self.footer() + self.key_handler() + + def menu(self) -> None: + self.stdscr.addstr(0, self.alignC(self.title), self.title) + _pos: int = 1 + _chars: int = 0 + _menu_start_pos: int = 0 + for item in self.main_menu: + _chars += len(item) + 2 + _menu_start_pos = (self.width // 2) - (_chars // 2) + _pos = _menu_start_pos + for item in self.main_menu: + self.stdscr.addstr(1, _pos + 1, item) + _pos += len(item) + 2 + self.stdscr.addstr(2, 0, self.hSep) + + def footer(self, text: Optional[str] = None) -> None: + _win = self.new_footer_window() + if text is None: + text = "Audiobookshelf CLI by: th3r00t > github.com/th3r00t/abstui" + _win.addstr(0, 0, self.hSep) + _win.addstr(1, self.alignC(text), text) + _win.refresh() + + def new_main_window(self) -> curses.window: + return curses.newwin(self.main_content_height, self.main_content_width, + self.main_content_begin_y, self.main_content_begin_x) + + def new_main_pad(self, item_count: int) -> curses.window: + return curses.newpad(item_count, self.main_content_width) + + def new_footer_window(self) -> curses.window: + return curses.newwin(self.main_content_height, self.main_content_width, + self.main_content_height + 3, 0) + + def library_window(self) -> None: + _y: int = 0 + libraries: list[Library]|None = self.api.get_libraries() + books: list[Book]|None = self.api.get_books(libraries[0].id) if libraries else None + if books is not None: + _pad: curses.window = self.new_main_pad(len(books) if libraries else 1) + for book in books: + _pad.addstr(_y, 0, f"- {book.media.metadata.title}\t {book.media.metadata.authorName} \t {book.media.duration}") + _y += 1 + _pad.refresh(0, 0, self.main_content_begin_y, self.main_content_begin_x, + self.main_content_begin_y + self.main_content_height - 1, + self.main_content_begin_x + self.main_content_width - 1) + else: + self.help_window("No books found in the library.") + self.active_window = "library" + + def search_window(self) -> None: + self.active_window = "search" + _win: curses.window = self.new_main_window() + _win.addstr(0, 0, "Search Books - Feature not implemented yet.") + _win.refresh() + + def now_playing_window(self) -> None: + self.active_window = "now_playing" + _win: curses.window = self.new_main_window() + _win.addstr(0, 0, "Now Playing - Feature not implemented yet.") + _win.refresh() + + def settings_window(self) -> None: + self.active_window = "settings" + _win: curses.window = self.new_main_window() + _win.addstr(0, 0, "Settings - Feature not implemented yet.") + _win.refresh() + + def help_window(self, error: str|None) -> None: + self.active_window = "help" + _win: curses.window = self.new_main_window() + help_text: list[str] = [ + "Help Menu", + "", + "F1: View Libraries - Displays the list of available libraries.", + "F2: Search Books - Search for books in the libraries.", + "F3: Now Playing - View the currently playing book.", + "F4: Settings - Configure application settings.", + "F5: Help - Display this help menu.", + "F6: Exit - Exit the application.", + "", + f"Error: {error}" if error else "", + ] + _y: int = 0 + for line in help_text: + _win.addstr(_y, 0, line) + _y += 1 + _win.refresh() + self.key_handler() + + def play_toggle(self) -> None: + # Placeholder for play/pause functionality + if self.playing: + self.playing = False + else: + self.playing = True + self.footer(f"Playing {self.playing} - Feature not implemented yet.") + + def key_handler(self) -> None: + while True: + key = self.stdscr.getch() + # Handle Menu Shortcuts + if key == curses.KEY_F1: + self.library_window() + elif key == curses.KEY_F2: + self.search_window() + elif key == curses.KEY_F3: + self.now_playing_window() + elif key == curses.KEY_F4: + self.settings_window() + elif key == curses.KEY_F5: + self.help_window(None) + elif key == curses.KEY_F6: + raise ExitTerminal() + elif key == ord(' '): + self.play_toggle()