Book Display

This commit is contained in:
2025-09-16 03:09:08 -04:00
parent 2e136eeab0
commit f59b3794ea
5 changed files with 494 additions and 23 deletions

1
.envrc
View File

@@ -1 +1,2 @@
use flake
dotenv

View File

@@ -60,6 +60,13 @@
}
venvVersionWarn
if [ -f .env ]; then
set -a
source .env
set +a
echo "Loaded .env file"
fi
'';
packages = with python.pkgs; [

View File

@@ -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

View File

@@ -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?<q> 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/<ID> DELETE Delete Item https://api.audiobookshelf.org/#delete-a-library-item
GETITEM = lambda item_id: f"/api/items/{item_id}" # /api/items/<ID> 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

View File

@@ -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()