98 lines
3.5 KiB
Python
98 lines
3.5 KiB
Python
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())
|