Add library push to website from the content editor

This commit is contained in:
SirBlob committed 2026-11-03 00:39:33 -05:00
1 parent ec57bd73b3
commit 0f90babb7a
13 files changed
+281 -20

No files matched your search

+34 -1
View File
@@ -1,12 +1,14 @@
import sys
from textual import on
from textual import on, work
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.widgets import DataTable, Footer, Header, TabbedContent, TabPane
import library_push
import portfolio
from tui.books_pane import BooksPane
from tui.dialogs import ConfirmDialog
from tui.games_pane import GamesPane
from tui.media_pane import MediaPane
from tui.music_pane import MusicPane
@@ -41,6 +43,7 @@ class ContentApp(App):
BINDINGS = [
Binding("q", "quit", "Quit"),
Binding("t", "next_theme", "Theme"),
Binding("P", "push_library", "Push to website"),
*[Binding(tab_key(number), f"show_tab('{tab_id}')", label, show=False) for number, (tab_id, label) in enumerate(TABS, start=1)],
]
@@ -97,6 +100,36 @@ class ContentApp(App):
def on_mount(self):
self.focus_active_table()
def action_push_library(self):
self.notify("Comparing the library with the website...")
self.prepare_push()
@work(thread=True, exclusive=True, group="push")
def prepare_push(self):
try:
plan = library_push.plan_push()
except (OSError, ValueError) as error:
self.call_from_thread(self.notify, str(error), title="Push", severity="error", timeout=10)
return
self.call_from_thread(self.confirm_push, plan)
def confirm_push(self, plan: library_push.PushPlan):
def confirmed(should_push: bool | None):
if should_push:
self.push_library(plan)
self.push_screen(ConfirmDialog(plan.describe(), "Push", "primary"), confirmed)
@work(thread=True, exclusive=True, group="push")
def push_library(self, plan: library_push.PushPlan):
progress = lambda message: self.call_from_thread(self.notify, message)
result = library_push.run_push(plan, progress)
for message in result.errors[:5]:
self.call_from_thread(self.notify, message, title="Push", severity="warning", timeout=10)
summary = f"Pushed {result.pushed} and removed {result.removed} on {plan.target.url}"
severity = "warning" if result.errors else "information"
self.call_from_thread(self.notify, f"{summary}, {len(result.errors)} failed", title="Push", severity=severity, timeout=10)
def action_show_tab(self, tab_id: str):
self.query_one(TabbedContent).active = tab_id
+30 -10
View File
@@ -14,15 +14,30 @@ class LibraryError(ValueError):
pass
class Site:
def __init__(self, url: str, key: str | None):
self.url = url.rstrip("/")
self.key = key
def require_key(self) -> str:
if not self.key:
raise LibraryError(f"Set an API key in .env to change the library at {self.url}")
return self.key
def api_url() -> str:
return (env_value("LIBRARY_API_URL") or DEFAULT_API_URL).rstrip("/")
def api_key() -> str:
key = env_value("LIBRARY_API_KEY")
if not key:
raise LibraryError("Set LIBRARY_API_KEY in .env to change the library")
return key
def local_site() -> Site:
return Site(api_url(), env_value("LIBRARY_API_KEY"))
def push_site() -> Site:
url = env_value("LIBRARY_PUSH_URL")
if not url:
raise LibraryError("Set LIBRARY_PUSH_URL in .env to the website to push to, for example https://dev.sirblob.co")
return Site(url, env_value("LIBRARY_PUSH_KEY") or env_value("LIBRARY_API_KEY"))
def error_message(error: urllib.error.HTTPError) -> str:
@@ -32,22 +47,23 @@ def error_message(error: urllib.error.HTTPError) -> str:
return str(error.reason)
def send(method: str, path: str, body: dict | None = None, signed: bool = True):
def send(method: str, path: str, body: dict | None = None, signed: bool = True, site: Site | None = None):
site = site or local_site()
headers = {"Accept": "application/json"}
data = None
if signed:
headers["Authorization"] = f"Bearer {api_key()}"
headers["Authorization"] = f"Bearer {site.require_key()}"
if body is not None:
headers["Content-Type"] = "application/json"
data = json.dumps(body).encode("utf-8")
request = urllib.request.Request(f"{api_url()}{path}", data=data, headers=headers, method=method)
request = urllib.request.Request(f"{site.url}{path}", data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT_SECONDS) as response:
text = response.read().decode("utf-8")
except urllib.error.HTTPError as error:
raise LibraryError(f"{api_url()} said {error.code}: {error_message(error)}") from error
raise LibraryError(f"{site.url} said {error.code}: {error_message(error)}") from error
except urllib.error.URLError as error:
raise LibraryError(f"Could not reach {api_url()}: {error.reason}") from error
raise LibraryError(f"Could not reach {site.url}: {error.reason}") from error
return json.loads(text) if text else None
@@ -59,6 +75,10 @@ def fetch_library() -> dict:
return send("GET", "/api/library", signed=False)
def fetch_export(site: Site) -> dict:
return send("GET", "/api/library/export", site=site)
def save_media(imdb_id: str, fields: dict) -> dict:
return send("PUT", f"/api/library/media/{quoted(imdb_id)}", fields)
+97
View File
@@ -0,0 +1,97 @@
import sys
import urllib.parse
from dataclasses import dataclass, field
from typing import Callable
import library_api
SECTIONS = {
"media": {"label": "shows and movies", "path": "/api/library/media/"},
"books": {"label": "books", "path": "/api/library/books/"},
"games": {"label": "games", "path": "/api/library/games/"},
}
@dataclass
class PushPlan:
source: library_api.Site
target: library_api.Site
entries: dict[str, list[dict]]
removals: dict[str, list[str]]
def describe(self) -> str:
pushed = ", ".join(f"{len(self.entries[name])} {section['label']}" for name, section in SECTIONS.items())
removed = sum(len(identifiers) for identifiers in self.removals.values())
removal_text = f" and remove {removed} entries that only exist there" if removed else ""
return f"Push {pushed} from {self.source.url} to {self.target.url}{removal_text}?"
@dataclass
class PushResult:
pushed: int = 0
removed: int = 0
errors: list[str] = field(default_factory=list)
def entry_path(section: str, identifier: str) -> str:
return f"{SECTIONS[section]['path']}{urllib.parse.quote(identifier, safe='')}"
def plan_push() -> PushPlan:
source = library_api.local_site()
target = library_api.push_site()
if source.url == target.url:
raise library_api.LibraryError("LIBRARY_PUSH_URL is the same site as LIBRARY_API_URL")
local = library_api.fetch_export(source)
if not any(local[section] for section in SECTIONS):
raise library_api.LibraryError(f"The library at {source.url} is empty, so nothing was pushed")
remote = library_api.fetch_export(target)
removals = {}
for section in SECTIONS:
local_ids = {entry["id"] for entry in local[section]}
removals[section] = [entry["id"] for entry in remote[section] if entry["id"] not in local_ids]
return PushPlan(source, target, {section: local[section] for section in SECTIONS}, removals)
def run_push(plan: PushPlan, on_progress: Callable[[str], None] = lambda message: None) -> PushResult:
result = PushResult()
for section, identifiers in plan.removals.items():
for identifier in identifiers:
try:
library_api.send("DELETE", entry_path(section, identifier), site=plan.target)
result.removed += 1
except library_api.LibraryError as error:
result.errors.append(f"remove {identifier}: {error}")
for section, entries in plan.entries.items():
on_progress(f"Pushing {len(entries)} {SECTIONS[section]['label']}...")
for entry in entries:
try:
library_api.send("PUT", entry_path(section, entry["id"]), entry["fields"], site=plan.target)
result.pushed += 1
except library_api.LibraryError as error:
result.errors.append(f"{entry['id']}: {error}")
return result
def main() -> int:
try:
plan = plan_push()
except (OSError, ValueError) as error:
print(f"Push stopped: {error}", file=sys.stderr)
return 1
print(plan.describe())
if input("Type yes to continue: ").strip().lower() != "yes":
print("Nothing was pushed")
return 0
result = run_push(plan, print)
for message in result.errors:
print(f"Error: {message}", file=sys.stderr)
print(f"Pushed {result.pushed}, removed {result.removed}, {len(result.errors)} failed")
return 1 if result.errors else 0
if __name__ == "__main__":
sys.exit(main())
+4 -2
View File
@@ -86,15 +86,17 @@ class FormDialog(ModalScreen[dict | None]):
class ConfirmDialog(ModalScreen[bool]):
BINDINGS = [("y", "confirm", "Yes"), ("n", "cancel", "No"), ("escape", "cancel", "No")]
def __init__(self, message: str):
def __init__(self, message: str, confirm_label: str = "Delete", confirm_variant: str = "error"):
super().__init__()
self.message = message
self.confirm_label = confirm_label
self.confirm_variant = confirm_variant
def compose(self) -> ComposeResult:
with Vertical(classes="dialog"):
yield Label(self.message, classes="dialog-title")
with Horizontal(classes="dialog-buttons"):
yield Button("Delete", variant="error", id="confirm")
yield Button(self.confirm_label, variant=self.confirm_variant, id="confirm")
yield Button("Cancel", id="cancel")
@on(Button.Pressed, "#confirm")