Replace notice pages with widgets: pinned text, Sonarr/Radarr calendars, Plex recently-added
WidgetStore keeps the JSON file (same atomic-write/lock pattern as store.py) and
migrates any old pages.json in place. WidgetData fetches each live widget's own
base_url server-side, normalises Sonarr/Radarr/Plex payloads into one row shape,
caches per <id>@<updated_at> behind UWUDASH_WIDGET_CACHE_TTL, and relays artwork
through /api/widgets/{id}/image so the browser never needs the internal host.
Home now renders one merged calendar timeline, one card per recent widget, then
notices; the Settings table carries a Test button that bypasses the cache.
This commit is contained in:
1 parent
298ef9dbf5
commit
24dd017905
12 files changed
+1050
-302
No files matched your search
+1
-1
@@ -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
|
||||
|
||||
@@ -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` | `<data-dir>/services.json` | explicit store path |
|
||||
| `--pages-file` | `<data-dir>/pages.json` | explicit notice store path |
|
||||
| `--widgets-file` | `<data-dir>/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 (`<base_url>/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
|
||||
`<id>@<updated_at>`, 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: <error>` 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
|
||||
|
||||
+80
-29
@@ -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")
|
||||
|
||||
+10
-5
@@ -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 []
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
+249
-87
@@ -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 = '<p class="status">No services configured yet.</p>';
|
||||
if (!state.services.length && !state.widgets.length) {
|
||||
el.serviceList.innerHTML = '<p class="status">No services or widgets yet.</p>';
|
||||
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 =
|
||||
`<span class="icon">${esc(page.icon || "📌")}</span>` +
|
||||
`<span><span class="label">${esc(page.name)}</span>` +
|
||||
`<span class="sub">${esc(page.body.slice(0, 60))}</span></span>`;
|
||||
button.addEventListener("click", () => openPage(page));
|
||||
`<span class="icon">${esc(widget.icon || WIDGET_ICONS[widget.kind])}</span>` +
|
||||
`<span><span class="label">${esc(widget.name)}</span>` +
|
||||
`<span class="sub">${esc(widgetSub(widget))}</span></span>`;
|
||||
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 = '<p class="status">No notices yet - pin one above.</p>';
|
||||
function renderWidgetTable() {
|
||||
el.widgetTable.innerHTML = "";
|
||||
if (!state.widgets.length) {
|
||||
el.widgetTable.innerHTML = '<p class="status">No widgets yet - add one on the left.</p>';
|
||||
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 =
|
||||
`<div class="meta"><strong>${esc(page.name)}</strong>` +
|
||||
`<span>${esc(page.body.slice(0, 80))}</span></div>` +
|
||||
`<button class="btn ghost" data-action="edit" data-id="${esc(page.id)}">Edit</button>` +
|
||||
`<button class="btn ghost" data-action="delete" data-id="${esc(page.id)}">Delete</button>`;
|
||||
el.pageTable.appendChild(row);
|
||||
`<div class="meta"><strong>${esc(widget.name)}</strong>` +
|
||||
`<code>${esc(detail)}</code>` +
|
||||
`<span>${esc(widget.kind)}</span></div>` +
|
||||
`<span class="status" data-status="${esc(widget.id)}">${esc(status)}</span>` +
|
||||
(live
|
||||
? `<button class="btn ghost" data-action="test" data-id="${esc(widget.id)}">Test</button>`
|
||||
: "") +
|
||||
`<button class="btn ghost" data-action="edit" data-id="${esc(widget.id)}">Edit</button>` +
|
||||
`<button class="btn ghost" data-action="delete" data-id="${esc(widget.id)}">Delete</button>`;
|
||||
el.widgetTable.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function noticeCard(page) {
|
||||
function noticeCard(widget) {
|
||||
return (
|
||||
`<div class="notice-card">` +
|
||||
`<span class="icon">${esc(page.icon || "📌")}</span>` +
|
||||
`<div class="notice-title">${esc(page.name)}</div>` +
|
||||
`<div class="notice-body">${esc(page.body)}</div>` +
|
||||
`<div class="widget-card notice">` +
|
||||
`<span class="icon">${widgetIcon(widget)}</span>` +
|
||||
`<div class="widget-title">${esc(widget.name)}</div>` +
|
||||
`<div class="widget-body">${esc(widget.body || "")}</div>` +
|
||||
`</div>`
|
||||
);
|
||||
}
|
||||
|
||||
function itemRow(item) {
|
||||
const thumb = item.thumb
|
||||
? `<img class="widget-thumb" src="${esc(item.thumb)}" alt="${esc(item.label)}" loading="lazy" />`
|
||||
: "";
|
||||
return (
|
||||
`<div class="widget-item">` +
|
||||
thumb +
|
||||
`<span class="widget-label">${esc(item.label)}</span>` +
|
||||
(item.sub ? `<span class="widget-sub">${esc(item.sub)}</span>` : "") +
|
||||
`<span class="widget-src">${esc(item.source || "")}</span>` +
|
||||
`</div>`
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
`<div class="widget-card live">` +
|
||||
`<span class="icon">${widgetIcon(widgets[0])}</span>` +
|
||||
`<div class="widget-title">${esc(title)}</div>` +
|
||||
`<div class="widget-meta">${esc(meta)}</div>` +
|
||||
days.map(
|
||||
(day) =>
|
||||
`<div class="widget-day">` +
|
||||
`<div class="widget-day-head">${esc(dayLabel(day.date))}</div>` +
|
||||
day.items.map(itemRow).join("") +
|
||||
`</div>`
|
||||
).join("") +
|
||||
(days.length ? "" : `<div class="widget-empty">${esc(meta || "nothing in the window")}</div>`) +
|
||||
`</div>`
|
||||
);
|
||||
}
|
||||
|
||||
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 =
|
||||
`<div class="home-banner">` +
|
||||
`<div class="home-title">Home (uwu)</div>` +
|
||||
`<div class="home-sub">the notice board — pin anything the crew should know</div>` +
|
||||
`<div class="home-sub">widgets — release calendars, what just landed, and anything pinned</div>` +
|
||||
`</div>`;
|
||||
const cards = state.pages.slice().reverse().map(noticeCard).join("");
|
||||
el.pagePanel.innerHTML =
|
||||
banner + (cards || '<p class="home-empty">no notices yet ( 🥯🥯 ) — pin one from Settings!</p>');
|
||||
el.widgetPanel.innerHTML =
|
||||
banner + (cards.join("") || '<p class="home-empty">no widgets yet ( 🥯🥯 ) — add one from Settings!</p>');
|
||||
}
|
||||
|
||||
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", () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
<button class="btn ghost" id="reload-btn" hidden>Reload</button>
|
||||
</header>
|
||||
<iframe id="viewer-frame" title="Service view" hidden></iframe>
|
||||
<div class="page-panel" id="page-panel" hidden></div>
|
||||
<div class="widget-panel" id="widget-panel" hidden></div>
|
||||
<div class="empty" id="empty-state" hidden>
|
||||
<p id="empty-text">Pick a service from the sidebar, or add one in Settings.</p>
|
||||
</div>
|
||||
@@ -89,28 +89,66 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form id="notice-form" class="card form" autocomplete="off">
|
||||
<h2 id="notice-form-title">Add notice</h2>
|
||||
<form id="widget-form" class="card form" autocomplete="off">
|
||||
<h2 id="widget-form-title">Add widget</h2>
|
||||
<label>
|
||||
Name
|
||||
<input id="n-name" required maxlength="120" placeholder="Server status" />
|
||||
<input id="w-name" required maxlength="120" placeholder="Upcoming" />
|
||||
</label>
|
||||
<label>
|
||||
Icon
|
||||
<input id="n-icon" maxlength="8" placeholder="emoji or initials" />
|
||||
<input id="w-icon" maxlength="8" placeholder="emoji or initials" />
|
||||
</label>
|
||||
<label>
|
||||
Body
|
||||
<textarea id="n-body" required rows="6" placeholder="Anything the crew should know"></textarea>
|
||||
Kind
|
||||
<select id="w-kind">
|
||||
<option value="notice" selected>notice</option>
|
||||
<option value="calendar">calendar</option>
|
||||
<option value="recent">recently added</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Body (notices)
|
||||
<textarea id="w-body" rows="5" placeholder="Anything the crew should know"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
Source URL (live widgets)
|
||||
<input id="w-base" placeholder="http://10.6.9.204:8989" />
|
||||
</label>
|
||||
<label>
|
||||
Headers (one per line, <code>Name: value</code>)
|
||||
<textarea id="w-headers" rows="2" placeholder="X-Api-Key: ..."></textarea>
|
||||
</label>
|
||||
<label>
|
||||
API prefix
|
||||
<input id="w-prefix" maxlength="32" placeholder="/api/v3" />
|
||||
</label>
|
||||
<label>
|
||||
Window (days)
|
||||
<input id="w-days" type="number" min="1" max="90" placeholder="14" />
|
||||
</label>
|
||||
<label>
|
||||
Release date
|
||||
<select id="w-date">
|
||||
<option value="auto" selected>auto</option>
|
||||
<option value="physical">physical</option>
|
||||
<option value="digital">digital</option>
|
||||
<option value="cinemas">cinemas</option>
|
||||
<option value="release">release</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Limit
|
||||
<input id="w-limit" type="number" min="1" max="50" placeholder="12" />
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn">Save</button>
|
||||
<button type="button" class="btn ghost" id="notice-cancel">Cancel</button>
|
||||
<button type="button" class="btn ghost" id="widget-cancel">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="service-table" id="service-table"></div>
|
||||
<div class="page-table" id="page-table"></div>
|
||||
<div class="widget-table" id="widget-table"></div>
|
||||
<div class="user-table" id="user-table" hidden></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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: "<widget-id>@<updated-at>" 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
|
||||
Reference in new issue
Block a user