diff --git a/Dockerfile b/Dockerfile index 8ac7da4..86fca49 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ # Two stages: the wheel is built from the src layout, then installed into a venv # in a clean image, so the runtime layer carries no build tooling and no second # copy of the source. data/ is a volume: the services store, users.json and -# pages.json live there, so edits survive a restart and the host's internal URLs +# widgets.json live there, so edits survive a restart and the host's internal URLs # never get baked into a layer. ARG PYTHON_VERSION=3.11 diff --git a/README.md b/README.md index 19facb5..edcf38a 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ clean stage installs it into `/app/.venv`, and the entrypoint is the (`UWUDASH_HOST` / `UWUDASH_PORT` are baked in as `ENV` defaults - override both the `--env` and the `-p` mapping if you change the port). -`/app/data` is a `VOLUME` holding `services.json`, `pages.json` (and `users.json` +`/app/data` is a `VOLUME` holding `services.json`, `widgets.json` (and `users.json` once auth is on): a named volume keeps your service list across restarts, a bind mount (`-v ./data:/app/data`) seeds the container from the host file instead. `data/` is in `.dockerignore`, so internal URLs and per-service `headers` tokens never @@ -78,7 +78,7 @@ container stays `healthy` with auth on. | `--port` | `8000` | bind port | | `--data-dir` | `data` | directory holding `services.json` | | `--services-file` | `/services.json` | explicit store path | -| `--pages-file` | `/pages.json` | explicit notice store path | +| `--widgets-file` | `/widgets.json` | explicit widget store path | | `--oidc-issuer` | unset | OIDC issuer; enables sign-in together with the client env vars | | `--public-url` | request origin | external base URL used for the OIDC `redirect_uri` | | `--reload` | off | uvicorn reload on source changes | @@ -91,7 +91,8 @@ container stays `healthy` with auth on. | `UWUDASH_PORT` | `8000` | bind port | | `UWUDASH_DATA_DIR` | `data` | data directory | | `UWUDASH_SERVICES_FILE` | `data/services.json` | services store path | -| `UWUDASH_PAGES_FILE` | `data/pages.json` | notice store path | +| `UWUDASH_WIDGETS_FILE` | `data/widgets.json` | widget store path | +| `UWUDASH_WIDGET_CACHE_TTL` | `300` | seconds a live widget's upstream payload is reused (0 = fetch every render) | | `UWUDASH_PROXY_TIMEOUT` | `30.0` | upstream request timeout (seconds) | | `UWUDASH_STRIP_FRAME_OPTIONS` | `true` | strip `X-Frame-Options` and CSP `frame-ancestors` from proxied responses | | `UWUDASH_OIDC_ISSUER` | unset | OIDC issuer, e.g. `https://id.example.com` (auth stays off unless issuer, client id and secret are all set) | @@ -205,20 +206,34 @@ Services live in `data/services.json`: Edit them from the **Settings** page in the UI, or via the API. -## Notice pages +## Widgets -Notices are native dashboard content - no URL, no proxy. They live in -`data/pages.json` (same atomic-write format as `services.json`): +Widgets are native dashboard content - no iframe, no URL bar. They live in +`data/widgets.json` (same atomic-write/lock format as `services.json`) and come +in three kinds: + +| kind | what it shows | what it needs | +| --- | --- | --- | +| `notice` | pinned text, nothing fetched | `body` (โ‰ค 5000 chars, line breaks kept) | +| `calendar` | a release window from a Sonarr-family API | `base_url`, `api_prefix` (default `/api/v3`), `days` (1-90), `date_type` (`auto`/`physical`/`digital`/`cinemas`/`release`) | +| `recent` | what just landed (`/library/recentlyAdded`) | `base_url`, `limit` (1-50) | ```json { "version": 1, - "pages": [ + "widgets": [ { - "id": "maintenance", - "name": "Maintenance", - "icon": "๐Ÿ”ง", - "body": "Server down tonight\nBring snacks", + "id": "upcoming", + "name": "Upcoming", + "icon": "", + "kind": "calendar", + "body": null, + "base_url": "http://internal-host:8080", + "headers": { "X-Api-Key": "redacted" }, + "api_prefix": "/api/v3", + "days": 14, + "date_type": "auto", + "limit": 12, "created_at": "2026-10-28T09:29:31+00:00", "updated_at": "2026-10-28T09:29:31+00:00" } @@ -226,18 +241,29 @@ Notices are native dashboard content - no URL, no proxy. They live in } ``` -- `id` is slugified from the name and stays stable across edits; `body` is capped - at 5000 characters and keeps its line breaks (`white-space: pre-wrap`, escaped). -- Each notice gets a sidebar button; clicking it shows a single card in the viewer - area instead of the iframe. -- The notice board is the **default view**: landing on `/` already shows it in the - viewer area (banner plus every notice, newest first), no click needed. The pinned - **Home** button (`๐Ÿ `, always present) re-opens it, and deleting whatever you were - viewing falls back to it. `/` itself stays the dashboard. -- Writes are admin-only when OIDC is on, reads any session - same gate as services. +- `id` is slugified from the name and stays stable across edits. +- Live widgets are fetched **at render time**, never at save time: the first + render after a save fetches, later renders reuse the payload until + `UWUDASH_WIDGET_CACHE_TTL` (default 300 s) expires. The cache key is + `@`, so editing a widget invalidates it immediately. Errors + cache for at most 60 s so a fixed upstream recovers quickly. +- Upstream keys go in `headers` (the form's "Headers" textarea, one `Key: value` + per line) - they stay on the server-to-server hop and never appear in the + browser. +- Unreachable upstreams degrade instead of blanking the board: the card shows + `unreachable: ` and the Settings table's Test button relays the exact + upstream message (`connection refused`, Sonarr's `ApiKey was not provided`...). +- Artwork (Radarr posters, Plex thumbs) is relayed through + `/api/widgets/{id}/image?u=...` - the browser cannot reach the internal host, + and the relay only ever fetches from that widget's own origin. +- Home renders one merged calendar card (every `calendar` widget, sorted by + date), one card per `recent` widget, then notices. Each widget also gets its + own sidebar button for a single-card view. +- Writes are admin-only when OIDC is on, reads any session - same gate as + services. -Edit them from the **Settings** page (the "Add notice" form and the notice table), -or via the API. +Edit them from the **Settings** page (the "Add widget" form and the widget +table, which carries a Test button per live widget), or via the API. ## How the proxy works @@ -285,11 +311,13 @@ Details: | `PATCH` / `PUT` | `/api/services/{id}` | update | | `DELETE` | `/api/services/{id}` | delete | | `GET` | `/api/services/{id}/health` | upstream reachability check | -| `GET` | `/api/pages` | list notices | -| `POST` | `/api/pages` | create - admin only | -| `GET` | `/api/pages/{id}` | read | -| `PATCH` | `/api/pages/{id}` | update - admin only | -| `DELETE` | `/api/pages/{id}` | delete - admin only | +| `GET` | `/api/widgets` | every widget, with live data inlined for `calendar`/`recent` | +| `POST` | `/api/widgets` | create - admin only | +| `GET` | `/api/widgets/{id}` | read the stored config | +| `PATCH` | `/api/widgets/{id}` | update - admin only | +| `POST` | `/api/widgets/{id}/refresh` | bypass the cache and fetch now - admin only | +| `GET` | `/api/widgets/{id}/image?u=...` | relay one artwork URL from that widget's own origin | +| `DELETE` | `/api/widgets/{id}` | delete - admin only | | `GET` | `/api/config` | effective config (never includes the OIDC secret) | | `GET` | `/api/me` | current user (only when auth is enabled) | | `GET` | `/api/users` | list users - admin only | @@ -306,9 +334,9 @@ Details: ``` src/uwudash_mk3/ config.py env-driven Config dataclass - models.py Service / ServiceInput / ServicePatch / Page / PageInput / PagePatch / User / UserPatch + URL helpers + models.py Service / ServiceInput / ServicePatch / Widget / WidgetInput / WidgetPatch / User / UserPatch + URL helpers store.py JSON file store (atomic writes, lock) - pages.py JSON notice store (same pattern as store.py) + widgets.py JSON widget store + server-side fetch/cache/relay layer users.py JSON user store (same pattern as store.py) auth.py OIDC client, login/callback/logout, role dependencies proxy.py reverse proxy: header filtering, HTML/redirect rewriting diff --git a/src/uwudash_mk3/api.py b/src/uwudash_mk3/api.py index 36b8a90..362fc10 100644 --- a/src/uwudash_mk3/api.py +++ b/src/uwudash_mk3/api.py @@ -1,31 +1,38 @@ -"""JSON API used by the dashboard UI to manage services, notices, and users.""" +"""JSON API used by the dashboard UI to manage services, widgets, and users.""" from __future__ import annotations -from typing import List, Optional +from typing import List from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import Response from .config import Config -from .models import Page, PageInput, PagePatch, Service, ServiceInput, ServicePatch +from .models import Service, ServiceInput, ServicePatch from .models import User, UserPatch -from .pages import PageStore +from .models import Widget, WidgetInput, WidgetPatch from .proxy import Proxy from .store import ServiceStore from .users import UserStore +from .widgets import WidgetData, WidgetStore def create_api_router( store: ServiceStore, - pages: PageStore, + widgets: WidgetStore, + widget_data: WidgetData, proxy: Proxy, config: Config, - users: Optional[UserStore] = None, + users=None, require_login=None, require_admin=None, ) -> APIRouter: """Build the ``/api`` router. + Live widget data is fetched here rather than in the browser: the upstream + only has to be reachable from this process, so the UI never sees a token or + an internal hostname. + ``users``/``require_login``/``require_admin`` are only supplied when OIDC is configured; without them the router is exactly the unauthenticated one. """ @@ -40,7 +47,8 @@ def create_api_router( "host": config.host, "port": config.port, "services_file": str(config.services_file), - "pages_file": str(config.pages_file), + "widgets_file": str(config.widgets_file), + "widget_cache_ttl": config.widget_cache_ttl, "strip_frame_options": config.strip_frame_options, "proxy_timeout": config.proxy_timeout, "auth_enabled": config.auth_enabled, @@ -102,43 +110,86 @@ def create_api_router( raise HTTPException(status_code=404, detail=f"unknown service: {service_id}") return await proxy.check(service) - @router.get("/pages") - def list_pages() -> List[Page]: - return pages.list() + @router.get("/widgets") + async def list_widgets() -> List[dict]: + """Every widget, with live data inlined for the ones that have an upstream. - @router.get("/pages/{page_id}") - def get_page(page_id: str) -> Page: - page = pages.get(page_id) - if page is None: - raise HTTPException(status_code=404, detail=f"unknown page: {page_id}") - return page + Inlining keeps the dashboard to one round trip; the fetch layer caches + per widget (keyed on ``updated_at``) so a reload is a dict lookup. + """ + out = [] + for widget in widgets.list(): + entry = widget.model_dump() + if widget.kind != "notice": + entry["data"] = await widget_data.data(widget) + out.append(entry) + return out + + @router.get("/widgets/{widget_id}") + def get_widget(widget_id: str) -> Widget: + widget = widgets.get(widget_id) + if widget is None: + raise HTTPException(status_code=404, detail=f"unknown widget: {widget_id}") + return widget @router.post( - "/pages", + "/widgets", status_code=201, dependencies=[Depends(require_admin)] if require_admin else [], ) - def create_page(payload: PageInput) -> Page: - return pages.create(payload) + def create_widget(payload: WidgetInput) -> Widget: + return widgets.create(payload) @router.patch( - "/pages/{page_id}", + "/widgets/{widget_id}", dependencies=[Depends(require_admin)] if require_admin else [], ) - def update_page(page_id: str, payload: PagePatch) -> Page: - page = pages.update(page_id, payload) - if page is None: - raise HTTPException(status_code=404, detail=f"unknown page: {page_id}") - return page + def update_widget(widget_id: str, payload: WidgetPatch) -> Widget: + try: + widget = widgets.update(widget_id, payload) + except ValueError as exc: + # The store re-validates the merged record, so a patch that leaves + # an inconsistent widget (kind=notice without a body) surfaces here + # as a bad request rather than a crash. + raise HTTPException(status_code=422, detail=str(exc)) from exc + if widget is None: + raise HTTPException(status_code=404, detail=f"unknown widget: {widget_id}") + return widget + + @router.post( + "/widgets/{widget_id}/refresh", + dependencies=[Depends(require_admin)] if require_admin else [], + ) + async def refresh_widget(widget_id: str) -> dict: + """Force a fetch - the UI's Test button, so a config typo shows up.""" + widget = widgets.get(widget_id) + if widget is None: + raise HTTPException(status_code=404, detail=f"unknown widget: {widget_id}") + return await widget_data.data(widget, refresh=True) + + @router.get("/widgets/{widget_id}/image") + async def widget_image(widget_id: str, u: str) -> Response: + """Relay one artwork URL from a widget's own upstream to the browser.""" + widget = widgets.get(widget_id) + if widget is None: + raise HTTPException(status_code=404, detail=f"unknown widget: {widget_id}") + if not widget.base_url: + raise HTTPException(status_code=404, detail=f"widget {widget_id} has no upstream") + body, media_type = await widget_data.image(widget, u) + return Response( + content=body, + media_type=media_type, + headers={"cache-control": f"private, max-age={config.widget_cache_ttl}"}, + ) @router.delete( - "/pages/{page_id}", + "/widgets/{widget_id}", status_code=204, dependencies=[Depends(require_admin)] if require_admin else [], ) - def delete_page(page_id: str) -> None: - if not pages.delete(page_id): - raise HTTPException(status_code=404, detail=f"unknown page: {page_id}") + def delete_widget(widget_id: str) -> None: + if not widgets.delete(widget_id): + raise HTTPException(status_code=404, detail=f"unknown widget: {widget_id}") if users is not None: @router.get("/me") diff --git a/src/uwudash_mk3/app.py b/src/uwudash_mk3/app.py index 22907c1..614f403 100644 --- a/src/uwudash_mk3/app.py +++ b/src/uwudash_mk3/app.py @@ -25,7 +25,7 @@ from starlette.middleware.sessions import SessionMiddleware from .api import create_api_router from .auth import auth_dependencies, create_auth_router, create_oauth, get_current_user from .config import Config -from .pages import PageStore +from .widgets import WidgetData, WidgetStore from .proxy import Proxy from .store import ServiceStore from .users import UserStore @@ -38,7 +38,8 @@ def create_app(config: Config | None = None) -> FastAPI: config = config or Config.from_env() store = ServiceStore(config.services_file) - pages = PageStore(config.pages_file) + widgets = WidgetStore(config.widgets_file, legacy=config.data_dir / "pages.json") + widget_data = WidgetData(widgets, config) proxy = Proxy(store, config) users = UserStore(config.users_file) if config.auth_enabled else None oauth = create_oauth(config) if config.auth_enabled else None @@ -52,6 +53,7 @@ def create_app(config: Config | None = None) -> FastAPI: ) yield await proxy.close() + await widget_data.close() app = FastAPI( title="UwuDash MK3", @@ -73,7 +75,8 @@ def create_app(config: Config | None = None) -> FastAPI: app.state.config = config app.state.store = store - app.state.pages = pages + app.state.widgets = widgets + app.state.widget_data = widget_data app.state.proxy = proxy app.state.users = users app.state.oauth = oauth @@ -88,7 +91,9 @@ def create_app(config: Config | None = None) -> FastAPI: require_login = require_admin = None app.include_router( - create_api_router(store, pages, proxy, config, users, require_login, require_admin) + create_api_router( + store, widgets, widget_data, proxy, config, users, require_login, require_admin + ) ) @app.get("/") @@ -101,7 +106,7 @@ def create_app(config: Config | None = None) -> FastAPI: @app.get("/health") def health() -> dict: - return {"status": "ok", "services": len(store.list())} + return {"status": "ok", "services": len(store.list()), "widgets": len(widgets.list())} auth_gate = [Depends(require_login)] if require_login else [] diff --git a/src/uwudash_mk3/config.py b/src/uwudash_mk3/config.py index 53d4a2b..ba300bd 100644 --- a/src/uwudash_mk3/config.py +++ b/src/uwudash_mk3/config.py @@ -4,7 +4,8 @@ Everything is driven by environment variables (with sane defaults) so the dashboard can be deployed without editing code: UWUDASH_HOST, UWUDASH_PORT, UWUDASH_DATA_DIR, UWUDASH_SERVICES_FILE, - UWUDASH_PAGES_FILE, UWUDASH_PROXY_TIMEOUT, UWUDASH_STRIP_FRAME_OPTIONS + UWUDASH_WIDGETS_FILE, UWUDASH_WIDGET_CACHE_TTL, UWUDASH_PROXY_TIMEOUT, + UWUDASH_STRIP_FRAME_OPTIONS Authentication is opt-in: the OIDC routes and the session middleware only take effect when an issuer, client id and client secret are all present. @@ -29,6 +30,7 @@ DEFAULT_PROXY_TIMEOUT = 30.0 DEFAULT_OIDC_SCOPE = "openid email profile" DEFAULT_SESSION_TTL = 3600 DEFAULT_ADMIN_VALUES = ("admin",) +DEFAULT_WIDGET_CACHE_TTL = 300 def _int(name: str, default: int) -> int: @@ -87,13 +89,14 @@ class Config: session_ttl_seconds: int = DEFAULT_SESSION_TTL users_file: Optional[Path] = None public_url: Optional[str] = None - pages_file: Optional[Path] = None + widgets_file: Optional[Path] = None + widget_cache_ttl: int = DEFAULT_WIDGET_CACHE_TTL def __post_init__(self) -> None: if self.users_file is None: object.__setattr__(self, "users_file", self.data_dir / "users.json") - if self.pages_file is None: - object.__setattr__(self, "pages_file", self.data_dir / "pages.json") + if self.widgets_file is None: + object.__setattr__(self, "widgets_file", self.data_dir / "widgets.json") if self.oidc_issuer: object.__setattr__(self, "oidc_issuer", self.oidc_issuer.rstrip("/")) @@ -126,7 +129,8 @@ class Config: os.environ.get("UWUDASH_USERS_FILE", data_dir / "users.json") ), public_url=os.environ.get("UWUDASH_PUBLIC_URL") or None, - pages_file=Path( - os.environ.get("UWUDASH_PAGES_FILE", data_dir / "pages.json") + widgets_file=Path( + os.environ.get("UWUDASH_WIDGETS_FILE", data_dir / "widgets.json") ), + widget_cache_ttl=_int("UWUDASH_WIDGET_CACHE_TTL", DEFAULT_WIDGET_CACHE_TTL), ) diff --git a/src/uwudash_mk3/main.py b/src/uwudash_mk3/main.py index a2365f6..09b2b1d 100644 --- a/src/uwudash_mk3/main.py +++ b/src/uwudash_mk3/main.py @@ -18,7 +18,7 @@ def parse_args(argv=None) -> argparse.Namespace: parser.add_argument("--port", type=int, help="bind port (default 8000)") parser.add_argument("--data-dir", help="directory holding services.json") parser.add_argument("--services-file", help="path to the services JSON file") - parser.add_argument("--pages-file", help="path to the pages JSON file") + parser.add_argument("--widgets-file", help="path to the widgets JSON file") parser.add_argument("--reload", action="store_true", help="restart on source changes") parser.add_argument( "--oidc-issuer", @@ -43,10 +43,10 @@ def build_config(args: argparse.Namespace) -> Config: config = replace(config, services_file=Path(args.services_file)) elif args.data_dir: config = replace(config, services_file=Path(args.data_dir) / "services.json") - if args.pages_file: - config = replace(config, pages_file=Path(args.pages_file)) + if args.widgets_file: + config = replace(config, widgets_file=Path(args.widgets_file)) elif args.data_dir: - config = replace(config, pages_file=Path(args.data_dir) / "pages.json") + config = replace(config, widgets_file=Path(args.data_dir) / "widgets.json") if args.oidc_issuer: config = replace(config, oidc_issuer=args.oidc_issuer) if args.public_url: diff --git a/src/uwudash_mk3/models.py b/src/uwudash_mk3/models.py index 1631ae2..8675608 100644 --- a/src/uwudash_mk3/models.py +++ b/src/uwudash_mk3/models.py @@ -6,7 +6,7 @@ import re from typing import Dict, Optional from urllib.parse import urlsplit -from pydantic import BaseModel, Field, computed_field, field_validator +from pydantic import BaseModel, Field, computed_field, field_validator, model_validator ALLOWED_SCHEMES = {"http", "https"} OPEN_MODES = ("iframe", "newtab") @@ -119,28 +119,118 @@ class ServicePatch(BaseModel): return value -class PageInput(BaseModel): - """Payload accepted by the page create/update endpoints.""" +WIDGET_KINDS = ("notice", "calendar", "recent") +DATE_TYPES = ("auto", "physical", "digital", "cinemas", "release") + + +class WidgetInput(BaseModel): + """Payload accepted by the widget create/update endpoints. + + ``notice`` widgets are plain text and carry no upstream. ``calendar`` and + ``recent`` widgets name their own source (``base_url`` plus whatever + headers that source needs) - they are deliberately **not** tied to the + proxied services, so a widget can exist without ever being framed. + """ name: str = Field(min_length=1, max_length=120) icon: str = Field(default="", max_length=8) - body: str = Field(min_length=1, max_length=5000) + kind: str = "notice" + body: Optional[str] = Field(default=None, min_length=1, max_length=5000) + base_url: Optional[str] = Field(default=None, max_length=500) + headers: Dict[str, str] = Field(default_factory=dict) + api_prefix: str = Field(default="/api/v3", max_length=32) + days: int = Field(default=14, ge=1, le=90) + date_type: str = "auto" + limit: int = Field(default=12, ge=1, le=50) + + @field_validator("kind") + def _validate_kind(cls, value: str) -> str: + if value not in WIDGET_KINDS: + raise ValueError(f"kind must be one of {WIDGET_KINDS}") + return value + + @field_validator("base_url") + def _validate_base_url(cls, value: Optional[str]) -> Optional[str]: + if value is None: + return value + parts = urlsplit(value.strip()) + if parts.scheme not in ALLOWED_SCHEMES: + raise ValueError("base_url must start with http:// or https://") + if not parts.netloc: + raise ValueError("base_url must include a host") + return value.strip() + + @field_validator("headers") + def _validate_headers(cls, value: Dict[str, str]) -> Dict[str, str]: + for key in value: + if not key or any(c in key for c in "\r\n"): + raise ValueError("header names must not contain newlines") + return value + + @field_validator("date_type") + def _validate_date_type(cls, value: str) -> str: + if value not in DATE_TYPES: + raise ValueError(f"date_type must be one of {DATE_TYPES}") + return value + + @model_validator(mode="after") + def _validate_kind_fields(self) -> "WidgetInput": + if self.kind == "notice": + if not self.body: + raise ValueError("notice widgets need a body") + elif not self.base_url: + raise ValueError(f"{self.kind} widgets need a base_url") + return self -class Page(PageInput): - """A stored notice page.""" +class Widget(WidgetInput): + """A stored widget.""" id: str created_at: str updated_at: str -class PagePatch(BaseModel): - """Partial page update; unset fields are left alone.""" +class WidgetPatch(BaseModel): + """Partial widget update; unset fields are left alone.""" name: Optional[str] = Field(default=None, min_length=1, max_length=120) icon: Optional[str] = Field(default=None, max_length=8) + kind: Optional[str] = None body: Optional[str] = Field(default=None, min_length=1, max_length=5000) + base_url: Optional[str] = Field(default=None, max_length=500) + headers: Optional[Dict[str, str]] = None + api_prefix: Optional[str] = Field(default=None, max_length=32) + days: Optional[int] = Field(default=None, ge=1, le=90) + date_type: Optional[str] = None + limit: Optional[int] = Field(default=None, ge=1, le=50) + + @field_validator("kind") + def _validate_kind(cls, value: Optional[str]) -> Optional[str]: + if value is None: + return value + if value not in WIDGET_KINDS: + raise ValueError(f"kind must be one of {WIDGET_KINDS}") + return value + + @field_validator("base_url") + def _validate_base_url(cls, value: Optional[str]) -> Optional[str]: + if value is None: + return value + parts = urlsplit(value.strip()) + if parts.scheme not in ALLOWED_SCHEMES: + raise ValueError("base_url must start with http:// or https://") + if not parts.netloc: + raise ValueError("base_url must include a host") + return value.strip() + + @field_validator("date_type") + def _validate_date_type(cls, value: Optional[str]) -> Optional[str]: + if value is None: + return value + if value not in DATE_TYPES: + raise ValueError(f"date_type must be one of {DATE_TYPES}") + return value ROLES = ("admin", "viewer") diff --git a/src/uwudash_mk3/pages.py b/src/uwudash_mk3/pages.py deleted file mode 100644 index 7c8b520..0000000 --- a/src/uwudash_mk3/pages.py +++ /dev/null @@ -1,105 +0,0 @@ -"""Persistence for notice pages. - -The store is a plain JSON file so it is easy to inspect, back up, or seed from -a container. Writes are atomic (temp file + ``os.replace``) and serialised with -a lock so concurrent API requests cannot interleave. -""" - -from __future__ import annotations - -import json -import os -import threading -from datetime import datetime, timezone -from pathlib import Path -from typing import List, Optional - -from .models import Page, PageInput, PagePatch, slugify - - -def _now() -> str: - return datetime.now(timezone.utc).isoformat(timespec="seconds") - - -class PageStore: - def __init__(self, path: Path) -> None: - self.path = Path(path) - self._lock = threading.Lock() - self.path.parent.mkdir(parents=True, exist_ok=True) - if not self.path.exists(): - self._write([]) - - # ------------------------------------------------------------------ read - def _read(self) -> List[Page]: - try: - raw = json.loads(self.path.read_text(encoding="utf-8")) - except FileNotFoundError: - return [] - except json.JSONDecodeError as exc: - raise ValueError(f"{self.path} is not valid JSON: {exc}") from exc - items = raw.get("pages", []) if isinstance(raw, dict) else raw - return [Page.model_validate(item) for item in items] - - def _write(self, pages: List[Page]) -> None: - payload = { - "version": 1, - "pages": [p.model_dump() for p in pages], - } - tmp = self.path.with_suffix(".tmp") - tmp.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") - os.replace(tmp, self.path) - - def list(self) -> List[Page]: - with self._lock: - return self._read() - - def get(self, page_id: str) -> Optional[Page]: - with self._lock: - for page in self._read(): - if page.id == page_id: - return page - return None - - # ----------------------------------------------------------------- write - def create(self, data: PageInput) -> Page: - with self._lock: - pages = self._read() - base = slugify(data.name) - candidate = base - suffix = 1 - existing = {p.id for p in pages} - while candidate in existing: - suffix += 1 - candidate = f"{base}-{suffix}" - now = _now() - page = Page( - **data.model_dump(), id=candidate, created_at=now, updated_at=now - ) - pages.append(page) - self._write(pages) - return page - - def update(self, page_id: str, patch: PagePatch) -> Optional[Page]: - with self._lock: - pages = self._read() - for index, page in enumerate(pages): - if page.id != page_id: - continue - updates = {k: v for k, v in patch.model_dump().items() if v is not None} - if not updates: - return page - updated = page.model_copy(update=updates) - updated.updated_at = _now() - pages[index] = updated - self._write(pages) - return updated - return None - - def delete(self, page_id: str) -> bool: - with self._lock: - pages = self._read() - remaining = [p for p in pages if p.id != page_id] - if len(remaining) == len(pages): - return False - self._write(remaining) - return True diff --git a/src/uwudash_mk3/static/app.js b/src/uwudash_mk3/static/app.js index cee8134..c7832c4 100644 --- a/src/uwudash_mk3/static/app.js +++ b/src/uwudash_mk3/static/app.js @@ -10,13 +10,13 @@ const el = { viewerFrame: document.getElementById("viewer-frame"), emptyState: document.getElementById("empty-state"), emptyText: document.getElementById("empty-text"), - pagePanel: document.getElementById("page-panel"), + widgetPanel: document.getElementById("widget-panel"), reloadBtn: document.getElementById("reload-btn"), settings: document.getElementById("settings"), settingsToggle: document.getElementById("settings-toggle"), settingsClose: document.getElementById("settings-close"), serviceTable: document.getElementById("service-table"), - pageTable: document.getElementById("page-table"), + widgetTable: document.getElementById("widget-table"), form: document.getElementById("service-form"), formTitle: document.getElementById("form-title"), name: document.getElementById("f-name"), @@ -27,12 +27,19 @@ const el = { headers: document.getElementById("f-headers"), enabled: document.getElementById("f-enabled"), formCancel: document.getElementById("form-cancel"), - noticeForm: document.getElementById("notice-form"), - noticeFormTitle: document.getElementById("notice-form-title"), - noticeName: document.getElementById("n-name"), - noticeIcon: document.getElementById("n-icon"), - noticeBody: document.getElementById("n-body"), - noticeCancel: document.getElementById("notice-cancel"), + widgetForm: document.getElementById("widget-form"), + widgetFormTitle: document.getElementById("widget-form-title"), + wName: document.getElementById("w-name"), + wIcon: document.getElementById("w-icon"), + wKind: document.getElementById("w-kind"), + wBody: document.getElementById("w-body"), + wBase: document.getElementById("w-base"), + wHeaders: document.getElementById("w-headers"), + wPrefix: document.getElementById("w-prefix"), + wDays: document.getElementById("w-days"), + wDate: document.getElementById("w-date"), + wLimit: document.getElementById("w-limit"), + widgetCancel: document.getElementById("widget-cancel"), toast: document.getElementById("toast"), userChip: document.getElementById("user-chip"), userName: document.getElementById("user-name"), @@ -40,16 +47,16 @@ const el = { userTable: document.getElementById("user-table"), }; -// Sentinel for state.selected while the Home notice board is open; slugify() -// only ever emits [a-z0-9-], so no page id can collide with it. +// Sentinel for state.selected while the Home widget board is open; slugify() +// only ever emits [a-z0-9-], so no widget id can collide with it. const HOME = "@home"; const state = { services: [], - pages: [], + widgets: [], selected: HOME, editing: null, - editingPage: null, + editingWidget: null, user: null, }; @@ -103,8 +110,8 @@ function textToHeaders(text) { /* ---------------------------------------------------------------- rendering */ function renderSidebar() { el.serviceList.innerHTML = ""; - if (!state.services.length && !state.pages.length) { - el.serviceList.innerHTML = '

No services configured yet.

'; + if (!state.services.length && !state.widgets.length) { + el.serviceList.innerHTML = '

No services or widgets yet.

'; return; } for (const service of state.services) { @@ -121,19 +128,33 @@ function renderSidebar() { button.addEventListener("click", () => openService(service)); el.serviceList.appendChild(button); } - for (const page of state.pages) { + for (const widget of state.widgets) { const button = document.createElement("button"); - button.className = "page-btn" + (state.selected === page.id ? " active" : ""); - button.title = page.name; + button.className = "widget-btn" + (state.selected === widget.id ? " active" : ""); + button.title = widget.name; button.innerHTML = - `${esc(page.icon || "๐Ÿ“Œ")}` + - `${esc(page.name)}` + - `${esc(page.body.slice(0, 60))}`; - button.addEventListener("click", () => openPage(page)); + `${esc(widget.icon || WIDGET_ICONS[widget.kind])}` + + `${esc(widget.name)}` + + `${esc(widgetSub(widget))}`; + button.addEventListener("click", () => openWidget(widget)); el.serviceList.appendChild(button); } } +const WIDGET_ICONS = { notice: "๐Ÿ“Œ", calendar: "๐Ÿ“…", recent: "๐Ÿ†•" }; + +function widgetIcon(widget) { + return esc(widget.icon || WIDGET_ICONS[widget.kind]); +} + +function widgetSub(widget) { + if (widget.kind === "notice") return (widget.body || "").slice(0, 60); + const data = widget.data || {}; + if (data.error) return `โš  ${data.error.error}`; + const host = (widget.base_url || "").split("://", 1)[1]; + return `${(data.items || []).length} items ยท ${host}`; +} + function renderTable() { el.serviceTable.innerHTML = ""; if (!state.services.length) { @@ -155,51 +176,152 @@ function renderTable() { } } -function renderPageTable() { - el.pageTable.innerHTML = ""; - if (!state.pages.length) { - el.pageTable.innerHTML = '

No notices yet - pin one above.

'; +function renderWidgetTable() { + el.widgetTable.innerHTML = ""; + if (!state.widgets.length) { + el.widgetTable.innerHTML = '

No widgets yet - add one on the left.

'; return; } - for (const page of state.pages) { + for (const widget of state.widgets) { + const live = widget.kind !== "notice"; + const data = widget.data || {}; + const detail = live + ? `${widget.base_url || ""}${widget.kind === "calendar" ? ` ${widget.api_prefix}/calendar` : " /library/recentlyAdded"}` + : (widget.body || "").slice(0, 80); + const status = live + ? data.error + ? `unreachable: ${data.error.error}` + : `${(data.items || []).length} items` + : "pinned"; const row = document.createElement("div"); - row.className = "page-row"; + row.className = "widget-row"; row.innerHTML = - `
${esc(page.name)}` + - `${esc(page.body.slice(0, 80))}
` + - `` + - ``; - el.pageTable.appendChild(row); + `
${esc(widget.name)}` + + `${esc(detail)}` + + `${esc(widget.kind)}
` + + `${esc(status)}` + + (live + ? `` + : "") + + `` + + ``; + el.widgetTable.appendChild(row); } } -function noticeCard(page) { +function noticeCard(widget) { return ( - `
` + - `${esc(page.icon || "๐Ÿ“Œ")}` + - `
${esc(page.name)}
` + - `
${esc(page.body)}
` + + `
` + + `${widgetIcon(widget)}` + + `
${esc(widget.name)}
` + + `
${esc(widget.body || "")}
` + `
` ); } +function itemRow(item) { + const thumb = item.thumb + ? `${esc(item.label)}` + : ""; + return ( + `
` + + thumb + + `${esc(item.label)}` + + (item.sub ? `${esc(item.sub)}` : "") + + `${esc(item.source || "")}` + + `
` + ); +} + +function groupByDate(items) { + const days = []; + for (const item of items) { + const last = days[days.length - 1]; + if (last && last.date === item.date) last.items.push(item); + else days.push({ date: item.date, items: [item] }); + } + return days; +} + +function dayLabel(date) { + const parts = (date || "").split("-").map(Number); + if (parts.length !== 3) return date || ""; + const day = new Date(parts[0], parts[1] - 1, parts[2]); + const now = new Date(); + // Compare whole days: `new Date()` carries the current time, so subtracting + // it directly rounds 2026-11-09 to "tomorrow" on a late-in-the-day render. + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const diff = (day - today) / 86400000; + if (diff === 0) return "today"; + if (diff === 1) return "tomorrow"; + const options = { month: "short", day: "numeric" }; + if (parts[0] !== today.getFullYear()) options.year = "numeric"; + return day.toLocaleDateString(undefined, options); +} + +function clockOf(iso) { + if (!iso) return ""; + return new Date(iso).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); +} + +// One card per live widget group: every calendar widget merges into a single +// sorted timeline, each "recently added" widget gets its own card. +function liveCard(widgets, title) { + const items = widgets.flatMap((widget) => (widget.data || {}).items || []); + const days = groupByDate(items); + const notes = widgets + .map((widget) => { + const data = widget.data || {}; + return data.error ? `${widget.name}: ${data.error.error}` : null; + }) + .filter(Boolean); + const refreshed = widgets.map((widget) => (widget.data || {}).fetched_at || "").filter(Boolean); + const stamp = refreshed.length ? `refreshed ${clockOf(refreshed.sort()[0])}` : ""; + const meta = [...notes, stamp].filter(Boolean).join(" ยท "); + return ( + `
` + + `${widgetIcon(widgets[0])}` + + `
${esc(title)}
` + + `
${esc(meta)}
` + + days.map( + (day) => + `
` + + `
${esc(dayLabel(day.date))}
` + + day.items.map(itemRow).join("") + + `
` + ).join("") + + (days.length ? "" : `
${esc(meta || "nothing in the window")}
`) + + `
` + ); +} + +function widgetCard(widget) { + return widget.kind === "notice" ? noticeCard(widget) : liveCard([widget], widget.name); +} + function renderHome() { + const calendars = state.widgets.filter((widget) => widget.kind === "calendar"); + const recents = state.widgets.filter((widget) => widget.kind === "recent"); + const notices = state.widgets.filter((widget) => widget.kind === "notice"); + const cards = []; + if (calendars.length) cards.push(liveCard(calendars, "Upcoming")); + for (const widget of recents) cards.push(liveCard([widget], widget.name)); + for (const widget of notices) cards.push(noticeCard(widget)); const banner = `
` + `
Home (uwu)
` + - `
the notice board โ€” pin anything the crew should know
` + + `
widgets โ€” release calendars, what just landed, and anything pinned
` + `
`; - const cards = state.pages.slice().reverse().map(noticeCard).join(""); - el.pagePanel.innerHTML = - banner + (cards || '

no notices yet ( ๐Ÿฅฏ๐Ÿฅฏ ) โ€” pin one from Settings!

'); + el.widgetPanel.innerHTML = + banner + (cards.join("") || '

no widgets yet ( ๐Ÿฅฏ๐Ÿฅฏ ) โ€” add one from Settings!

'); } function findService(id) { return state.services.find((s) => s.id === id); } -function findPage(id) { - return state.pages.find((p) => p.id === id); +function findWidget(id) { + return state.widgets.find((w) => w.id === id); } /* ---------------------------------------------------------------- auth */ @@ -267,7 +389,7 @@ function openService(service) { if (service.open_in === "newtab") { window.open(service.proxy_path, "_blank"); el.settings.hidden = true; - el.pagePanel.hidden = true; + el.widgetPanel.hidden = true; el.viewerFrame.hidden = true; el.viewerFrame.src = ""; el.reloadBtn.hidden = true; @@ -279,7 +401,7 @@ function openService(service) { el.settings.hidden = true; el.viewer.hidden = false; el.emptyState.hidden = true; - el.pagePanel.hidden = true; + el.widgetPanel.hidden = true; el.viewerFrame.hidden = false; el.reloadBtn.hidden = false; el.viewerTitle.textContent = service.name; @@ -287,27 +409,27 @@ function openService(service) { el.viewerFrame.src = service.proxy_path; } -function openPage(page) { - state.selected = page.id; +function openWidget(widget) { + state.selected = widget.id; renderSidebar(); el.settings.hidden = true; el.viewer.hidden = false; el.emptyState.hidden = true; - el.pagePanel.hidden = false; + el.widgetPanel.hidden = false; el.viewerFrame.hidden = true; el.viewerFrame.src = ""; el.reloadBtn.hidden = true; - el.viewerTitle.textContent = page.name; - el.viewerUrl.textContent = ""; - el.pagePanel.innerHTML = noticeCard(page); + el.viewerTitle.textContent = widget.name; + el.viewerUrl.textContent = widget.base_url || ""; + el.widgetPanel.innerHTML = widgetCard(widget); } // Paint the Home board into the viewer area without touching which section is // active, so a delete performed from Settings leaves Home ready for settingsClose. function showHome() { el.emptyState.hidden = true; - el.pagePanel.hidden = false; + el.widgetPanel.hidden = false; el.viewerFrame.hidden = true; el.viewerFrame.src = ""; el.reloadBtn.hidden = true; @@ -329,7 +451,7 @@ function showSettings() { el.viewer.hidden = true; el.viewerFrame.src = ""; renderTable(); - renderPageTable(); + renderWidgetTable(); renderUsers(); } @@ -357,28 +479,42 @@ function fillForm(service) { el.enabled.checked = service.enabled; } -function resetNoticeForm() { - state.editingPage = null; - el.noticeFormTitle.textContent = "Add notice"; - el.noticeName.value = ""; - el.noticeIcon.value = ""; - el.noticeBody.value = ""; +function resetWidgetForm() { + state.editingWidget = null; + el.widgetFormTitle.textContent = "Add widget"; + el.wName.value = ""; + el.wIcon.value = ""; + el.wKind.value = "notice"; + el.wBody.value = ""; + el.wBase.value = ""; + el.wHeaders.value = ""; + el.wPrefix.value = "/api/v3"; + el.wDays.value = "14"; + el.wDate.value = "auto"; + el.wLimit.value = "12"; } -function fillNoticeForm(page) { - state.editingPage = page.id; - el.noticeFormTitle.textContent = `Edit ${page.name}`; - el.noticeName.value = page.name; - el.noticeIcon.value = page.icon || ""; - el.noticeBody.value = page.body; +function fillWidgetForm(widget) { + state.editingWidget = widget.id; + el.widgetFormTitle.textContent = `Edit ${widget.name}`; + el.wName.value = widget.name; + el.wIcon.value = widget.icon || ""; + el.wKind.value = widget.kind; + el.wBody.value = widget.body || ""; + el.wBase.value = widget.base_url || ""; + el.wHeaders.value = headersToText(widget.headers); + el.wPrefix.value = widget.api_prefix; + el.wDays.value = String(widget.days); + el.wDate.value = widget.date_type; + el.wLimit.value = String(widget.limit); } async function refresh() { state.services = await api("/api/services"); - state.pages = await api("/api/pages"); + state.widgets = await api("/api/widgets"); renderSidebar(); renderTable(); - renderPageTable(); + renderWidgetTable(); } async function saveService(event) { @@ -404,19 +540,26 @@ async function saveService(event) { } } -async function saveNotice(event) { +async function saveWidget(event) { event.preventDefault(); const payload = { - name: el.noticeName.value.trim(), - icon: el.noticeIcon.value.trim(), - body: el.noticeBody.value.trim(), + name: el.wName.value.trim(), + icon: el.wIcon.value.trim(), + kind: el.wKind.value, + body: el.wBody.value.trim() || null, + base_url: el.wBase.value.trim() || null, + headers: textToHeaders(el.wHeaders.value), + api_prefix: el.wPrefix.value.trim() || "/api/v3", + days: Number(el.wDays.value) || 14, + date_type: el.wDate.value, + limit: Number(el.wLimit.value) || 12, }; try { - const saved = state.editingPage - ? await api(`/api/pages/${state.editingPage}`, { method: "PATCH", body: JSON.stringify(payload) }) - : await api("/api/pages", { method: "POST", body: JSON.stringify(payload) }); - toast(state.editingPage ? `Updated ${saved.name}` : `Added ${saved.name}`); - resetNoticeForm(); + const saved = state.editingWidget + ? await api(`/api/widgets/${state.editingWidget}`, { method: "PATCH", body: JSON.stringify(payload) }) + : await api("/api/widgets", { method: "POST", body: JSON.stringify(payload) }); + toast(state.editingWidget ? `Updated ${saved.name}` : `Added ${saved.name}`); + resetWidgetForm(); await refresh(); } catch (error) { toast(error.message, "error"); @@ -438,14 +581,14 @@ async function deleteService(id) { } } -async function deletePage(id) { - const page = findPage(id); - if (!page || !confirm(`Delete ${page.name}?`)) return; +async function deleteWidget(id) { + const widget = findWidget(id); + if (!widget || !confirm(`Delete ${widget.name}?`)) return; try { - await api(`/api/pages/${id}`, { method: "DELETE" }); + await api(`/api/widgets/${id}`, { method: "DELETE" }); const wasViewing = state.selected === id; if (wasViewing) state.selected = HOME; - toast(`Deleted ${page.name}`); + toast(`Deleted ${widget.name}`); await refresh(); if (wasViewing) showHome(); } catch (error) { @@ -467,6 +610,22 @@ async function testService(id, statusEl) { } } +// Bypasses the cache on purpose: a config typo should show up the moment you +// save it, not whenever the TTL happens to expire. +async function testWidget(id, statusEl) { + statusEl.textContent = "fetching..."; + try { + const data = await api(`/api/widgets/${id}/refresh`, { method: "POST" }); + statusEl.textContent = data.error + ? `unreachable: ${data.error.error}` + : `${data.items.length} items`; + statusEl.className = `status ${data.error ? "bad" : "ok"}`; + } catch (error) { + statusEl.textContent = error.message; + statusEl.className = "status bad"; + } +} + /* ---------------------------------------------------------------- wiring */ el.serviceTable.addEventListener("click", (event) => { const button = event.target.closest("button[data-action]"); @@ -483,22 +642,25 @@ el.serviceTable.addEventListener("click", (event) => { } }); -el.pageTable.addEventListener("click", (event) => { +el.widgetTable.addEventListener("click", (event) => { const button = event.target.closest("button[data-action]"); if (!button) return; const id = button.dataset.id; if (button.dataset.action === "edit") { - const page = findPage(id); - if (page) fillNoticeForm(page); + const widget = findWidget(id); + if (widget) fillWidgetForm(widget); } else if (button.dataset.action === "delete") { - deletePage(id); + deleteWidget(id); + } else if (button.dataset.action === "test") { + const statusEl = el.widgetTable.querySelector(`[data-status="${id}"]`); + testWidget(id, statusEl); } }); el.form.addEventListener("submit", saveService); el.formCancel.addEventListener("click", resetForm); -el.noticeForm.addEventListener("submit", saveNotice); -el.noticeCancel.addEventListener("click", resetNoticeForm); +el.widgetForm.addEventListener("submit", saveWidget); +el.widgetCancel.addEventListener("click", resetWidgetForm); el.homeBtn.addEventListener("click", openHome); el.settingsToggle.addEventListener("click", showSettings); el.settingsClose.addEventListener("click", () => { diff --git a/src/uwudash_mk3/static/styles.css b/src/uwudash_mk3/static/styles.css index bca7945..200d66a 100644 --- a/src/uwudash_mk3/static/styles.css +++ b/src/uwudash_mk3/static/styles.css @@ -232,9 +232,9 @@ body { .btn.ghost { background: transparent; color: var(--fg); border-color: var(--border); } .btn:hover { filter: brightness(1.1); } -/* ------------------------------------------------------------------ pages */ +/* ---------------------------------------------------------------- widgets */ .home-btn, -.page-btn { +.widget-btn { display: flex; align-items: center; gap: 10px; @@ -252,16 +252,16 @@ body { border-bottom: 3px solid var(--accent); } .home-btn:hover { filter: brightness(1.1); } -.page-btn { border-left: 3px solid var(--accent); } -.page-btn:hover { border-color: var(--accent); } -.page-btn.active { border-color: var(--accent); background: var(--accent); color: #fff; } +.widget-btn { border-left: 3px solid var(--accent); } +.widget-btn:hover { border-color: var(--accent); } +.widget-btn.active { border-color: var(--accent); background: var(--accent); color: #fff; } .home-btn .icon, -.page-btn .icon { font-size: 16px; } +.widget-btn .icon { font-size: 16px; } .home-btn .label, -.page-btn .label { display: block; font-weight: 600; } -.page-btn .sub { display: block; font-size: 11px; color: var(--muted); } +.widget-btn .label { display: block; font-weight: 600; } +.widget-btn .sub { display: block; font-size: 11px; color: var(--muted); } -.page-panel { flex: 1; overflow: auto; padding: 24px; } +.widget-panel { flex: 1; overflow: auto; padding: 24px; } .home-banner { display: flex; @@ -275,23 +275,41 @@ body { .home-title { font-size: 22px; font-weight: 800; } .home-sub { color: var(--muted); font-size: 13px; } -.notice-card { +.widget-card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; margin-bottom: 12px; } -.notice-card .icon { font-size: 16px; } -.notice-card .notice-title { font-weight: 700; } -.notice-body { white-space: pre-wrap; font-size: 14px; line-height: 1.5; } +.widget-card .icon { font-size: 16px; } +.widget-card .widget-title { font-weight: 700; } +.widget-card.notice .widget-body { white-space: pre-wrap; font-size: 14px; line-height: 1.5; } + +/* live widgets: one merged timeline for calendars, own card for recents */ +.widget-card.live { border-left: 3px solid var(--accent); } +.widget-card.live .widget-meta { color: var(--muted); font-size: 12px; } +.widget-card.live .widget-day { display: flex; flex-direction: column; gap: 8px; margin-top: 10px; } +.widget-card.live .widget-day-head { font-size: 12px; font-weight: 700; color: var(--muted); } +.widget-card.live .widget-item { display: flex; align-items: center; gap: 10px; } +.widget-card.live .widget-thumb { + width: 34px; + height: 50px; + object-fit: cover; + border-radius: 4px; + margin-right: 8px; +} +.widget-card.live .widget-label { font-weight: 600; font-size: 14px; } +.widget-card.live .widget-sub { color: var(--muted); font-size: 12px; } +.widget-card.live .widget-src { font-size: 11px; color: var(--accent); } +.widget-card.live .widget-empty { color: var(--muted); font-size: 13px; margin-top: 8px; } .home-empty { color: var(--muted); font-size: 14px; } -.page-table { display: flex; flex-direction: column; gap: 10px; } -.page-row { +.widget-table { display: flex; flex-direction: column; gap: 10px; } +.widget-row { display: grid; - grid-template-columns: 1fr auto auto; + grid-template-columns: 1fr auto auto auto auto; /* meta + status + test/edit/delete */ gap: 12px; align-items: center; background: var(--panel); @@ -299,9 +317,10 @@ body { border-radius: 10px; padding: 12px 14px; } -.page-row .meta { min-width: 0; } -.page-row .meta strong { display: block; } -.page-row .meta span { color: var(--muted); font-size: 12px; display: block; } +.widget-row .meta { min-width: 0; } +.widget-row .meta strong { display: block; } +.widget-row .meta code { color: var(--muted); font-size: 12px; display: block; } +.widget-row .meta span { color: var(--muted); font-size: 11px; display: block; } /* ----------------------------------------------------------------- toast */ .toast { diff --git a/src/uwudash_mk3/templates/index.html b/src/uwudash_mk3/templates/index.html index 05e17a0..dc9e046 100644 --- a/src/uwudash_mk3/templates/index.html +++ b/src/uwudash_mk3/templates/index.html @@ -38,7 +38,7 @@ - + @@ -89,28 +89,66 @@
-
-

Add notice

+ +

Add widget

+ + + + + + +
- +
-
+
diff --git a/src/uwudash_mk3/widgets.py b/src/uwudash_mk3/widgets.py new file mode 100644 index 0000000..ab1e84d --- /dev/null +++ b/src/uwudash_mk3/widgets.py @@ -0,0 +1,456 @@ +"""Persistence for widgets, plus the server-side fetch layer behind them. + +Two halves live here on purpose: + +``WidgetStore`` is the JSON file (same atomic-write/lock pattern as + ``store.py``). +``WidgetData`` fetches each live widget's upstream (Sonarr/Radarr calendar, + Plex recently-added), normalises it into one shape the UI can + render, and caches it. + +Widgets are **not** wired to the proxied services: a live widget carries its own +``base_url`` and headers, so the upstream only ever has to be reachable from +this process - the browser only ever talks to ``/api/...``. +""" + +from __future__ import annotations + +import json +import os +import threading +import time +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +import httpx +from urllib.parse import quote, urlsplit + +from fastapi import HTTPException + +from .config import Config +from .models import Widget, WidgetInput, WidgetPatch, slugify + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +# Negative results are cached briefly so a broken upstream does not get +# re-fetched on every dashboard load, but a fixed one recovers quickly. +NEGATIVE_TTL = 60.0 +MAX_IMAGE_BYTES = 2 * 1024 * 1024 +MAX_IMAGE_ENTRIES = 64 + +# Radarr's MovieResource carries four possible release dates; "auto" walks this +# order so a movie with only a digital release still lands on the right day. +RADARR_DATES = { + "physical": ("physicalRelease",), + "digital": ("digitalRelease",), + "cinemas": ("inCinemas",), + "release": ("releaseDate",), + "auto": ("physicalRelease", "digitalRelease", "inCinemas", "releaseDate"), +} +RADARR_DATE_LABELS = { + "physicalRelease": "physical", + "digitalRelease": "digital", + "inCinemas": "in cinemas", + "releaseDate": "release", +} + + +class WidgetStore: + def __init__(self, path: Path, legacy: Optional[Path] = None) -> None: + self.path = Path(path) + self._lock = threading.Lock() + self.path.parent.mkdir(parents=True, exist_ok=True) + if not self.path.exists(): + self._write(self._migrate(legacy) if legacy else []) + + # ------------------------------------------------------------------ read + def _read(self) -> List[Widget]: + try: + raw = json.loads(self.path.read_text(encoding="utf-8")) + except FileNotFoundError: + return [] + except json.JSONDecodeError as exc: + raise ValueError(f"{self.path} is not valid JSON: {exc}") from exc + items = raw.get("widgets", []) if isinstance(raw, dict) else raw + return [Widget.model_validate(item) for item in items] + + def _write(self, widgets: List[Widget]) -> None: + payload = { + "version": 1, + "widgets": [w.model_dump() for w in widgets], + } + tmp = self.path.with_suffix(".tmp") + tmp.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + os.replace(tmp, self.path) + + # ---------------------------------------------------------- legacy notices + def _migrate(self, legacy: Optional[Path]) -> List[Widget]: + """Seed from the old ``pages.json`` so pinned notices survive renaming.""" + try: + raw = json.loads(Path(legacy).read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError): + return [] + items = raw.get("pages", []) if isinstance(raw, dict) else raw + migrated = [] + for item in items: + item.setdefault("kind", "notice") + try: + migrated.append(Widget.model_validate(item)) + except ValueError: + continue + if migrated: + os.replace(Path(legacy), Path(legacy).with_suffix(".json.migrated")) + return migrated + + def list(self) -> List[Widget]: + with self._lock: + return self._read() + + def get(self, widget_id: str) -> Optional[Widget]: + with self._lock: + for widget in self._read(): + if widget.id == widget_id: + return widget + return None + + # ----------------------------------------------------------------- write + def create(self, data: WidgetInput) -> Widget: + with self._lock: + widgets = self._read() + base = slugify(data.name) + candidate = base + suffix = 1 + existing = {w.id for w in widgets} + while candidate in existing: + suffix += 1 + candidate = f"{base}-{suffix}" + now = _now() + widget = Widget( + **data.model_dump(), id=candidate, created_at=now, updated_at=now + ) + widgets.append(widget) + self._write(widgets) + return widget + + def update(self, widget_id: str, patch: WidgetPatch) -> Optional[Widget]: + with self._lock: + widgets = self._read() + for index, widget in enumerate(widgets): + if widget.id != widget_id: + continue + updates = {k: v for k, v in patch.model_dump().items() if v is not None} + if not updates: + return widget + # Re-validate rather than model_copy: a partial update could + # otherwise leave a calendar widget without a base_url. + merged = widget.model_dump() + merged.update(updates) + updated = Widget(**merged) + updated.updated_at = _now() + widgets[index] = updated + self._write(widgets) + return updated + return None + + def delete(self, widget_id: str) -> bool: + with self._lock: + widgets = self._read() + remaining = [w for w in widgets if w.id != widget_id] + if len(remaining) == len(widgets): + return False + self._write(remaining) + return True + + +class WidgetData: + """Fetch + normalise + cache the live widgets' upstreams.""" + + def __init__(self, store: WidgetStore, config: Config) -> None: + self.store = store + self.config = config + self.client = httpx.AsyncClient( + timeout=config.proxy_timeout, + follow_redirects=False, + headers={"user-agent": "uwudash-mk3/0.1"}, + ) + # key: "@" so editing a widget invalidates it. + self._cache: Dict[str, Tuple[float, dict]] = {} + self._images: Dict[str, Tuple[bytes, str]] = {} + + async def close(self) -> None: + await self.client.aclose() + + # --------------------------------------------------------------- helpers + @staticmethod + def _origin(url: str) -> str: + parts = urlsplit(url) + return f"{parts.scheme}://{parts.netloc}" + + def _endpoint(self, widget: Widget) -> str: + base = widget.base_url.rstrip("/") + if widget.kind == "calendar": + return f"{base}{widget.api_prefix.rstrip('/')}/calendar" + return f"{base}/library/recentlyAdded" + + def _params(self, widget: Widget) -> Dict[str, str]: + if widget.kind != "calendar": + return {} + today = datetime.now() + end = today + timedelta(days=widget.days) + return {"start": today.strftime("%Y-%m-%d"), "end": end.strftime("%Y-%m-%d")} + + def _headers(self, widget: Widget) -> Dict[str, str]: + headers = dict(widget.headers) + if widget.kind == "recent": + # Plex answers XML unless it is told otherwise. + headers.setdefault("Accept", "application/json") + return headers + + def _relay(self, widget: Widget, url: str) -> str: + return f"/api/widgets/{widget.id}/image?u={quote(url, safe='')}" + + # ---------------------------------------------------------- normalisation + @staticmethod + def _iso_date(value: object) -> Optional[str]: + if isinstance(value, (int, float)) or (isinstance(value, str) and value.isdigit()): + seconds = float(value) + # Plex's addedAt is epoch *milliseconds* (Sonarr/Radarr send ISO + # strings); anything past ~3160 years in seconds is a ms value. + if seconds >= 1e11: + seconds /= 1000.0 + try: + return datetime.fromtimestamp(seconds).strftime("%Y-%m-%d") + except (OverflowError, ValueError, OSError): + return None + if isinstance(value, str) and len(value) >= 10: + head = value[:10] + if head[4] == "-" and head[7] == "-": + return head + return None + + @staticmethod + def _items(payload: object) -> list: + if isinstance(payload, list): + return payload + if isinstance(payload, dict): + for key in ("items", "events", "movies", "episodes"): + if isinstance(payload.get(key), list): + return payload[key] + return [] + + def _calendar_rows(self, widget: Widget, payload: object) -> List[dict]: + rows = [] + for item in self._items(payload): + if not isinstance(item, dict): + continue + if "airDate" in item: # Sonarr EpisodeResource + date = self._iso_date(item.get("airDate")) + if not date: + continue + season, episode = item.get("seasonNumber"), item.get("episodeNumber") + bits = [] + if season is not None and episode is not None: + bits.append(f"S{season} E{episode}") + if item.get("episodeTitle"): + bits.append(str(item["episodeTitle"])) + rows.append( + { + "date": date, + "label": str(item.get("seriesTitle") or item.get("title") or "?"), + "sub": " ยท ".join(bits), + "type": "episode", + "source": widget.name, + "thumb": None, + } + ) + continue + # Radarr MovieResource: pick the release date the widget asked for. + field, date = None, None + for candidate in RADARR_DATES.get(widget.date_type, RADARR_DATES["auto"]): + date = self._iso_date(item.get(candidate)) + if date: + field = candidate + break + if not date: + continue + year = item.get("year") + label = str(item.get("title") or "?") + if year: + label = f"{label} ({year})" + rows.append( + { + "date": date, + "label": label, + "sub": RADARR_DATE_LABELS.get(field, ""), + "type": "movie", + "source": widget.name, + "thumb": self._thumb(widget, self._radarr_image(item)), + } + ) + rows.sort(key=lambda row: row["date"]) + return rows + + def _recent_rows(self, widget: Widget, payload: object) -> List[dict]: + rows = [] + for item in self._items(payload): + if not isinstance(item, dict): + continue + date = self._iso_date(item.get("addedAt")) + if not date: + continue + bits = [] + if item.get("type"): + bits.append(str(item["type"])) + if item.get("year"): + bits.append(str(item["year"])) + if item.get("parentTitle"): + index = f"{item.get('parentIndex', '')}x{item.get('index', '')}" + bits.append(f"{item['parentTitle']} {index}") + rows.append( + { + "date": date, + "label": str(item.get("title") or "?"), + "sub": " ยท ".join(bits), + "type": str(item.get("type") or "added"), + "source": widget.name, + "thumb": self._thumb(widget, item.get("thumb")), + } + ) + rows.sort(key=lambda row: row["date"], reverse=True) + return rows + + @staticmethod + def _radarr_image(item: dict) -> Optional[str]: + images = item.get("images") + if not isinstance(images, list): + return None + urls = [img.get("url") for img in images if isinstance(img, dict) and img.get("url")] + for img in images: + if isinstance(img, dict) and img.get("coverType") in ("Poster", "Main"): + return img["url"] + return urls[0] if urls else None + + def _thumb(self, widget: Widget, url: Optional[str]) -> Optional[str]: + if not url or not isinstance(url, str): + return None + return self._relay(widget, url) + + # ------------------------------------------------------------------ fetch + async def data(self, widget: Widget, refresh: bool = False) -> dict: + ttl = float(self.config.widget_cache_ttl) + key = f"{widget.id}@{widget.updated_at}" + now = time.monotonic() + if not refresh: + hit = self._cache.get(key) + if hit and now < hit[0]: + return hit[1] + for stale in [k for k, (expiry, _) in self._cache.items() if expiry <= now]: + del self._cache[stale] + + payload = await self._fetch(widget) + expiry = now + (ttl if payload["error"] is None else min(ttl, NEGATIVE_TTL)) + self._cache[key] = (expiry, payload) + return payload + + async def _fetch(self, widget: Widget) -> dict: + fetched_at = _now() + ttl = float(self.config.widget_cache_ttl) + if not widget.base_url: + return {"fetched_at": fetched_at, "ttl_seconds": ttl, "error": None, "items": []} + + try: + upstream = await self.client.request( + "GET", + self._endpoint(widget), + params=self._params(widget), + headers=self._headers(widget), + ) + except httpx.HTTPError as exc: + return { + "fetched_at": fetched_at, + "ttl_seconds": ttl, + "error": {"reachable": False, "status": None, "error": str(exc)}, + "items": [], + } + + if upstream.status_code >= 300: + return { + "fetched_at": fetched_at, + "ttl_seconds": ttl, + "error": { + "reachable": True, + "status": upstream.status_code, + "error": f"upstream answered {upstream.status_code}", + }, + "items": [], + } + + try: + payload = upstream.json() + except ValueError: + return { + "fetched_at": fetched_at, + "ttl_seconds": ttl, + "error": { + "reachable": True, + "status": upstream.status_code, + "error": f"expected JSON, got {upstream.headers.get('content-type', '?')}", + }, + "items": [], + } + + rows = ( + self._calendar_rows(widget, payload) + if widget.kind == "calendar" + else self._recent_rows(widget, payload) + ) + return {"fetched_at": fetched_at, "ttl_seconds": ttl, "error": None, "items": rows[: widget.limit]} + + # ------------------------------------------------------------- image relay + async def image(self, widget: Widget, url: str) -> Tuple[bytes, str]: + """Fetch an upstream artwork URL, but only if it is on that upstream. + + The browser cannot reach the internal host, so every ``thumb`` in a + payload is a ``/api/widgets/{id}/image?u=...`` URL: this is the only + same-origin way to show artwork, and it must never become a general + URL fetcher. + """ + origin = self._origin(widget.base_url) + target = url if url.startswith(("http://", "https://")) else f"{origin}/{url.lstrip('/')}" + if self._origin(target) != origin: + raise HTTPException( + status_code=404, + detail="image is not on the widget's own origin", + ) + + cached = self._images.get(target) + if cached: + return cached + + try: + upstream = await self.client.request( + "GET", target, headers={**self._headers(widget), "Accept": "image/*"} + ) + except httpx.HTTPError as exc: + raise HTTPException(status_code=502, detail=f"upstream {origin} unreachable: {exc}") from exc + + if upstream.status_code >= 300: + raise HTTPException(status_code=404, detail=f"no image at {target.rsplit('/', 1)[-1]}") + + media_type = upstream.headers.get("content-type", "") + if not media_type.startswith("image/"): + raise HTTPException( + status_code=415, + detail=f"upstream served {media_type or '?'}, not an image", + ) + if len(upstream.content) > MAX_IMAGE_BYTES: + raise HTTPException(status_code=413, detail="image too large") + + if len(self._images) >= MAX_IMAGE_ENTRIES: + self._images.clear() + self._images[target] = (upstream.content, media_type) + return upstream.content, media_type