Add SQLite library with API, library desktop apps and MobyGames scraper
This commit is contained in:
1 parent
6674428346
commit
a16444451a
69 files changed
+2988
-1026
No files matched your search
@@ -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"])
|
||||
|
||||
@@ -7,6 +7,7 @@ from textual.widgets import DataTable
|
||||
|
||||
class ContentPane(Vertical):
|
||||
content_file = ""
|
||||
reload_message = "Reloaded from disk"
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
@@ -40,7 +41,7 @@ class ContentPane(Vertical):
|
||||
|
||||
def action_reload(self):
|
||||
self.reload(self.selected_key())
|
||||
self.notify("Reloaded from disk")
|
||||
self.notify(self.reload_message)
|
||||
|
||||
def run_now(self, task: Callable[[], Any], message: str | Callable[[Any], str], focus_key: str | None = None):
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
from typing import Any, Callable
|
||||
|
||||
import library_api
|
||||
from dates import check_date
|
||||
from tui.content_pane import ContentPane
|
||||
from tui.dialogs import FormDialog, FormField
|
||||
|
||||
|
||||
class LibraryPane(ContentPane):
|
||||
reload_message = "Reloaded from the library"
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.border_title = f"[b]kitty[/b] {library_api.api_url()}/api/library"
|
||||
|
||||
def load_entries(self, load: Callable[[], list[dict]]) -> list[dict]:
|
||||
try:
|
||||
return load()
|
||||
except (OSError, ValueError) as error:
|
||||
self.notify(str(error), title="Library", severity="error", timeout=10)
|
||||
return []
|
||||
|
||||
def edit_dates(self, title: str, entry: dict, save: Callable[[str | None, str | None], Any], focus_key: str):
|
||||
fields = [
|
||||
FormField("started", "Started (YYYY-MM-DD, empty to clear)", value=entry.get("started", ""), placeholder="2026-01-31"),
|
||||
FormField("finished", "Finished (YYYY-MM-DD, empty to clear)", value=entry.get("finished", ""), placeholder="2026-02-14"),
|
||||
]
|
||||
|
||||
def saved(values: dict | None):
|
||||
if values is not None:
|
||||
self.run_now(lambda: save(check_date(values["started"]), check_date(values["finished"])), f"Saved dates for {title}", focus_key)
|
||||
|
||||
self.app.push_screen(FormDialog(f"History for {title}", fields), saved)
|
||||
@@ -8,10 +8,9 @@ from textual.widgets import DataTable
|
||||
|
||||
import imdb
|
||||
import media
|
||||
import media_cache
|
||||
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
|
||||
|
||||
REQUEST_DELAY_SECONDS = 0.5
|
||||
STATUS_STYLES = {"watching": "blue", "watched": "green", "planning": "magenta"}
|
||||
@@ -21,25 +20,25 @@ def rating_text(item: dict) -> str:
|
||||
return f"{item['rating']}/10" if item.get("rating") is not None else "-"
|
||||
|
||||
|
||||
class MediaPane(ContentPane):
|
||||
class MediaPane(LibraryPane):
|
||||
BINDINGS = [
|
||||
Binding("a", "add", "Add"),
|
||||
Binding("e", "rate", "Rate"),
|
||||
Binding("s", "status", "Status"),
|
||||
Binding("g", "collection", "Group"),
|
||||
Binding("h", "dates", "Dates"),
|
||||
Binding("d", "delete", "Delete"),
|
||||
Binding("u", "refresh", "Refresh"),
|
||||
Binding("C", "cache_all", "Cache all"),
|
||||
Binding("r", "reload", "Reload"),
|
||||
]
|
||||
|
||||
content_file = "about.json"
|
||||
|
||||
def __init__(self, kind: str, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.kind = kind
|
||||
self.label = media.MEDIA_KINDS[kind]["label"]
|
||||
self.border_subtitle = media.MEDIA_KINDS[kind]["key"]
|
||||
self.items: dict[str, dict] = {}
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield DataTable(cursor_type="row", zebra_stripes=True)
|
||||
@@ -51,6 +50,7 @@ class MediaPane(ContentPane):
|
||||
table.add_column("Title", key="title")
|
||||
table.add_column("Group", key="collection")
|
||||
table.add_column("Year", key="year")
|
||||
table.add_column("Finished", key="finished")
|
||||
table.add_column("Cache", key="cache")
|
||||
table.add_column("IMDb", key="imdb")
|
||||
self.reload()
|
||||
@@ -58,30 +58,29 @@ class MediaPane(ContentPane):
|
||||
def reload(self, focus_key: str | None = None):
|
||||
table = self.table()
|
||||
table.clear()
|
||||
stored = media_cache.load_details()
|
||||
for item in media.list_items(self.kind):
|
||||
identifier = imdb.imdb_id(item.get("url", "")) or item.get("name", "")
|
||||
items = self.load_entries(lambda: media.list_items(self.kind))
|
||||
self.items = {item["imdbId"]: item for item in items}
|
||||
for item in items:
|
||||
status = media.status_of(item)
|
||||
details = stored.get(identifier)
|
||||
state = cache_state(bool(details), bool(details) and media_cache.has_local_poster(identifier, details))
|
||||
state = cache_state(media.has_details(item), media.has_stored_poster(item))
|
||||
table.add_row(
|
||||
Text(status, style=STATUS_STYLES[status]),
|
||||
rating_text(item),
|
||||
item.get("name", ""),
|
||||
item.get("collection", ""),
|
||||
(details or {}).get("year") or "-",
|
||||
item["details"].get("year") or "-",
|
||||
item.get("finished") or "-",
|
||||
cache_text(state),
|
||||
identifier,
|
||||
key=identifier,
|
||||
item["imdbId"],
|
||||
key=item["imdbId"],
|
||||
)
|
||||
self.restore_cursor(focus_key)
|
||||
|
||||
def selected_item(self) -> dict | None:
|
||||
identifier = self.selected_key()
|
||||
return next((item for item in media.list_items(self.kind) if imdb.imdb_id(item.get("url", "")) == identifier), None)
|
||||
return self.items.get(self.selected_key())
|
||||
|
||||
def collection_hint(self) -> str:
|
||||
names = media.collection_names(self.kind)
|
||||
names = sorted({item["collection"] for item in self.items.values() if item.get("collection")})
|
||||
return f"Existing: {', '.join(names)}" if names else "e.g. Star Wars"
|
||||
|
||||
def action_add(self):
|
||||
@@ -113,7 +112,7 @@ class MediaPane(ContentPane):
|
||||
item = self.selected_item()
|
||||
if not item:
|
||||
return
|
||||
identifier = imdb.imdb_id(item["url"])
|
||||
identifier = item["imdbId"]
|
||||
current = "" if item.get("rating") is None else str(item["rating"])
|
||||
fields = [FormField("rating", f"Rating for {item['name']} (empty to clear)", kind="rating", value=current)]
|
||||
|
||||
@@ -127,7 +126,7 @@ class MediaPane(ContentPane):
|
||||
item = self.selected_item()
|
||||
if not item:
|
||||
return
|
||||
identifier = imdb.imdb_id(item["url"])
|
||||
identifier = item["imdbId"]
|
||||
fields = [FormField("status", f"Status for {item['name']}", kind="select", options=media.STATUSES, value=media.status_of(item))]
|
||||
|
||||
def changed(values: dict | None):
|
||||
@@ -141,7 +140,7 @@ class MediaPane(ContentPane):
|
||||
item = self.selected_item()
|
||||
if not item:
|
||||
return
|
||||
identifier = imdb.imdb_id(item["url"])
|
||||
identifier = item["imdbId"]
|
||||
fields = [FormField("collection", f"Group for {item['name']} (empty to remove)", value=item.get("collection", ""), placeholder=self.collection_hint())]
|
||||
|
||||
def grouped(values: dict | None):
|
||||
@@ -152,32 +151,39 @@ class MediaPane(ContentPane):
|
||||
|
||||
self.app.push_screen(FormDialog("Group titles", fields), grouped)
|
||||
|
||||
def action_delete(self):
|
||||
identifier = self.selected_key()
|
||||
if not identifier:
|
||||
def action_dates(self):
|
||||
item = self.selected_item()
|
||||
if not item:
|
||||
return
|
||||
name = self.table().get_cell(identifier, "title")
|
||||
identifier = item["imdbId"]
|
||||
self.edit_dates(item["name"], item, lambda started, finished: media.set_dates(self.kind, identifier, started, finished), identifier)
|
||||
|
||||
def action_delete(self):
|
||||
item = self.selected_item()
|
||||
if not item:
|
||||
return
|
||||
identifier = item["imdbId"]
|
||||
|
||||
def confirmed(should_delete: bool | None):
|
||||
if should_delete:
|
||||
self.run_now(lambda: media.remove_item(self.kind, identifier), f"Removed {name}")
|
||||
self.run_now(lambda: media.remove_item(self.kind, identifier), f"Removed {item['name']}")
|
||||
|
||||
self.app.push_screen(ConfirmDialog(f"Remove {name}?"), confirmed)
|
||||
self.app.push_screen(ConfirmDialog(f"Remove {item['name']}?"), confirmed)
|
||||
|
||||
def action_refresh(self):
|
||||
item = self.selected_item()
|
||||
if not item:
|
||||
return
|
||||
identifier = imdb.imdb_id(item["url"])
|
||||
identifier = item["imdbId"]
|
||||
|
||||
def refreshed(details: dict):
|
||||
def refreshed(_saved: dict):
|
||||
self.reload(identifier)
|
||||
self.notify(f"Saved details and poster for {item['name']}")
|
||||
|
||||
self.run_in_background(f"Looking up {item['name']} on OMDb...", lambda: media.refresh_details(identifier), refreshed)
|
||||
|
||||
def action_cache_all(self):
|
||||
missing = media.missing_details(self.kind)
|
||||
missing = [identifier for identifier, item in self.items.items() if not media.has_details(item) or not media.has_stored_poster(item)]
|
||||
if not missing:
|
||||
self.notify(f"Every {self.label} already has saved details and a poster")
|
||||
return
|
||||
|
||||
Reference in new issue
Block a user