Add SQLite library with API, library desktop apps and MobyGames scraper

This commit is contained in:
SirBlob committed 2026-11-02 23:26:31 -05:00
1 parent 6674428346
commit a16444451a
69 files changed
+2988 -1026

No files matched your search

+30 -13
View File
@@ -7,13 +7,13 @@ from textual.binding import Binding
from textual.containers import Horizontal, VerticalScroll
from textual.widgets import DataTable, Static
import book_cache
import books
import goodreads
import goodreads_series
import library_api
from tui.cache_indicator import cache_state, cache_text
from tui.content_pane import ContentPane
from tui.dialogs import ConfirmDialog, FormDialog, FormField
from tui.library_pane import LibraryPane
from tui.series_picker import SeriesPicker
REQUEST_DELAY_SECONDS = 3
@@ -29,6 +29,8 @@ def book_details(book: dict) -> str:
("Published", book.get("published")),
("Genres", ", ".join(book.get("genres") or [])),
("Added", book.get("added")),
("Started", book.get("started")),
("Finished", book.get("finished")),
("Cache", book_cache_state(book)),
]
lines = [f"[b]{escape(book.get('title', ''))}[/b]", escape(", ".join(book.get("authors") or [])), ""]
@@ -41,15 +43,16 @@ def book_details(book: dict) -> str:
def book_cache_state(book: dict) -> str:
has_details = bool(book.get("authors")) or bool(book.get("description"))
return cache_state(has_details, book_cache.is_local_cover(book.get("cover")))
return cache_state(has_details, library_api.is_stored_image(book.get("cover")))
class BooksPane(ContentPane):
class BooksPane(LibraryPane):
BINDINGS = [
Binding("a", "add", "Add"),
Binding("S", "add_series", "Add series"),
Binding("e", "rate", "Rate"),
Binding("s", "shelf", "Shelf"),
Binding("h", "dates", "Dates"),
Binding("d", "delete", "Delete"),
Binding("u", "refresh", "Refresh"),
Binding("U", "refresh_all", "Refresh all"),
@@ -57,7 +60,9 @@ class BooksPane(ContentPane):
Binding("r", "reload", "Reload"),
]
content_file = "books.json"
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.books: dict[str, dict] = {}
def compose(self) -> ComposeResult:
with Horizontal(classes="split-layout"):
@@ -74,14 +79,19 @@ class BooksPane(ContentPane):
table.add_column("Cache", key="cache")
self.reload()
def current_shelves(self) -> dict[str, str]:
return {identifier: book.get("shelf", "read") for identifier, book in self.books.items()}
def book_by_id(self, identifier: str | None) -> dict | None:
return next((book for book in books.load_books() if book.get("goodreadsId") == identifier), None)
return self.books.get(identifier) if identifier else None
def reload(self, focus_key: str | None = None):
table = self.table()
table.clear()
shelf_order = {shelf: index for index, shelf in enumerate(books.SHELVES)}
sorted_books = sorted(books.load_books(), key=lambda book: (shelf_order.get(book.get("shelf"), 99), -(book.get("rating") or -1)))
loaded = self.load_entries(books.load_books)
self.books = {book["goodreadsId"]: book for book in loaded}
sorted_books = sorted(loaded, key=lambda book: (shelf_order.get(book.get("shelf"), 99), -(book.get("rating") or -1)))
for book in sorted_books:
rating = f"{book['rating']}/10" if book.get("rating") is not None else "-"
authors = ", ".join(book.get("authors") or [])
@@ -141,6 +151,13 @@ class BooksPane(ContentPane):
self.app.push_screen(FormDialog("Move to shelf", fields, "Move"), moved)
def action_dates(self):
book = self.book_by_id(self.selected_key())
if not book:
return
identifier = book["goodreadsId"]
self.edit_dates(book["title"], book, lambda started, finished: books.set_dates(identifier, started, finished), identifier)
def action_delete(self):
book = self.book_by_id(self.selected_key())
if not book:
@@ -173,7 +190,7 @@ class BooksPane(ContentPane):
@work(thread=True, exclusive=True, group="refresh")
def refresh_every_book(self):
refreshed = 0
all_books = books.load_books()
all_books = list(self.books.values())
for index, book in enumerate(all_books):
if index:
time.sleep(REQUEST_DELAY_SECONDS)
@@ -190,9 +207,9 @@ class BooksPane(ContentPane):
self.app.call_from_thread(self.notify, f"Refreshed {refreshed} of {len(all_books)} books")
def action_cache_covers(self):
missing = books.books_missing_local_covers()
missing = [book for book in self.books.values() if book.get("coverSource") and not library_api.is_stored_image(book.get("cover"))]
if not missing:
self.notify("Every cover is already saved in static/covers/books")
self.notify("Every cover is already saved in the library")
return
self.notify(f"Saving {len(missing)} covers...")
self.cache_missing_covers(missing)
@@ -202,7 +219,7 @@ class BooksPane(ContentPane):
saved = 0
for book in missing:
try:
books.cache_book_cover(book["goodreadsId"])
books.store_book_cover(book)
except (OSError, ValueError) as error:
self.app.call_from_thread(self.notify, f"{book['title']}: {error}", title="Skipped", severity="warning")
continue
@@ -225,12 +242,12 @@ class BooksPane(ContentPane):
self.notify(f"Adding {len(choices)} books from {series['name']}...")
self.add_series_books(series, choices)
self.app.push_screen(SeriesPicker(series, books.library_shelves()), picked)
self.app.push_screen(SeriesPicker(series, self.current_shelves()), picked)
@work(thread=True, exclusive=True, group="series")
def add_series_books(self, series: dict, choices: dict[str, str]):
added = 0
already_listed = set(books.library_shelves())
already_listed = set(self.current_shelves())
fetched_any = False
for entry in series["books"]:
shelf = choices.get(entry["goodreadsId"])