Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d5cfa7986 | ||
|
|
f6b1133f4a | ||
|
|
a60c1ff60e | ||
|
|
362be36552 |
@@ -1,6 +1,6 @@
|
||||
# Meta-Stremio
|
||||
|
||||
Stremio addon for the MetaMesh stack. Reads file metadata from the shared KV store, reads files via WebDAV (or direct mount), and serves HLS with on-the-fly FFmpeg transcoding plus the standard Stremio addon protocol.
|
||||
Stremio addon for the MetaMesh stack. Reads file metadata from meta-core's HTTP API (no direct Redis access), reads files via meta-core's WebDAV (or a direct mount), and serves HLS with on-the-fly FFmpeg transcoding plus the standard Stremio addon protocol.
|
||||
|
||||
External port in the dev stack: **8182** (auth-gated, via Caddy + nginx-hash-lock). Internal container port is `7000`. Debug-direct port (bypasses the perimeter): **18182**. Container name: `metastremio-app` (backend) / `metastremio` (hash-lock proxy).
|
||||
|
||||
@@ -18,8 +18,8 @@ External port in the dev stack: **8182** (auth-gated, via Caddy + nginx-hash-loc
|
||||
│ │ │ │ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Shared KV Store (Redis) │ │
|
||||
│ │ meta-core is leader, exposes /urls + /meta APIs │ │
|
||||
│ │ meta-core (owns Redis; /urls + /meta APIs) │ │
|
||||
│ │ located over UDP multicast (beacon v2) │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
@@ -34,7 +34,8 @@ External port in the dev stack: **8182** (auth-gated, via Caddy + nginx-hash-loc
|
||||
- **HLS transcoding** — adaptive preset+CRF targeting a 60–80% transcode ratio, prefetch of N segments ahead, persistent segment cache.
|
||||
- **Stremio protocol** — manifest, catalog, meta, stream; one HLS stream per audio track; subtitles extracted to VTT.
|
||||
- **Direct file serving** — range-request capable, for clients that can play the source directly.
|
||||
- **Storage abstractions** — `LeaderStorage` (auto-discovers Redis via meta-core), `RedisStorage` (direct URL), `direct` mode (reads from meta-core's HTTP API + SSE meta-stream).
|
||||
- **meta-core over HTTP** — `LeaderStorage` locates meta-core via **beacon v2** (UDP multicast `239.255.99.1:9099`; see [`beacon-v2.md`](../../docs/project-architecture/beacon-v2.md)) or a pinned `META_CORE_URL`, reads every record through meta-core's `/meta/*` API, and keeps its cache fresh from the SSE meta-stream. The WebDAV URL is taken from meta-core's announce/`/urls` — nothing is configured by hand and no `/meta-core` volume is mounted. (`RedisStorage` remains in `src/storage/` as a legacy, unused backend; meta-core no longer publishes a Redis URL.)
|
||||
- **Neighbour nav** — this service announces itself on the discovery group and serves its own neighbour map at `/api/neighbors` for the dashboard's shared `<meta-service-menu>`.
|
||||
- **Path-token gate for addon URLs** — when `HASH_API_SEED` is set, addon paths must be prefixed with `/s/<token>`. Dashboard/browser paths are protected separately by the upstream hash-lock + OIDC sidecar (Stremio clients can't carry cookies, so they get the path-token instead).
|
||||
- **Language-configurable manifest** — `displayLanguage` in the per-install config (base64-in-path) selects which `titles` translation meta-sort's TMDB plugin populated.
|
||||
|
||||
@@ -54,23 +55,17 @@ This brings up `metastremio-app` on port 18182 (debug-direct) and the auth-gated
|
||||
```bash
|
||||
docker build -t meta-stremio packages/meta-stremio
|
||||
|
||||
# Leader mode (auto-discover Redis via meta-core)
|
||||
# Against an existing meta-core (found over UDP on the same Docker network,
|
||||
# or pinned with META_CORE_URL)
|
||||
docker run -d \
|
||||
-p 8182:7000 \
|
||||
-v /path/to/media:/files:ro \
|
||||
-v /path/to/meta-core:/meta-core \
|
||||
meta-stremio
|
||||
|
||||
# Direct Redis mode
|
||||
docker run -d \
|
||||
-p 8182:7000 \
|
||||
-v /path/to/media:/files:ro \
|
||||
-e STORAGE_MODE=redis \
|
||||
-e REDIS_URL=redis://your-redis:6379 \
|
||||
-e STORAGE_MODE=direct \
|
||||
-e META_CORE_URL=http://metacore-app:9000 \
|
||||
meta-stremio
|
||||
```
|
||||
|
||||
The container always listens on port `7000` internally — map it to whatever you want externally.
|
||||
The container always listens on port `7000` internally — map it to whatever you want externally. The image bundles a `meta-core` binary (copied from `ghcr.io/worph/meta-core`, overridable with `--build-arg META_CORE_IMAGE=…`): with the image default `STORAGE_MODE=leader`, `docker/start.sh` starts it as an in-container sidecar first; any other value (the dev stack and the CasaOS app use `direct`) skips it and uses an external meta-core. Published images: `ghcr.io/worph/meta-stremio` (semver tags from `v*` git tags; in this repo `:latest` follows `main` — see `.github/workflows/docker-publish.yml`); the CasaOS app is `MetaStremio` in [`MetaAppStore`](../MetaAppStore/Apps/MetaStremio/docker-compose.yml).
|
||||
|
||||
### Standalone (development)
|
||||
|
||||
@@ -78,7 +73,7 @@ The container always listens on port `7000` internally — map it to whatever yo
|
||||
# Requires Python 3.9+ and FFmpeg.
|
||||
pip install -r requirements.txt # redis, watchdog, Pillow, requests
|
||||
cd src
|
||||
python server.py
|
||||
python server.py # finds meta-core over UDP, or set META_CORE_URL=<meta-core API URL>
|
||||
```
|
||||
|
||||
## Configuration
|
||||
@@ -91,18 +86,20 @@ python server.py
|
||||
| `BASE_URL` | — | External URL emitted in manifests / poster URLs (e.g. `https://metastremio-dev.localhost:8182`). |
|
||||
| `MEDIA_DIR` | `/files/watch` | Media directory (consumed by `transcoder.py`). |
|
||||
| `CACHE_DIR` | `/data/cache` | Transcoded segment cache. |
|
||||
| `STORAGE_MODE` | `leader` | `leader`, `redis`, or `direct`. |
|
||||
| `META_CORE_PATH` | `/meta-core` | Shared meta-core volume; used by `LeaderClient` for `kv-leader.info`. |
|
||||
| `META_CORE_URL` | — | meta-core HTTP API (e.g. `http://metacore-app:9000`); required for `STORAGE_MODE=direct` since there is no leader file to read. |
|
||||
| `FILES_PATH` | `/files` | Files volume. |
|
||||
| `REDIS_URL` | `redis://localhost:6379` | Redis URL for `STORAGE_MODE=redis`. |
|
||||
| `REDIS_PREFIX` | `meta-sort:` | Redis key prefix. |
|
||||
| `PUBLIC_URL` | — | Browser-facing URL announced to neighbours (nav menu); wins over `BASE_URL` for the announce. |
|
||||
| `STORAGE_MODE` | `leader` (image) | Read only by `docker/start.sh`: `leader` starts the bundled meta-core sidecar in-container, anything else (e.g. `direct`) skips it. The Python server always uses `LeaderStorage`. |
|
||||
| `META_CORE_URL` | — | Pins meta-core's HTTP API (e.g. `http://metacore-app:9000`); when set, UDP discovery never overrides it. Unset → meta-core is located over UDP. |
|
||||
| `ENABLE_UDP_DISCOVERY` | `true` | beacon v2 announce/listen. |
|
||||
| `LEADER_WAIT_TIMEOUT` | `0` | Seconds to wait for meta-core at startup (`0` = forever); on timeout, keeps retrying in the background. |
|
||||
| `LEADER_RETRY_INTERVAL` | `5` | Seconds between meta-core lookup attempts. |
|
||||
| `FILES_PATH` | `/files` | Files volume (local fallback when WebDAV is not configured). |
|
||||
| `SCHEME` | `auto` | URL scheme for generated URLs: `http`, `https`, or `auto`. |
|
||||
| `SEGMENT_DURATION` | `4` | HLS segment length (s). |
|
||||
| `PREFETCH_SEGMENTS` | `4` | Segments to prefetch ahead. |
|
||||
| `META_CORE_WEBDAV_URL` | — | When set, files are read over HTTP from meta-core's WebDAV instead of the filesystem (lets meta-stremio reach SMB / rclone mounts that only exist inside meta-core). |
|
||||
| `HASH_API_SEED` | — | When set, derives a 16-char path-token; all addon routes must be prefixed with `/s/<token>`. Dashboard routes bypass. |
|
||||
|
||||
`META_CORE_PATH`, `REDIS_URL` and `REDIS_PREFIX` are still set by the Dockerfile but are vestigial: meta-core is no longer found through the `/meta-core` volume, and reads go over meta-core's HTTP API rather than Redis.
|
||||
|
||||
## API endpoints
|
||||
|
||||
All routes below are matched in `src/server.py`. When `HASH_API_SEED` is set, **every addon path is prefixed with `/s/<token>`**; the dashboard paths in the first table are *not* prefixed (they're protected by hash-lock/OIDC upstream).
|
||||
@@ -116,8 +113,11 @@ All routes below are matched in `src/server.py`. When `HASH_API_SEED` is set, **
|
||||
| `/health` | GET | Liveness + storage status |
|
||||
| `/api/stats` | GET | Library statistics |
|
||||
| `/api/library` | GET | Full library list (videos + count) |
|
||||
| `/api/services` | GET | Discovered MetaMesh services (proxied from `meta-core /services`) |
|
||||
| `/api/neighbors` | GET | This peer's UDP neighbour map (beacon v2) for the nav menu |
|
||||
| `/api/services` | GET | Alias of `/api/neighbors` (kept for older dashboards) |
|
||||
| `/api/languages` | GET | Languages selectable from the `/configure` page |
|
||||
| `/meta-service-menu.js` | GET | Shared `<meta-service-menu>` nav element loaded by the dashboard |
|
||||
| `/transcode/metrics` | GET | Transcoder metrics (also listed below; exempt from the path-token) |
|
||||
|
||||
### Stremio addon protocol
|
||||
|
||||
@@ -178,20 +178,22 @@ meta-stremio/
|
||||
│ └── storage/
|
||||
│ ├── __init__.py
|
||||
│ ├── provider.py # StorageProvider + VideoMetadata abstract base
|
||||
│ ├── redis_storage.py # Direct Redis backend
|
||||
│ ├── leader_storage.py # Leader-aware Redis backend
|
||||
│ ├── leader_client.py # Reads /meta-core/locks/kv-leader.info + calls meta-core /urls
|
||||
│ ├── leader_storage.py # Storage backend in use: meta-core HTTP API + SSE cache invalidation
|
||||
│ ├── leader_client.py # Locates meta-core (UDP or META_CORE_URL pin), exposes its /urls set
|
||||
│ ├── meshdisco.py # beacon v2 (Python port of the Go reference in meta-core)
|
||||
│ ├── meta_consumer.py # SSE consumer from meta-core /meta stream
|
||||
│ ├── meta_core_api_client.py # HTTP client for meta-core REST API
|
||||
│ └── service_registration.py # Registers meta-stremio with meta-core /services
|
||||
│ └── redis_storage.py # Legacy direct-Redis backend (unused)
|
||||
└── www/
|
||||
├── index.html # Dashboard
|
||||
└── configure.html # Language configuration page
|
||||
└── meta-service-menu.js # Shared nav element
|
||||
```
|
||||
|
||||
The `/configure` page is rendered inline by `server.py` (there is no `www/configure.html`).
|
||||
|
||||
## KV store schema
|
||||
|
||||
meta-stremio reads from the per-file Redis hash `/file/{cid}` populated by meta-sort and its plugins. Field semantics (`videoType`, `originalTitle`, `titles`, `season`, `episode`, `movieYear`, `cid_*`, etc.) are documented in the repo-root [`METADATA_KEYS.md`](../../METADATA_KEYS.md) — that is the single source of truth, and matches the `@metazla/meta-interface` types.
|
||||
meta-stremio reads the per-file record `/file/{cid}` (via meta-core's API) populated by meta-sort and its plugins. Field semantics (`videoType`, `originalTitle`, `titles`, `season`, `episode`, `movieYear`, `cid_*`, etc.) are documented in the repo-root [`METADATA_KEYS.md`](../../METADATA_KEYS.md) — that is the single source of truth, and matches the `@metazla/meta-interface` types.
|
||||
|
||||
## Stream types
|
||||
|
||||
@@ -199,14 +201,15 @@ For each video, the addon advertises multiple streams:
|
||||
|
||||
1. **Direct file** — original bytes, served via `/direct/...` with `Range` support. Best quality, may not play on every device.
|
||||
2. **HLS original** — transcoded at source resolution, H.264/AAC, one stream per audio track.
|
||||
3. **HLS ABR** — adaptive ladder. Quality presets are baked into `transcoder.py`.
|
||||
3. **HLS ABR** — adaptive ladder (rungs above the source height are skipped). Presets are baked into `transcoder.py`: video is x264 **CRF-based** (the adaptive controller shifts preset and CRF offset at runtime), audio is AAC 128 kbps stereo. `BANDWIDTH` is only the hint written to the master playlist.
|
||||
|
||||
| Resolution | Video bitrate | Audio bitrate |
|
||||
| Rung | Base CRF | Master-playlist `BANDWIDTH` hint |
|
||||
|---|---|---|
|
||||
| 1080p | 5000 kbps | 192 kbps |
|
||||
| 720p | 3000 kbps | 128 kbps |
|
||||
| 480p | 1500 kbps | 128 kbps |
|
||||
| 360p | 800 kbps | 96 kbps |
|
||||
| original | 23 | — |
|
||||
| 1080p | 23 | 4000 kbps |
|
||||
| 720p | 24 | 2500 kbps |
|
||||
| 480p | 25 | 1200 kbps |
|
||||
| 360p | 26 | 800 kbps |
|
||||
|
||||
## Adding to Stremio
|
||||
|
||||
@@ -234,8 +237,9 @@ Container lifecycle: do **not** `docker restart metastremio-app` to apply config
|
||||
## Related projects
|
||||
|
||||
- **[meta-sort](../meta-sort)** — file indexer; writes the metadata meta-stremio reads.
|
||||
- **[meta-fuse](../meta-fuse)** — virtual filesystem; reads the same KV store.
|
||||
- **[meta-share](../meta-share)** — decentralised metadata sharing.
|
||||
- **[meta-core](../meta-core)** — owns Redis; the HTTP API + WebDAV meta-stremio reads from.
|
||||
- **[meta-fuse](../meta-fuse)** — virtual filesystem; reads the same records.
|
||||
- **[meta-watch](../meta-watch)** — browser video client for the meta-share network (server-side zero media work, unlike this addon's transcoder).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+65
-28
@@ -35,7 +35,6 @@ import threading
|
||||
|
||||
import stremio
|
||||
import transcoder
|
||||
from storage import init_service_discovery, get_service_discovery
|
||||
import fileserver
|
||||
import webdav_client
|
||||
|
||||
@@ -58,18 +57,23 @@ API_PREFIX = f'/s/{API_KEY}' if API_KEY else ''
|
||||
# upstream by hash-lock/OIDC instead of the path-token.
|
||||
DASHBOARD_PATHS = frozenset({
|
||||
'/', '/index.html', '/configure', '/health',
|
||||
'/api/stats', '/api/library', '/api/services', '/api/languages',
|
||||
'/api/stats', '/api/library', '/api/services', '/api/neighbors', '/api/languages',
|
||||
# The dashboard's shared nav element, loaded as a module from index.html.
|
||||
'/meta-service-menu.js',
|
||||
'/transcode/metrics',
|
||||
})
|
||||
|
||||
# Initialize storage
|
||||
storage = stremio.init_storage()
|
||||
|
||||
# Initialize service discovery (only if META_CORE_PATH exists)
|
||||
# Service discovery is UDP now (beacon v2), so there is no
|
||||
# META_CORE_PATH gate: the leader client's node announces this service on the
|
||||
# same multicast group it listens on. Being discoverable and discovering are
|
||||
# one thing. See docs/project-architecture/service-discovery.md.
|
||||
service_discovery = None
|
||||
if os.path.exists(META_CORE_PATH):
|
||||
try:
|
||||
service_discovery = init_service_discovery(base_url=BASE_URL if BASE_URL else None)
|
||||
from storage.leader_client import get_leader_client
|
||||
get_leader_client().start_watching()
|
||||
except Exception as e:
|
||||
print(f"[Server] Service discovery init failed: {e}")
|
||||
|
||||
@@ -252,6 +256,13 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if path == '/' or path == '/index.html':
|
||||
return self.serve_setup_page()
|
||||
|
||||
# The one static asset under www/ that the dashboard loads as a module:
|
||||
# the shared <meta-service-menu> element. There is no generic static
|
||||
# route here (index.html is string-injected on every request), so this
|
||||
# is an explicit allowlist of one rather than an open file server.
|
||||
if path == '/meta-service-menu.js':
|
||||
return self.serve_www_asset('meta-service-menu.js', 'text/javascript')
|
||||
|
||||
# Configure page
|
||||
if path == '/configure':
|
||||
return self.serve_configure_page()
|
||||
@@ -287,7 +298,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
})
|
||||
|
||||
# Discovered services (for inter-service navigation)
|
||||
if path == '/api/services':
|
||||
if path == '/api/services' or path == '/api/neighbors':
|
||||
return self.handle_services_api()
|
||||
|
||||
# === File API ===
|
||||
@@ -368,6 +379,22 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
self.send_error(404)
|
||||
|
||||
def serve_www_asset(self, name, content_type):
|
||||
"""Serve one named file from www/ verbatim (no template injection)."""
|
||||
for base in (os.path.dirname(__file__), os.path.dirname(os.path.dirname(__file__))):
|
||||
candidate = os.path.join(base, 'www', name)
|
||||
if os.path.exists(candidate):
|
||||
with open(candidate, 'rb') as f:
|
||||
body = f.read()
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', content_type)
|
||||
self.send_header('Content-Length', len(body))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
return
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
def serve_setup_page(self):
|
||||
"""Serve the setup/dashboard page."""
|
||||
html_path = os.path.join(os.path.dirname(__file__), 'www', 'index.html')
|
||||
@@ -627,35 +654,42 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.wfile.write(content.encode('utf-8'))
|
||||
|
||||
def handle_services_api(self):
|
||||
"""Return list of discovered services for dashboard navigation.
|
||||
"""Neighbours for the dashboard nav, from this peer's own beacon v2 view.
|
||||
|
||||
Fetches services from meta-core (single source of truth) instead of
|
||||
doing local filesystem-based discovery.
|
||||
No hop through meta-core any more: every service keeps its own
|
||||
neighbour table, so the nav still renders when meta-core is down.
|
||||
`?all=1` returns every instance, `?cap=<pattern>` filters by
|
||||
capability (docs/project-architecture/beacon-v2.md). `services` is kept
|
||||
as an alias of `neighbors` so the existing dashboards keep working.
|
||||
"""
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from storage.leader_client import get_leader_client
|
||||
from urllib.request import urlopen
|
||||
from urllib.error import URLError
|
||||
|
||||
try:
|
||||
leader_client = get_leader_client()
|
||||
leader_info = leader_client.get_leader_info() if leader_client else None
|
||||
|
||||
if leader_info and leader_info.api_url:
|
||||
# Fetch services from meta-core API
|
||||
url = f"{leader_info.api_url}/services?current=meta-stremio"
|
||||
with urlopen(url, timeout=5) as response:
|
||||
data = json.loads(response.read().decode())
|
||||
return self.send_json(data)
|
||||
except URLError as e:
|
||||
print(f"[Server] Error fetching services from meta-core: {e}")
|
||||
query = parse_qs(urlparse(self.path).query)
|
||||
client = get_leader_client()
|
||||
neighbors = client.neighbors(
|
||||
all=bool(query.get('all')),
|
||||
cap=(query.get('cap') or [None])[0],
|
||||
) if client else []
|
||||
return self.send_json({
|
||||
'current': 'meta-stremio',
|
||||
'enabled': True,
|
||||
'count': len(neighbors),
|
||||
'neighbors': neighbors,
|
||||
'services': neighbors,
|
||||
'self': client.self_announce() if client else None,
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"[Server] Error in handle_services_api: {e}")
|
||||
|
||||
# Fallback: return empty list if meta-core is unavailable
|
||||
return self.send_json({
|
||||
'services': [],
|
||||
'current': 'meta-stremio',
|
||||
'enabled': False,
|
||||
'count': 0,
|
||||
'neighbors': [],
|
||||
'services': [],
|
||||
})
|
||||
|
||||
def handle_file(self, cid: str, width: int = None):
|
||||
@@ -906,11 +940,14 @@ def shutdown_handler(signum, frame):
|
||||
"""Handle graceful shutdown."""
|
||||
print("\n[Server] Shutting down...")
|
||||
|
||||
# Stop service discovery
|
||||
# Stop service discovery. close() emits a final "stopping" announce so
|
||||
# neighbours drop us immediately instead of waiting out the staleness
|
||||
# window.
|
||||
try:
|
||||
sd = get_service_discovery()
|
||||
if sd:
|
||||
sd.stop()
|
||||
from storage.leader_client import get_leader_client
|
||||
client = get_leader_client()
|
||||
if client:
|
||||
client.close()
|
||||
except Exception as e:
|
||||
print(f"[Server] Error stopping service discovery: {e}")
|
||||
|
||||
|
||||
@@ -13,15 +13,6 @@ from .provider import StorageProvider, VideoMetadata
|
||||
from .redis_storage import RedisStorage
|
||||
from .leader_storage import LeaderStorage
|
||||
from .leader_client import LeaderClient, LeaderLockInfo, get_leader_client
|
||||
from .service_registration import (
|
||||
ServiceRegistration,
|
||||
ServiceInfo,
|
||||
get_service_discovery, # Kept for backward compatibility
|
||||
init_service_discovery # Kept for backward compatibility
|
||||
)
|
||||
|
||||
# Backward compatibility alias
|
||||
ServiceDiscovery = ServiceRegistration
|
||||
|
||||
__all__ = [
|
||||
'StorageProvider',
|
||||
@@ -31,9 +22,4 @@ __all__ = [
|
||||
'LeaderClient',
|
||||
'LeaderLockInfo',
|
||||
'get_leader_client',
|
||||
'ServiceRegistration',
|
||||
'ServiceDiscovery', # Backward compatibility alias
|
||||
'ServiceInfo',
|
||||
'get_service_discovery',
|
||||
'init_service_discovery',
|
||||
]
|
||||
|
||||
+182
-222
@@ -1,40 +1,41 @@
|
||||
"""LeaderClient — locates meta-core and exposes its URLs.
|
||||
|
||||
Since UDP discovery (meta-discovery v1, now beacon v2) this is a thin adapter
|
||||
over ``MetaCoreLocator`` (``meshdisco.py``): meta-core is found by UDP advertise
|
||||
instead of by reading ``/meta-core/locks/kv-leader.info``, so meta-stremio no
|
||||
longer needs the ``/meta-core`` volume mounted at all.
|
||||
|
||||
The public surface is deliberately unchanged so ``LeaderStorage`` did not have
|
||||
to move: ``get_leader_info``, ``get_urls``, ``get_api_url``,
|
||||
``get_webdav_url*``, ``wait_for_leader``, ``start_watching`` / ``on_change``
|
||||
(the watchdog on the lock directory became "an announce arrived carrying a
|
||||
different apiUrl", feeding the same callback).
|
||||
|
||||
Precedence is the pin: when ``meta_core_url`` (``META_CORE_URL``) is set it
|
||||
always wins and the wire is never consulted for core selection. See
|
||||
docs/project-architecture/service-discovery.md.
|
||||
|
||||
The name "leader" is legacy: meta-core's flock election is vestigial, and
|
||||
``redis_url`` has been empty since the api-mediated-access lockdown — every
|
||||
read goes over HTTP.
|
||||
"""
|
||||
LeaderClient - Client for reading leader info from meta-core
|
||||
|
||||
This replaces the old LeaderDiscovery.py by delegating leader election
|
||||
to meta-core (Go sidecar) and just reading the results.
|
||||
|
||||
Features:
|
||||
- Reads kv-leader.info file for leader URLs
|
||||
- Calls meta-core /urls API for current leader URLs
|
||||
- Watches for leader changes via file system watcher
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import json
|
||||
import threading
|
||||
import os
|
||||
import time
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional, Callable
|
||||
from urllib.request import urlopen, Request
|
||||
from urllib.error import URLError
|
||||
from typing import Callable, List, Optional
|
||||
|
||||
# Try to import watchdog for file watching (optional)
|
||||
try:
|
||||
from watchdog.observers import Observer
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
WATCHDOG_AVAILABLE = True
|
||||
except ImportError:
|
||||
WATCHDOG_AVAILABLE = False
|
||||
Observer = None
|
||||
FileSystemEventHandler = object
|
||||
from .meshdisco import MetaCoreLocator
|
||||
|
||||
|
||||
@dataclass
|
||||
class LeaderLockInfo:
|
||||
"""Leader lock file content format (kv-leader.info).
|
||||
Written by meta-core leader, read by other services.
|
||||
"""meta-core's identity and URLs.
|
||||
|
||||
Historically the parsed kv-leader.info + /urls pair; now assembled from the
|
||||
announce (or from /urls when meta-core is pinned).
|
||||
"""
|
||||
hostname: str
|
||||
base_url: str
|
||||
@@ -53,140 +54,133 @@ class URLsResponse:
|
||||
base_url: str
|
||||
api_url: str
|
||||
redis_url: str
|
||||
webdav_url: str # External WebDAV URL (via nginx/HTTPS)
|
||||
webdav_url_internal: str # Internal WebDAV URL (direct to port 9000)
|
||||
webdav_url: str
|
||||
webdav_url_internal: str
|
||||
is_leader: bool
|
||||
|
||||
|
||||
class LockFileHandler(FileSystemEventHandler):
|
||||
"""File system event handler for lock file changes."""
|
||||
|
||||
def __init__(self, callback: Callable[[], None], lock_filename: str):
|
||||
self.callback = callback
|
||||
self.lock_filename = lock_filename
|
||||
|
||||
def on_modified(self, event):
|
||||
if event.src_path.endswith(self.lock_filename):
|
||||
self.callback()
|
||||
|
||||
def on_created(self, event):
|
||||
if event.src_path.endswith(self.lock_filename):
|
||||
self.callback()
|
||||
|
||||
def on_deleted(self, event):
|
||||
if event.src_path.endswith(self.lock_filename):
|
||||
self.callback()
|
||||
|
||||
|
||||
class LeaderClient:
|
||||
"""
|
||||
LeaderClient for meta-stremio.
|
||||
|
||||
Reads leader info from kv-leader.info file written by meta-core.
|
||||
"""
|
||||
"""Locates meta-core over UDP and resolves its URL set."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
meta_core_path: str,
|
||||
meta_core_url: Optional[str] = None
|
||||
):
|
||||
self.meta_core_path = meta_core_path
|
||||
self.meta_core_url = meta_core_url
|
||||
meta_core_path: Optional[str] = None,
|
||||
meta_core_url: Optional[str] = None,
|
||||
service_name: str = "meta-stremio",
|
||||
base_url: Optional[str] = None,
|
||||
version: str = "",
|
||||
) -> None:
|
||||
# meta_core_path is accepted and ignored — kept so existing callers
|
||||
# construct unchanged. Nothing reads the volume any more.
|
||||
self._meta_core_path = meta_core_path
|
||||
self._meta_core_url = (meta_core_url or "").strip() or None
|
||||
self._leader_info: Optional[LeaderLockInfo] = None
|
||||
self._on_change_callbacks: List[Callable[[], None]] = []
|
||||
self._started = False
|
||||
|
||||
self.info_file_path = os.path.join(meta_core_path, 'locks', 'kv-leader.info')
|
||||
self.leader_info: Optional[LeaderLockInfo] = None
|
||||
|
||||
self._file_observer = None
|
||||
self._on_change_callbacks: list[Callable[[], None]] = []
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
# URL caching
|
||||
# Only used on the pinned path, where the URLs still come from an HTTP
|
||||
# GET /urls. A discovered core carries them in the announce itself.
|
||||
self._cached_urls: Optional[URLsResponse] = None
|
||||
self._urls_cache_time: float = 0
|
||||
self._urls_cache_ttl: float = 5.0 # 5 seconds
|
||||
self._urls_cache_time: float = 0.0
|
||||
self._urls_cache_ttl: float = 5.0
|
||||
|
||||
def _get_api_url_from_file(self) -> Optional[str]:
|
||||
"""Read API URL from file (plain text format)."""
|
||||
try:
|
||||
with open(self.info_file_path, 'r') as f:
|
||||
content = f.read()
|
||||
return content.strip() or None
|
||||
except FileNotFoundError:
|
||||
print(f"[LeaderClient] Leader info file not found: {self.info_file_path}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"[LeaderClient] Error reading API URL from file: {e}")
|
||||
return None
|
||||
self._locator = MetaCoreLocator(
|
||||
service_name=service_name,
|
||||
base_url=base_url,
|
||||
version=version,
|
||||
meta_core_url=self._meta_core_url,
|
||||
)
|
||||
self._locator.on_change(self._on_locator_change)
|
||||
|
||||
# -- lifecycle ---------------------------------------------------------
|
||||
|
||||
def _ensure_started(self) -> None:
|
||||
if self._started:
|
||||
return
|
||||
self._started = True
|
||||
self._locator.start()
|
||||
|
||||
def _on_locator_change(self) -> None:
|
||||
self._cached_urls = None
|
||||
self._urls_cache_time = 0.0
|
||||
self._notify_change()
|
||||
|
||||
# -- url resolution ----------------------------------------------------
|
||||
|
||||
def _assert_no_redis_url(self, redis_url: Optional[str]) -> None:
|
||||
"""Guard against a rollback reintroducing direct Redis exposure.
|
||||
|
||||
api-mediated-access PR D removed redis_url; ALLOW_LEGACY_REDIS_URL=1
|
||||
downgrades this to a warning during a deliberate temporary rollback.
|
||||
"""
|
||||
if not redis_url:
|
||||
return
|
||||
msg = (
|
||||
"meta-core still publishes redisUrl; direct Redis access was retired "
|
||||
"by the api-mediated-access lockdown. Verify meta-core version."
|
||||
)
|
||||
if os.environ.get("ALLOW_LEGACY_REDIS_URL") == "1":
|
||||
print(f"[LeaderClient] WARNING: {msg}")
|
||||
else:
|
||||
raise RuntimeError(msg)
|
||||
|
||||
def _fetch_urls(self, api_url: str) -> Optional[URLsResponse]:
|
||||
"""Fetch URLs from meta-core /urls API with caching."""
|
||||
import time
|
||||
|
||||
# Check cache
|
||||
now = time.time()
|
||||
if self._cached_urls and (now - self._urls_cache_time) < self._urls_cache_ttl:
|
||||
return self._cached_urls
|
||||
|
||||
try:
|
||||
url = f"{api_url}/urls"
|
||||
req = Request(url, headers={'Accept': 'application/json'})
|
||||
with urlopen(req, timeout=5) as response:
|
||||
if response.status != 200:
|
||||
print(f"[LeaderClient] Failed to fetch URLs: {response.status}")
|
||||
return None
|
||||
|
||||
data = json.loads(response.read().decode())
|
||||
|
||||
# api-mediated-access PR D: redisUrl is no longer published by
|
||||
# meta-core. If it shows up, an old build snuck back in —
|
||||
# fail loudly so the rollback can't go unnoticed. Set
|
||||
# ALLOW_LEGACY_REDIS_URL=1 to downgrade to a warning during
|
||||
# a deliberate temporary rollback.
|
||||
if data.get('redisUrl'):
|
||||
msg = (
|
||||
"[LeaderClient] meta-core still publishes redisUrl; "
|
||||
"direct Redis access was retired by the api-mediated-access "
|
||||
"lockdown. Verify meta-core version."
|
||||
req = urllib.request.Request(
|
||||
f"{api_url.rstrip('/')}/urls", headers={"Accept": "application/json"}
|
||||
)
|
||||
if os.environ.get("ALLOW_LEGACY_REDIS_URL") == "1":
|
||||
print("WARNING: " + msg)
|
||||
else:
|
||||
raise RuntimeError(msg)
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
self._assert_no_redis_url(data.get("redisUrl"))
|
||||
self._cached_urls = URLsResponse(
|
||||
hostname=data.get('hostname', ''),
|
||||
base_url=data.get('baseUrl', ''),
|
||||
api_url=data.get('apiUrl', ''),
|
||||
redis_url=data.get('redisUrl', ''),
|
||||
webdav_url=data.get('webdavUrl', ''),
|
||||
webdav_url_internal=data.get('webdavUrlInternal', ''),
|
||||
is_leader=data.get('isLeader', False)
|
||||
hostname=data.get("hostname", ""),
|
||||
base_url=data.get("baseUrl", ""),
|
||||
api_url=data.get("apiUrl", ""),
|
||||
redis_url=data.get("redisUrl", "") or "",
|
||||
webdav_url=data.get("webdavUrl", ""),
|
||||
webdav_url_internal=data.get("webdavUrlInternal", ""),
|
||||
is_leader=bool(data.get("isLeader", True)),
|
||||
)
|
||||
self._urls_cache_time = now
|
||||
return self._cached_urls
|
||||
|
||||
except URLError as e:
|
||||
except Exception as e:
|
||||
print(f"[LeaderClient] Error calling /urls API: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"[LeaderClient] Error fetching URLs: {e}")
|
||||
return None
|
||||
|
||||
def get_leader_info(self) -> Optional[LeaderLockInfo]:
|
||||
"""Read leader info from file and /urls API."""
|
||||
try:
|
||||
# Read API URL from file (plain text)
|
||||
api_url = self._get_api_url_from_file()
|
||||
def get_urls(self) -> Optional[URLsResponse]:
|
||||
"""Resolve meta-core's URLs.
|
||||
|
||||
Straight from the announce when discovered (no HTTP hop needed); over
|
||||
HTTP when meta-core is pinned.
|
||||
"""
|
||||
self._ensure_started()
|
||||
|
||||
wire = self._locator.get_urls()
|
||||
if wire and not self._meta_core_url:
|
||||
return URLsResponse(
|
||||
hostname=wire.get("hostname", ""),
|
||||
base_url=wire.get("baseUrl", ""),
|
||||
api_url=wire.get("apiUrl", ""),
|
||||
redis_url="",
|
||||
webdav_url=wire.get("webdavUrl", ""),
|
||||
webdav_url_internal=wire.get("webdavUrlInternal", ""),
|
||||
is_leader=True,
|
||||
)
|
||||
|
||||
api_url = self._locator.get_api_url()
|
||||
if not api_url:
|
||||
return None
|
||||
return self._fetch_urls(api_url)
|
||||
|
||||
# Fetch full info from /urls API
|
||||
urls = self._fetch_urls(api_url)
|
||||
def get_leader_info(self) -> Optional[LeaderLockInfo]:
|
||||
try:
|
||||
urls = self.get_urls()
|
||||
if not urls:
|
||||
return None
|
||||
|
||||
# Convert URLsResponse to LeaderLockInfo
|
||||
import time
|
||||
self.leader_info = LeaderLockInfo(
|
||||
self._leader_info = LeaderLockInfo(
|
||||
hostname=urls.hostname,
|
||||
base_url=urls.base_url,
|
||||
api_url=urls.api_url,
|
||||
@@ -194,145 +188,111 @@ class LeaderClient:
|
||||
webdav_url=urls.webdav_url,
|
||||
webdav_url_internal=urls.webdav_url_internal,
|
||||
timestamp=int(time.time() * 1000),
|
||||
pid=0 # Unknown for remote leader
|
||||
pid=0,
|
||||
)
|
||||
|
||||
return self.leader_info
|
||||
|
||||
return self._leader_info
|
||||
except Exception as e:
|
||||
print(f"[LeaderClient] Error reading leader info: {e}")
|
||||
print(f"[LeaderClient] Failed to read leader info: {e}")
|
||||
return None
|
||||
|
||||
def get_redis_url(self) -> Optional[str]:
|
||||
"""Get Redis URL from leader info."""
|
||||
info = self.get_leader_info()
|
||||
return info.redis_url if info else None
|
||||
|
||||
def get_webdav_url(self) -> Optional[str]:
|
||||
"""Get WebDAV URL from leader info (external, via nginx/HTTPS)."""
|
||||
info = self.get_leader_info()
|
||||
return info.webdav_url if info else None
|
||||
|
||||
def get_webdav_url_internal(self) -> Optional[str]:
|
||||
"""Get internal WebDAV URL from leader info (direct to port 9000).
|
||||
Use this for container-to-container communication.
|
||||
"""
|
||||
"""Internal WebDAV URL — use for container-to-container access."""
|
||||
info = self.get_leader_info()
|
||||
return info.webdav_url_internal if info else None
|
||||
|
||||
def get_api_url(self) -> Optional[str]:
|
||||
"""Get meta-core API URL from leader info."""
|
||||
info = self.get_leader_info()
|
||||
return info.api_url if info else None
|
||||
|
||||
def get_urls(self) -> Optional[URLsResponse]:
|
||||
"""Call meta-core /urls API to get current URLs."""
|
||||
# First try using configured meta_core_url
|
||||
api_url = self.meta_core_url
|
||||
|
||||
# Fall back to reading from file
|
||||
if not api_url:
|
||||
api_url = self._get_api_url_from_file()
|
||||
|
||||
if not api_url:
|
||||
print("[LeaderClient] No meta-core API URL available")
|
||||
return None
|
||||
|
||||
return self._fetch_urls(api_url)
|
||||
|
||||
def wait_for_leader(self, timeout_ms: int = 30000) -> LeaderLockInfo:
|
||||
"""Wait for leader info to be available."""
|
||||
import time
|
||||
start_time = time.time()
|
||||
poll_interval = 0.5 # 500ms
|
||||
"""Block until meta-core is reachable.
|
||||
|
||||
while (time.time() - start_time) * 1000 < timeout_ms:
|
||||
Probes immediately then every 500ms, matching the previous
|
||||
file-polling loop's timing.
|
||||
"""
|
||||
self._ensure_started()
|
||||
timeout_s = None if timeout_ms <= 0 else timeout_ms / 1000.0
|
||||
deadline = None if timeout_s is None else time.time() + timeout_s
|
||||
|
||||
if self._locator.wait_for_core(timeout_s) is None:
|
||||
raise TimeoutError(f"No meta-core found within {timeout_ms}ms")
|
||||
|
||||
while deadline is None or time.time() < deadline:
|
||||
info = self.get_leader_info()
|
||||
if info:
|
||||
print(f"[LeaderClient] Leader found: {info.hostname} at {info.redis_url}")
|
||||
print(f"[LeaderClient] meta-core found: {info.hostname} at {info.api_url}")
|
||||
return info
|
||||
time.sleep(0.5)
|
||||
raise TimeoutError(f"No meta-core found within {timeout_ms}ms")
|
||||
|
||||
print("[LeaderClient] Waiting for leader...")
|
||||
time.sleep(poll_interval)
|
||||
|
||||
raise TimeoutError(f"No leader found within {timeout_ms}ms")
|
||||
# -- change notification ----------------------------------------------
|
||||
|
||||
def start_watching(self) -> None:
|
||||
"""Start watching for leader changes."""
|
||||
if not WATCHDOG_AVAILABLE:
|
||||
print("[LeaderClient] watchdog not available, file watching disabled")
|
||||
return
|
||||
"""Previously a watchdog on the lock directory.
|
||||
|
||||
try:
|
||||
lock_dir = os.path.dirname(self.info_file_path)
|
||||
|
||||
handler = LockFileHandler(
|
||||
callback=self._on_file_change,
|
||||
lock_filename='kv-leader.info'
|
||||
)
|
||||
|
||||
self._file_observer = Observer()
|
||||
self._file_observer.schedule(handler, lock_dir, recursive=False)
|
||||
self._file_observer.start()
|
||||
print(f"[LeaderClient] Watching for leader changes in {lock_dir}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[LeaderClient] Could not start file watcher: {e}")
|
||||
Discovery is always listening, so this only has to make sure the node
|
||||
is running.
|
||||
"""
|
||||
self._ensure_started()
|
||||
|
||||
def stop_watching(self) -> None:
|
||||
"""Stop watching for leader changes."""
|
||||
if self._file_observer:
|
||||
self._file_observer.stop()
|
||||
self._file_observer.join(timeout=2.0)
|
||||
self._file_observer = None
|
||||
print("[LeaderClient] Stopped watching for leader changes")
|
||||
"""No-op: the locator keeps listening until close()."""
|
||||
|
||||
def _on_file_change(self) -> None:
|
||||
"""Handle file change event."""
|
||||
print("[LeaderClient] Leader info changed, invalidating cache...")
|
||||
# Invalidate cache to force fresh API call
|
||||
self._cached_urls = None
|
||||
self._urls_cache_time = 0
|
||||
|
||||
self.get_leader_info()
|
||||
self._notify_change()
|
||||
|
||||
def on_change(self, callback: Callable[[], None]) -> 'LeaderClient':
|
||||
"""Register callback for leader changes."""
|
||||
def on_change(self, callback: Callable[[], None]) -> "LeaderClient":
|
||||
self._on_change_callbacks.append(callback)
|
||||
return self
|
||||
|
||||
def _notify_change(self) -> None:
|
||||
"""Notify all change callbacks."""
|
||||
for callback in self._on_change_callbacks:
|
||||
print("[LeaderClient] meta-core changed, invalidating cache...")
|
||||
for cb in list(self._on_change_callbacks):
|
||||
try:
|
||||
callback()
|
||||
cb()
|
||||
except Exception as e:
|
||||
print(f"[LeaderClient] Error in change callback: {e}")
|
||||
|
||||
def get_cached_leader_info(self) -> Optional[LeaderLockInfo]:
|
||||
"""Get cached leader info (without re-reading file)."""
|
||||
return self.leader_info
|
||||
return self._leader_info
|
||||
|
||||
# -- nav menu ----------------------------------------------------------
|
||||
|
||||
def neighbors(self, all: bool = False, cap: Optional[str] = None) -> List[dict]:
|
||||
"""Neighbours for /api/neighbors: one row per name (every instance with
|
||||
``all``), optionally only nodes with a capability matching ``cap``."""
|
||||
self._ensure_started()
|
||||
return self._locator.neighbors(all=all, cap=cap)
|
||||
|
||||
def self_announce(self) -> dict:
|
||||
"""This service's own announce, so the menu can show itself."""
|
||||
self._ensure_started()
|
||||
return self._locator.self_announce()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Clean up resources."""
|
||||
self._stop_event.set()
|
||||
self.stop_watching()
|
||||
self._on_change_callbacks = []
|
||||
self._locator.stop()
|
||||
self._started = False
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_leader_client: Optional[LeaderClient] = None
|
||||
|
||||
|
||||
def get_leader_client() -> Optional[LeaderClient]:
|
||||
"""Get or create the LeaderClient singleton.
|
||||
|
||||
Returns None if META_CORE_PATH is not set.
|
||||
"""
|
||||
"""Process-wide singleton, so one UDP socket serves every caller."""
|
||||
global _leader_client
|
||||
if _leader_client is None:
|
||||
meta_core_path = os.environ.get('META_CORE_PATH', '/meta-core')
|
||||
meta_core_url = os.environ.get('META_CORE_URL')
|
||||
_leader_client = LeaderClient(meta_core_path, meta_core_url)
|
||||
_leader_client = LeaderClient(
|
||||
meta_core_path=os.environ.get("META_CORE_PATH"),
|
||||
meta_core_url=os.environ.get("META_CORE_URL"),
|
||||
# PUBLIC_URL (a browser-reachable debug-direct port) wins over
|
||||
# BASE_URL (the Caddy URL), same ladder as meta-core. Announcing a
|
||||
# Caddy URL on a stack with no Caddy puts a dead link in every menu.
|
||||
base_url=os.environ.get("PUBLIC_URL") or os.environ.get("BASE_URL") or None,
|
||||
)
|
||||
return _leader_client
|
||||
|
||||
@@ -18,7 +18,7 @@ import time
|
||||
from typing import List, Optional, Callable
|
||||
|
||||
from .provider import StorageProvider, VideoMetadata
|
||||
from .leader_client import LeaderClient, LeaderLockInfo
|
||||
from .leader_client import LeaderClient, LeaderLockInfo, get_leader_client
|
||||
from .meta_consumer import MetaConsumer
|
||||
from .meta_core_api_client import MetaCoreApiClient
|
||||
|
||||
@@ -97,10 +97,11 @@ class LeaderStorage(StorageProvider):
|
||||
self._connect_to_redis(self._override_url)
|
||||
return
|
||||
|
||||
# Use leader client
|
||||
self._leader_client = LeaderClient(
|
||||
meta_core_path=self._meta_core_path
|
||||
)
|
||||
# Use the process-wide leader client so meta-stremio holds ONE UDP
|
||||
# discovery socket, shared with server.py's neighbours endpoint.
|
||||
# META_CORE_URL, when set, pins meta-core and discovery never
|
||||
# overrides it — see docs/project-architecture/service-discovery.md.
|
||||
self._leader_client = get_leader_client()
|
||||
|
||||
# Set up change callback
|
||||
self._leader_client.on_change(self._on_leader_change)
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
"""Beacon v2 — local resource advertise / discover over UDP multicast
|
||||
(239.255.99.1:9099).
|
||||
|
||||
Every node advertises resources tagged with capability strings
|
||||
(``metamesh.core``, ``metamesh.service/meta-stremio``,
|
||||
``metamesh.transport/nzb@1``) and keeps a live view of everyone else's. This
|
||||
service uses it to locate meta-core (the ``metamesh.core`` resource carries its
|
||||
/urls block) and to feed the nav menu, with no shared volume.
|
||||
|
||||
The normative spec is docs/project-architecture/beacon-v2.md.
|
||||
|
||||
Two details this module must get right, both of which fail silently:
|
||||
|
||||
1. **Per-interface fan-out.** A socket that joins with ``INADDR_ANY`` and sends
|
||||
via the default route covers exactly one interface. Containers here are
|
||||
routinely attached to two docker networks, so we enumerate interfaces and
|
||||
join/send on each using a hand-packed ``ip_mreqn``.
|
||||
2. **The pin wins.** When ``META_CORE_URL`` is set, the wire is never consulted
|
||||
for core selection — otherwise a container that can see two cores could
|
||||
latch onto the wrong one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable, Dict, List, Optional
|
||||
|
||||
PROTO = "beacon"
|
||||
VERSION = 2
|
||||
DEFAULT_GROUP = "239.255.99.1"
|
||||
DEFAULT_PORT = 9099
|
||||
DEFAULT_INTERVAL = 10.0
|
||||
# A node unheard for interval * this is dropped (evaluated at read time).
|
||||
LIVENESS_FACTOR = 3
|
||||
READ_BUFFER = 8192
|
||||
# Senders stay under this so nothing fragments.
|
||||
MAX_DATAGRAM = 1400
|
||||
|
||||
TYPE_PROBE = "probe"
|
||||
TYPE_ADVERTISE = "advertise"
|
||||
TYPE_BYE = "bye"
|
||||
|
||||
CAP_CORE = "metamesh.core"
|
||||
CAP_SERVICE_PREFIX = "metamesh.service/"
|
||||
CAP_ANY_SERVICE = "metamesh.service/*"
|
||||
|
||||
|
||||
def cap_matches(pattern: str, cap: str) -> bool:
|
||||
"""Does capability ``cap`` satisfy ``pattern``?
|
||||
|
||||
``*`` matches everything; an ``@N`` on the pattern must equal the cap's
|
||||
contract (none ignores it); ``x/*`` matches any variant of ``x``; otherwise
|
||||
the bases must be equal.
|
||||
"""
|
||||
if pattern == "*":
|
||||
return True
|
||||
pbase, _, pc = pattern.rpartition("@") if "@" in pattern else (pattern, "", None)
|
||||
cbase, _, cc = cap.rpartition("@") if "@" in cap else (cap, "", None)
|
||||
if pc is not None and cc != pc:
|
||||
return False
|
||||
if pbase.endswith("/*"):
|
||||
prefix = pbase[:-2]
|
||||
if not cbase.startswith(prefix):
|
||||
return False
|
||||
rest = cbase[len(prefix):]
|
||||
return rest.startswith("/") and len(rest) > 1
|
||||
return pbase == cbase
|
||||
|
||||
|
||||
def resource_matches(resource: dict, pattern: str) -> bool:
|
||||
return any(cap_matches(pattern, c) for c in resource.get("caps") or [])
|
||||
|
||||
|
||||
def resource_endpoint(resource: dict, name: str) -> Optional[str]:
|
||||
"""The named endpoint as an absolute URL ('/…' joins onto endpoints.http)."""
|
||||
endpoints = resource.get("endpoints") or {}
|
||||
v = endpoints.get(name)
|
||||
if not v:
|
||||
return None
|
||||
if not v.startswith("/"):
|
||||
return v
|
||||
base = endpoints.get("http")
|
||||
return base.rstrip("/") + v if base else None
|
||||
|
||||
|
||||
def parse_message(data: bytes) -> Optional[dict]:
|
||||
"""Decode a datagram; None for anything that is not well-formed beacon v2
|
||||
(beacon v1, meta-discovery v1, other versions, junk)."""
|
||||
try:
|
||||
m = json.loads(data.decode("utf-8"))
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return None
|
||||
if not isinstance(m, dict) or m.get("proto") != PROTO or m.get("v") != VERSION:
|
||||
return None
|
||||
t = m.get("type")
|
||||
if t == TYPE_PROBE:
|
||||
return m
|
||||
if t in (TYPE_ADVERTISE, TYPE_BYE):
|
||||
node = m.get("node")
|
||||
if not isinstance(node, dict) or not node.get("name") or not node.get("instance"):
|
||||
return None
|
||||
return m
|
||||
return None
|
||||
|
||||
|
||||
def _neighbor_row(node: dict, resources: List[dict], addr: str, last_seen: float) -> dict:
|
||||
caps: List[str] = []
|
||||
base_url = None
|
||||
for r in resources:
|
||||
for c in r.get("caps") or []:
|
||||
if c not in caps:
|
||||
caps.append(c)
|
||||
if base_url is None and resource_matches(r, CAP_ANY_SERVICE):
|
||||
base_url = resource_endpoint(r, "ui")
|
||||
row = dict(node)
|
||||
row.update({"resources": resources, "caps": caps, "addr": addr, "lastSeen": last_seen})
|
||||
if base_url:
|
||||
row["baseUrl"] = base_url
|
||||
return row
|
||||
|
||||
|
||||
def _env_flag(name: str, default: bool = True) -> bool:
|
||||
raw = os.environ.get(name)
|
||||
if raw is None or raw == "":
|
||||
return default
|
||||
return raw.strip().lower() not in ("false", "0", "no", "off")
|
||||
|
||||
|
||||
def _interface_indexes() -> List[tuple]:
|
||||
"""(name, index, ipv4) for every up, non-loopback interface.
|
||||
|
||||
Falls back to a single unspecified entry when the platform cannot
|
||||
enumerate, so discovery still works on the default route.
|
||||
"""
|
||||
out: List[tuple] = []
|
||||
try:
|
||||
for idx, name in socket.if_nameindex():
|
||||
if name == "lo":
|
||||
continue
|
||||
try:
|
||||
# Cheap way to get the interface's IPv4 without netifaces.
|
||||
addr = _ipv4_of(name)
|
||||
except OSError:
|
||||
continue
|
||||
if addr:
|
||||
out.append((name, idx, addr))
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def _ipv4_of(ifname: str) -> Optional[str]:
|
||||
"""SIOCGIFADDR — avoids a netifaces dependency."""
|
||||
import fcntl
|
||||
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
packed = struct.pack("256s", ifname[:15].encode("utf-8"))
|
||||
return socket.inet_ntoa(fcntl.ioctl(s.fileno(), 0x8915, packed)[20:24])
|
||||
except OSError:
|
||||
return None
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def local_ipv4() -> str:
|
||||
"""First non-loopback IPv4, else the hostname. Matches the Go helper."""
|
||||
ifaces = _interface_indexes()
|
||||
if ifaces:
|
||||
return ifaces[0][2]
|
||||
return socket.gethostname()
|
||||
|
||||
|
||||
class MeshNode:
|
||||
"""One beacon v2 participant: advertises its resources, answers probes,
|
||||
and keeps a TTL map of every other node."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
instance: Optional[str] = None,
|
||||
version: str = "",
|
||||
group: Optional[str] = None,
|
||||
port: Optional[int] = None,
|
||||
interval: Optional[float] = None,
|
||||
enabled: bool = True,
|
||||
payload: Optional[Callable[[], dict]] = None,
|
||||
) -> None:
|
||||
self.name = name
|
||||
self.instance = instance or socket.gethostname()
|
||||
self.version = version
|
||||
self.group = group or os.environ.get("BEACON_GROUP") or DEFAULT_GROUP
|
||||
self.port = port or int(os.environ.get("BEACON_PORT") or DEFAULT_PORT)
|
||||
self.interval = interval or (float(os.environ.get("BEACON_INTERVAL_MS") or 0) / 1000.0) or DEFAULT_INTERVAL
|
||||
self.enabled = enabled
|
||||
# payload() -> {"status": str?, "resources": [resource, ...]}
|
||||
self._payload = payload
|
||||
|
||||
self._sock: Optional[socket.socket] = None
|
||||
self._ifaces: List[tuple] = []
|
||||
self._nodes: Dict[str, dict] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._send_lock = threading.Lock()
|
||||
self._running = False
|
||||
self._threads: List[threading.Thread] = []
|
||||
self._listeners: List[Callable[[dict], None]] = []
|
||||
|
||||
# -- lifecycle ---------------------------------------------------------
|
||||
|
||||
def start(self) -> None:
|
||||
if self._running or not self.enabled:
|
||||
if not self.enabled:
|
||||
print("[beacon] Disabled by configuration")
|
||||
return
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
try:
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
sock.bind(("0.0.0.0", self.port))
|
||||
# TTL 1: link-local only, never routed.
|
||||
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 1)
|
||||
|
||||
self._ifaces = _interface_indexes()
|
||||
group_bin = socket.inet_aton(self.group)
|
||||
joined = 0
|
||||
for ifname, idx, addr in self._ifaces:
|
||||
try:
|
||||
# ip_mreqn: multiaddr, address (INADDR_ANY), ifindex. The
|
||||
# ifindex is what makes this a per-interface join instead of
|
||||
# "whatever the routing table picks".
|
||||
mreq = group_bin + socket.inet_aton("0.0.0.0") + struct.pack("@i", idx)
|
||||
sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
|
||||
joined += 1
|
||||
except OSError as e:
|
||||
print(f"[beacon] Warning: join {self.group} on {ifname} failed: {e}")
|
||||
|
||||
self._sock = sock
|
||||
self._running = True
|
||||
print(
|
||||
f"[beacon] Listening on {self.group}:{self.port} as "
|
||||
f"{self.name}/{self.instance} (joined {joined} of {len(self._ifaces)} interfaces)"
|
||||
)
|
||||
|
||||
for target in (self._read_loop, self._advertise_loop):
|
||||
t = threading.Thread(target=target, daemon=True)
|
||||
t.start()
|
||||
self._threads.append(t)
|
||||
|
||||
def stop(self) -> None:
|
||||
if not self._running:
|
||||
return
|
||||
self._running = False
|
||||
# Bye, so neighbours drop us immediately rather than waiting out the
|
||||
# staleness window.
|
||||
try:
|
||||
self._send({"proto": PROTO, "v": VERSION, "type": TYPE_BYE, "node": self._node_info()})
|
||||
except OSError:
|
||||
pass
|
||||
if self._sock:
|
||||
try:
|
||||
self._sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
self._sock = None
|
||||
|
||||
# -- api ---------------------------------------------------------------
|
||||
|
||||
def probe(self, want: Optional[List[str]] = None) -> None:
|
||||
"""Multicast a probe. Every node owning a resource matching one of
|
||||
``want`` (every node, when empty) replies immediately."""
|
||||
msg = {"proto": PROTO, "v": VERSION, "type": TYPE_PROBE, "from": self.instance}
|
||||
if want:
|
||||
msg["want"] = list(want)
|
||||
self._send(msg)
|
||||
|
||||
def neighbors(self) -> List[dict]:
|
||||
"""Every live node, sorted by (name, instance)."""
|
||||
cutoff = time.time() - self.interval * LIVENESS_FACTOR
|
||||
with self._lock:
|
||||
for key in [k for k, v in self._nodes.items() if v["lastSeen"] < cutoff]:
|
||||
del self._nodes[key]
|
||||
out = [_neighbor_row(v["node"], v["resources"], v["addr"], v["lastSeen"]) for v in self._nodes.values()]
|
||||
return sorted(out, key=lambda n: (n.get("name") or "", n.get("instance") or ""))
|
||||
|
||||
def neighbors_by_name(self) -> List[dict]:
|
||||
"""One row per node name, most recently seen instance wins."""
|
||||
best: Dict[str, dict] = {}
|
||||
for nb in self.neighbors():
|
||||
cur = best.get(nb.get("name") or "")
|
||||
if cur is None or nb["lastSeen"] > cur["lastSeen"]:
|
||||
best[nb.get("name") or ""] = nb
|
||||
return sorted(best.values(), key=lambda n: n.get("name") or "")
|
||||
|
||||
def self_announce(self) -> dict:
|
||||
"""This node as a row, so a UI can render itself without waiting."""
|
||||
status, resources = self._get_payload()
|
||||
return _neighbor_row(self._node_info(status), resources, "", time.time())
|
||||
|
||||
def on_advertise(self, cb: Callable[[dict], None]) -> None:
|
||||
self._listeners.append(cb)
|
||||
|
||||
# -- internals ---------------------------------------------------------
|
||||
|
||||
def _get_payload(self) -> tuple:
|
||||
if not self._payload:
|
||||
return None, []
|
||||
try:
|
||||
p = self._payload() or {}
|
||||
except Exception:
|
||||
p = {}
|
||||
return p.get("status"), list(p.get("resources") or [])
|
||||
|
||||
def _node_info(self, status: Optional[str] = None) -> dict:
|
||||
n = {"name": self.name, "instance": self.instance}
|
||||
if self.version:
|
||||
n["version"] = self.version
|
||||
if status:
|
||||
n["status"] = status
|
||||
return n
|
||||
|
||||
def _advertise_msg(self) -> dict:
|
||||
status, resources = self._get_payload()
|
||||
return {
|
||||
"proto": PROTO, "v": VERSION, "type": TYPE_ADVERTISE,
|
||||
"node": self._node_info(status), "resources": resources,
|
||||
}
|
||||
|
||||
def _send(self, msg: dict, dest: Optional[tuple] = None) -> None:
|
||||
sock = self._sock
|
||||
if sock is None:
|
||||
return
|
||||
body = json.dumps(msg).encode("utf-8")
|
||||
if len(body) > MAX_DATAGRAM:
|
||||
print(f"[beacon] Warning: {len(body)}-byte datagram exceeds the fragmentation-safe {MAX_DATAGRAM}")
|
||||
with self._send_lock:
|
||||
if dest is not None:
|
||||
try:
|
||||
sock.sendto(body, dest)
|
||||
except OSError as e:
|
||||
print(f"[beacon] Reply to {dest} failed: {e}")
|
||||
return
|
||||
# One write per interface — the default route alone reaches a
|
||||
# single network.
|
||||
for ifname, idx, addr in self._ifaces:
|
||||
try:
|
||||
sock.setsockopt(
|
||||
socket.IPPROTO_IP,
|
||||
socket.IP_MULTICAST_IF,
|
||||
socket.inet_aton(addr) + socket.inet_aton("0.0.0.0") + struct.pack("@i", idx),
|
||||
)
|
||||
sock.sendto(body, (self.group, self.port))
|
||||
except OSError as e:
|
||||
print(f"[beacon] Warning: send on {ifname} failed: {e}")
|
||||
|
||||
def _read_loop(self) -> None:
|
||||
while self._running:
|
||||
sock = self._sock
|
||||
if sock is None:
|
||||
return
|
||||
try:
|
||||
data, addr = sock.recvfrom(READ_BUFFER)
|
||||
except OSError:
|
||||
if not self._running:
|
||||
return
|
||||
continue
|
||||
msg = parse_message(data)
|
||||
if msg is None:
|
||||
continue # v1 or foreign traffic on the shared group
|
||||
|
||||
if msg["type"] == TYPE_PROBE:
|
||||
if msg.get("from") == self.instance:
|
||||
continue
|
||||
ad = self._advertise_msg()
|
||||
want = msg.get("want") or []
|
||||
if not want or any(resource_matches(r, p) for r in ad["resources"] for p in want):
|
||||
self._send(ad, dest=addr)
|
||||
continue
|
||||
|
||||
node = msg["node"]
|
||||
if node["instance"] == self.instance:
|
||||
continue # our own multicast echo
|
||||
if msg["type"] == TYPE_BYE:
|
||||
with self._lock:
|
||||
self._nodes.pop(node["instance"], None)
|
||||
continue
|
||||
|
||||
# Source address comes from the packet, never the payload.
|
||||
entry = {"node": node, "resources": msg.get("resources") or [], "addr": addr[0], "lastSeen": time.time()}
|
||||
with self._lock:
|
||||
self._nodes[node["instance"]] = entry
|
||||
row = _neighbor_row(entry["node"], entry["resources"], entry["addr"], entry["lastSeen"])
|
||||
for cb in list(self._listeners):
|
||||
try:
|
||||
cb(row)
|
||||
except Exception as e:
|
||||
print(f"[beacon] Listener raised: {e}")
|
||||
|
||||
def _advertise_loop(self) -> None:
|
||||
self._send(self._advertise_msg())
|
||||
self.probe()
|
||||
while self._running:
|
||||
time.sleep(self.interval)
|
||||
if not self._running:
|
||||
return
|
||||
try:
|
||||
self._send(self._advertise_msg())
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
class MetaCoreLocator:
|
||||
"""Locates meta-core over beacon v2 (the first ``metamesh.core`` resource
|
||||
carrying ``data.urls``) and advertises this service as
|
||||
``metamesh.service/<service_name>``."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
service_name: str,
|
||||
base_url: Optional[str] = None,
|
||||
version: str = "",
|
||||
meta_core_url: Optional[str] = None,
|
||||
enabled: Optional[bool] = None,
|
||||
) -> None:
|
||||
self._pin = (meta_core_url or "").strip() or None
|
||||
self._current: Optional[dict] = None
|
||||
self._change_callbacks: List[Callable[[], None]] = []
|
||||
|
||||
self._node = MeshNode(
|
||||
name=service_name,
|
||||
version=version,
|
||||
enabled=_env_flag("ENABLE_UDP_DISCOVERY", True) if enabled is None else enabled,
|
||||
payload=lambda: {"resources": [{
|
||||
"id": "service",
|
||||
"caps": [CAP_SERVICE_PREFIX + service_name],
|
||||
"endpoints": {"ui": base_url or f"http://{local_ipv4()}"},
|
||||
}]},
|
||||
)
|
||||
self._node.on_advertise(self._on_advertise)
|
||||
|
||||
def _on_advertise(self, nb: dict) -> None:
|
||||
if self._pin:
|
||||
return # pinned: the wire cannot move us
|
||||
core = next((r for r in nb.get("resources") or [] if resource_matches(r, CAP_CORE)), None)
|
||||
urls = ((core or {}).get("data") or {}).get("urls")
|
||||
if not isinstance(urls, dict) or not urls.get("apiUrl"):
|
||||
return
|
||||
|
||||
prev = self._current
|
||||
if prev is None:
|
||||
self._current = urls
|
||||
print(f"[beacon] meta-core discovered at {urls['apiUrl']} ({nb.get('addr')})")
|
||||
for cb in list(self._change_callbacks):
|
||||
try:
|
||||
cb()
|
||||
except Exception as e:
|
||||
print(f"[beacon] Change callback raised: {e}")
|
||||
return
|
||||
if prev.get("apiUrl") == urls.get("apiUrl"):
|
||||
self._current = urls
|
||||
return
|
||||
# A second, different core. Do not flap — keep the first and say so,
|
||||
# because on a PCS box this means a client container can see a core it
|
||||
# must not use.
|
||||
print(
|
||||
f"[beacon] Ignoring a second meta-core at {urls['apiUrl']} "
|
||||
f"({nb.get('addr')}); staying with {prev.get('apiUrl')}. "
|
||||
f"Set META_CORE_URL to pin this explicitly."
|
||||
)
|
||||
|
||||
def start(self) -> None:
|
||||
if self._pin:
|
||||
print(f"[beacon] meta-core pinned to {self._pin}; discovery is advisory")
|
||||
self._node.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._node.stop()
|
||||
self._change_callbacks = []
|
||||
|
||||
def get_api_url(self) -> Optional[str]:
|
||||
if self._pin:
|
||||
return self._pin
|
||||
if self._current:
|
||||
return self._current.get("apiUrl")
|
||||
return None
|
||||
|
||||
def get_urls(self) -> Optional[dict]:
|
||||
return self._current
|
||||
|
||||
def wait_for_core(self, timeout: Optional[float] = 30.0) -> Optional[str]:
|
||||
"""Block until meta-core is located.
|
||||
|
||||
``timeout=None`` waits forever, which is meta-stremio's default
|
||||
(LEADER_WAIT_TIMEOUT=0). Re-probes every 500ms rather than waiting out
|
||||
an advertise interval.
|
||||
"""
|
||||
deadline = None if timeout is None else time.time() + timeout
|
||||
logged = False
|
||||
while deadline is None or time.time() < deadline:
|
||||
url = self.get_api_url()
|
||||
if url:
|
||||
return url
|
||||
if not logged:
|
||||
print("[beacon] Waiting for meta-core...")
|
||||
logged = True
|
||||
self._node.probe([CAP_CORE])
|
||||
time.sleep(0.5)
|
||||
return None
|
||||
|
||||
def on_change(self, cb: Callable[[], None]) -> None:
|
||||
self._change_callbacks.append(cb)
|
||||
|
||||
def neighbors(self, all: bool = False, cap: Optional[str] = None) -> List[dict]:
|
||||
"""One row per name (every instance with ``all``), optionally only
|
||||
nodes with a resource matching ``cap``."""
|
||||
rows = self._node.neighbors() if all else self._node.neighbors_by_name()
|
||||
if cap:
|
||||
rows = [r for r in rows if any(resource_matches(res, cap) for res in r.get("resources") or [])]
|
||||
return rows
|
||||
|
||||
def self_announce(self) -> dict:
|
||||
return self._node.self_announce()
|
||||
|
||||
def probe(self, want: Optional[List[str]] = None) -> None:
|
||||
self._node.probe(want)
|
||||
@@ -50,11 +50,19 @@ class MetaConsumer:
|
||||
def __init__(
|
||||
self,
|
||||
api_url: str,
|
||||
cursor_path: str = "/meta-core/cursors/meta-stremio-meta.cursor",
|
||||
cursor_path: Optional[str] = None,
|
||||
request_timeout: int = 35,
|
||||
) -> None:
|
||||
self._api_url = api_url.rstrip("/")
|
||||
self._cursor_path = cursor_path
|
||||
# The SSE resume cursor used to default under /meta-core, which is not
|
||||
# mounted any more (meta-discovery v1 removed the shared volume). It
|
||||
# belongs with the rest of this service's local state: CACHE_FOLDER_PATH
|
||||
# (/data/cache in the dev stack). Without a writable home the consumer
|
||||
# silently replays from `$` on every restart.
|
||||
self._cursor_path = cursor_path or os.path.join(
|
||||
os.environ.get("CACHE_FOLDER_PATH", "/data/cache"),
|
||||
"meta-stremio-meta.cursor",
|
||||
)
|
||||
self._request_timeout = request_timeout
|
||||
self._running = False
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
|
||||
@@ -1,297 +0,0 @@
|
||||
"""
|
||||
Service Registration for meta-stremio.
|
||||
|
||||
Python implementation of the service registration pattern used in meta-sort/meta-fuse.
|
||||
Each service registers itself in /meta-core/services/{service-name}-{hostname}.json.
|
||||
|
||||
Service discovery is centralized in meta-core - this module only handles registration.
|
||||
For service discovery, use the /api/services endpoint (fetches from meta-core).
|
||||
|
||||
Features:
|
||||
- Service registration with heartbeat
|
||||
- Automatic heartbeat loop
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, asdict
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Dict, Callable
|
||||
from urllib.request import urlopen
|
||||
from urllib.error import URLError
|
||||
|
||||
|
||||
# Configuration
|
||||
META_CORE_PATH = os.environ.get('META_CORE_PATH', '/meta-core')
|
||||
SERVICE_NAME = os.environ.get('SERVICE_NAME', 'meta-stremio')
|
||||
SERVICE_VERSION = os.environ.get('SERVICE_VERSION', '1.0.0')
|
||||
BASE_URL = os.environ.get('BASE_URL', '')
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServiceInfo:
|
||||
"""
|
||||
Service registration info stored in JSON file.
|
||||
|
||||
Simplified format matching TypeScript ServiceInfo interface:
|
||||
- name: Service name (e.g., 'meta-stremio')
|
||||
- hostname: Container/host hostname
|
||||
- baseUrl: Base URL for the service (e.g., 'http://localhost:8182')
|
||||
- status: Current status ('running' | 'stale' | 'stopped')
|
||||
- lastHeartbeat: ISO timestamp of last heartbeat
|
||||
- role: Optional role ('leader' | 'follower') - only used by meta-core
|
||||
"""
|
||||
name: str
|
||||
hostname: str
|
||||
baseUrl: str
|
||||
status: str = 'running' # running | stale | stopped
|
||||
lastHeartbeat: str = ''
|
||||
role: Optional[str] = None # "leader", "follower", or None (for non-meta-core services)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization.
|
||||
Excludes None values to avoid polluting JSON with null fields.
|
||||
"""
|
||||
result = asdict(self)
|
||||
# Remove None values (e.g., role should only appear for meta-core)
|
||||
return {k: v for k, v in result.items() if v is not None}
|
||||
|
||||
|
||||
class ServiceRegistration:
|
||||
"""
|
||||
Service Discovery using shared filesystem.
|
||||
|
||||
Each service registers itself in /meta-core/services/{service-name}-{hostname}.json
|
||||
and can discover other registered services.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
meta_core_path: str = None,
|
||||
service_name: str = None,
|
||||
version: str = None,
|
||||
api_url: str = None,
|
||||
base_url: str = None,
|
||||
capabilities: List[str] = None,
|
||||
endpoints: Dict[str, str] = None,
|
||||
heartbeat_interval: float = 30.0,
|
||||
stale_threshold: float = 60.0
|
||||
):
|
||||
self.meta_core_path = meta_core_path or META_CORE_PATH
|
||||
self.service_name = service_name or SERVICE_NAME
|
||||
# Use base_url if provided, then api_url, then env BASE_URL, then auto-detect
|
||||
self.base_url = base_url or api_url or BASE_URL or self._get_default_api_url()
|
||||
self.heartbeat_interval = heartbeat_interval
|
||||
self.stale_threshold = stale_threshold
|
||||
|
||||
# Keep for backwards compatibility but not used in simplified format
|
||||
self._version = version or SERVICE_VERSION
|
||||
self._capabilities = capabilities or []
|
||||
self._endpoints = endpoints or {}
|
||||
|
||||
self.current_hostname = socket.gethostname()
|
||||
self.services_dir = os.path.join(self.meta_core_path, 'services')
|
||||
# Use hostname-based file naming like TypeScript services
|
||||
self.service_file_path = os.path.join(self.services_dir, f'{self.service_name}-{self.current_hostname}.json')
|
||||
|
||||
self._heartbeat_thread: Optional[threading.Thread] = None
|
||||
self._stop_event = threading.Event()
|
||||
self._is_started = False
|
||||
|
||||
def _get_default_api_url(self) -> str:
|
||||
"""Get default API URL using local IP and PORT."""
|
||||
port = os.environ.get('PORT', '7000')
|
||||
ip = self._get_local_ip()
|
||||
return f'http://{ip}:{port}'
|
||||
|
||||
def _get_local_ip(self) -> str:
|
||||
"""Get the local machine's IP address."""
|
||||
try:
|
||||
# Create a socket to determine the outbound IP
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(('8.8.8.8', 80))
|
||||
ip = s.getsockname()[0]
|
||||
s.close()
|
||||
return ip
|
||||
except Exception:
|
||||
return socket.gethostname() or 'localhost'
|
||||
|
||||
def _ensure_services_dir(self) -> None:
|
||||
"""Ensure services directory exists."""
|
||||
Path(self.services_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _build_service_info(self, status: str = 'running') -> ServiceInfo:
|
||||
"""Build service info for this service (simplified format)."""
|
||||
return ServiceInfo(
|
||||
name=self.service_name,
|
||||
hostname=self.current_hostname,
|
||||
baseUrl=self.base_url,
|
||||
status=status,
|
||||
lastHeartbeat=datetime.utcnow().isoformat() + 'Z'
|
||||
)
|
||||
|
||||
def register(self) -> None:
|
||||
"""Register this service."""
|
||||
self._ensure_services_dir()
|
||||
|
||||
info = self._build_service_info('starting')
|
||||
|
||||
with open(self.service_file_path, 'w') as f:
|
||||
json.dump(info.to_dict(), f, indent=2)
|
||||
|
||||
print(f'[ServiceRegistration] Registered {self.service_name}')
|
||||
|
||||
def update_status(self, status: str) -> None:
|
||||
"""Update service status."""
|
||||
try:
|
||||
with open(self.service_file_path, 'r') as f:
|
||||
info = json.load(f)
|
||||
|
||||
info['status'] = status
|
||||
info['lastHeartbeat'] = datetime.utcnow().isoformat() + 'Z'
|
||||
|
||||
with open(self.service_file_path, 'w') as f:
|
||||
json.dump(info, f, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
print(f'[ServiceRegistration] Failed to update status: {e}')
|
||||
|
||||
def heartbeat(self) -> None:
|
||||
"""Send heartbeat (update lastHeartbeat timestamp)."""
|
||||
try:
|
||||
with open(self.service_file_path, 'r') as f:
|
||||
info = json.load(f)
|
||||
|
||||
info['lastHeartbeat'] = datetime.utcnow().isoformat() + 'Z'
|
||||
|
||||
with open(self.service_file_path, 'w') as f:
|
||||
json.dump(info, f, indent=2)
|
||||
|
||||
except FileNotFoundError:
|
||||
# If file was deleted, re-register
|
||||
self.register()
|
||||
self.update_status('running')
|
||||
except Exception as e:
|
||||
print(f'[ServiceRegistration] Heartbeat failed: {e}')
|
||||
|
||||
def _heartbeat_loop(self) -> None:
|
||||
"""Background heartbeat loop."""
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
self.heartbeat()
|
||||
except Exception as e:
|
||||
print(f'[ServiceRegistration] Heartbeat error: {e}')
|
||||
|
||||
self._stop_event.wait(self.heartbeat_interval)
|
||||
|
||||
def start_heartbeat(self) -> None:
|
||||
"""Start heartbeat loop in background thread."""
|
||||
if self._heartbeat_thread and self._heartbeat_thread.is_alive():
|
||||
return
|
||||
|
||||
self._stop_event.clear()
|
||||
self._heartbeat_thread = threading.Thread(
|
||||
target=self._heartbeat_loop,
|
||||
daemon=True,
|
||||
name='service-heartbeat'
|
||||
)
|
||||
self._heartbeat_thread.start()
|
||||
|
||||
def stop_heartbeat(self) -> None:
|
||||
"""Stop heartbeat loop."""
|
||||
self._stop_event.set()
|
||||
if self._heartbeat_thread and self._heartbeat_thread.is_alive():
|
||||
self._heartbeat_thread.join(timeout=2.0)
|
||||
|
||||
def unregister(self) -> None:
|
||||
"""Unregister this service (on shutdown)."""
|
||||
self.stop_heartbeat()
|
||||
|
||||
try:
|
||||
self.update_status('stopped')
|
||||
print(f'[ServiceRegistration] Unregistered {self.service_name}')
|
||||
except Exception as e:
|
||||
print(f'[ServiceRegistration] Failed to unregister: {e}')
|
||||
|
||||
# ========================================================================
|
||||
# Lifecycle
|
||||
# ========================================================================
|
||||
|
||||
def start(self) -> None:
|
||||
"""Full startup sequence."""
|
||||
if self._is_started:
|
||||
return
|
||||
|
||||
# Try to register, but don't fail if filesystem is read-only
|
||||
# This allows discovery-only mode when we can't write
|
||||
can_register = os.access(self.services_dir, os.W_OK) if os.path.exists(self.services_dir) else False
|
||||
|
||||
if can_register:
|
||||
try:
|
||||
self.register()
|
||||
self.update_status('running')
|
||||
self.start_heartbeat()
|
||||
print(f'[ServiceRegistration] Started {self.service_name}-{self.current_hostname} at {self.base_url}')
|
||||
except Exception as e:
|
||||
print(f'[ServiceRegistration] Registration failed (read-only?): {e}')
|
||||
print(f'[ServiceRegistration] Running in discovery-only mode')
|
||||
else:
|
||||
print(f'[ServiceRegistration] Services directory not writable, running in discovery-only mode')
|
||||
|
||||
self._is_started = True
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Full shutdown sequence."""
|
||||
if not self._is_started:
|
||||
return
|
||||
|
||||
self.unregister()
|
||||
self._is_started = False
|
||||
|
||||
# ========================================================================
|
||||
# Getters
|
||||
# ========================================================================
|
||||
|
||||
def get_service_file_path(self) -> str:
|
||||
return self.service_file_path
|
||||
|
||||
def get_services_dir(self) -> str:
|
||||
return self.services_dir
|
||||
|
||||
def is_started(self) -> bool:
|
||||
return self._is_started
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_service_registration: Optional[ServiceRegistration] = None
|
||||
|
||||
|
||||
def get_service_discovery() -> ServiceRegistration:
|
||||
"""Get or create the service registration singleton.
|
||||
|
||||
Note: Function name kept as 'get_service_discovery' for backward compatibility.
|
||||
"""
|
||||
global _service_registration
|
||||
if _service_registration is None:
|
||||
_service_registration = ServiceRegistration()
|
||||
return _service_registration
|
||||
|
||||
|
||||
def init_service_discovery(
|
||||
api_url: str = None,
|
||||
base_url: str = None,
|
||||
**kwargs
|
||||
) -> ServiceRegistration:
|
||||
"""Initialize and start the service registration singleton.
|
||||
|
||||
Note: Function name kept as 'init_service_discovery' for backward compatibility.
|
||||
"""
|
||||
global _service_registration
|
||||
_service_registration = ServiceRegistration(api_url=api_url, base_url=base_url, **kwargs)
|
||||
_service_registration.start()
|
||||
return _service_registration
|
||||
+14
-69
@@ -292,6 +292,14 @@
|
||||
footer a:hover { text-decoration: underline; }
|
||||
|
||||
/* Services Navigation */
|
||||
/* <meta-service-menu> is in shadow DOM and cannot read the vars
|
||||
above; map this UI's palette onto the four tokens it does read. */
|
||||
meta-service-menu {
|
||||
--mm-nav-fg: var(--text-primary);
|
||||
--mm-nav-bg: var(--bg-tertiary);
|
||||
--mm-nav-border: var(--border-color);
|
||||
--mm-nav-accent: var(--accent-primary);
|
||||
}
|
||||
.services-nav {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
@@ -362,16 +370,13 @@
|
||||
.services-nav { flex-direction: column; align-items: flex-start; }
|
||||
}
|
||||
</style>
|
||||
<script type="module" src="meta-service-menu.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- Services Navigation -->
|
||||
<nav class="services-nav" id="servicesNav" style="display: none;">
|
||||
<span class="services-nav-label">Services:</span>
|
||||
<div class="services-nav-items" id="servicesNavItems">
|
||||
<!-- Populated by JavaScript -->
|
||||
</div>
|
||||
</nav>
|
||||
<!-- Neighbouring meta-* services (meta-discovery v1). The element
|
||||
fetches /api/neighbors from this origin and renders a burger menu. -->
|
||||
<meta-service-menu id="servicesNav" current="meta-stremio"></meta-service-menu>
|
||||
|
||||
<header class="header">
|
||||
<div>
|
||||
@@ -548,78 +553,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Service icons mapping
|
||||
const serviceIcons = {
|
||||
'meta-sort': '📁',
|
||||
'meta-fuse': '🗂️',
|
||||
'meta-stremio': '🎬',
|
||||
'meta-core': '⚙️',
|
||||
'meta-dup': '🔍',
|
||||
'meta-orbit': '🌐',
|
||||
'default': '📦'
|
||||
};
|
||||
|
||||
// Fetch and display discovered services
|
||||
async function fetchServices() {
|
||||
try {
|
||||
const res = await fetch('/api/services');
|
||||
const data = await res.json();
|
||||
|
||||
const navEl = document.getElementById('servicesNav');
|
||||
const itemsEl = document.getElementById('servicesNavItems');
|
||||
|
||||
if (data.services && data.services.length > 0) {
|
||||
// Sort services by name
|
||||
const services = data.services.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// Build navigation items
|
||||
itemsEl.innerHTML = services.map(svc => {
|
||||
const icon = serviceIcons[svc.name] || serviceIcons.default;
|
||||
const isActive = svc.name === data.current;
|
||||
const activeClass = isActive ? ' active' : '';
|
||||
|
||||
// Use external URL for other services, current path for self
|
||||
const href = isActive ? '#' : svc.baseUrl;
|
||||
|
||||
return `
|
||||
<a href="${href}" class="service-link${activeClass}" onclick="event.preventDefault(); ${isActive ? '' : `window.location.href='${svc.baseUrl}'`}">
|
||||
<span class="service-icon">${icon}</span>
|
||||
<span>${formatServiceName(svc.name)}</span>
|
||||
<span class="service-status"></span>
|
||||
</a>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
navEl.style.display = 'flex';
|
||||
} else {
|
||||
navEl.style.display = 'none';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch services:', e);
|
||||
document.getElementById('servicesNav').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function formatServiceName(name) {
|
||||
// Convert 'meta-stremio' to 'Meta Stremio'
|
||||
return name.split('-').map(word =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1)
|
||||
).join(' ');
|
||||
}
|
||||
// Service nav is <meta-service-menu> now — no local icon map,
|
||||
// name formatter or poller lives here any more.
|
||||
|
||||
// Initial fetch
|
||||
fetchStats();
|
||||
fetchMetrics();
|
||||
fetchServices();
|
||||
|
||||
// Refresh metrics every 5 seconds
|
||||
setInterval(fetchMetrics, 5000);
|
||||
|
||||
// Refresh stats every 30 seconds
|
||||
setInterval(fetchStats, 30000);
|
||||
|
||||
// Refresh services every 60 seconds
|
||||
setInterval(fetchServices, 60000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* <meta-service-menu> — the neighbouring meta-* services, as a burger menu.
|
||||
*
|
||||
* Replaces the eight divergent "services bar" implementations (four copies of
|
||||
* a React `ServiceNav.tsx`, two mirrored `services.js`, two bespoke inline
|
||||
* ones) with a single framework-free custom element. It drops into the React
|
||||
* dashboards as a plain tag — no React dependency — and into the vanilla UIs
|
||||
* as an ES module.
|
||||
*
|
||||
* Data comes from the host service's own `GET /api/neighbors`, same-origin.
|
||||
* Never cross-origin: the dev stack's self-signed certs and the OIDC redirects
|
||||
* make a fetch to a neighbour a dead end. Links are plain navigations.
|
||||
*
|
||||
* ⚠ MIRRORED FILE. Byte-identical copies live in every meta-* UI tree; see
|
||||
* `scripts/check-mirrors.sh`. Edit one → edit all.
|
||||
*
|
||||
* Shadow DOM is not optional here. The eight UIs use four incompatible token
|
||||
* vocabularies, and two of them define *different colours under the same
|
||||
* names* (`--bg-secondary` is #12121a in meta-sort, #16213e in meta-core,
|
||||
* #161b22 in meta-dup). meta-search and meta-share are also the only
|
||||
* light-mode-capable UIs, so a hardcoded dark palette would break them.
|
||||
* The element therefore styles itself from four tokens with dark defaults:
|
||||
*
|
||||
* --mm-nav-fg, --mm-nav-bg, --mm-nav-border, --mm-nav-accent
|
||||
*
|
||||
* Each host maps its own variables onto those, e.g.
|
||||
* meta-service-menu { --mm-nav-bg: var(--bg-secondary); }
|
||||
*
|
||||
* Attributes:
|
||||
* current name of the host service, so it can be marked and not linked
|
||||
* endpoint override the default "/api/neighbors"
|
||||
* label button tooltip (default "Services")
|
||||
*
|
||||
* Property:
|
||||
* rewriteUrl optional (name, url, neighbour) => url, for hosts that must
|
||||
* translate internal hostnames into browser-reachable ones
|
||||
* (meta-gateway does this for its dev/prod split).
|
||||
*/
|
||||
|
||||
const ICONS = {
|
||||
"meta-core": "⚙️",
|
||||
"meta-sort": "📁",
|
||||
"meta-fuse": "🗂️",
|
||||
"meta-stremio": "🎬",
|
||||
"meta-dup": "🔍",
|
||||
"meta-search": "🔎",
|
||||
"meta-share": "🔗",
|
||||
"meta-gateway": "🌉",
|
||||
"meta-watch": "📺",
|
||||
"meta-listen": "🎧",
|
||||
"meta-read": "📖",
|
||||
};
|
||||
const DEFAULT_ICON = "📦";
|
||||
const POLL_MS = 30000;
|
||||
|
||||
function titleCase(name) {
|
||||
return String(name || "")
|
||||
.split("-")
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
const TEMPLATE = `
|
||||
<style>
|
||||
:host {
|
||||
--_fg: var(--mm-nav-fg, #e8eaed);
|
||||
--_bg: var(--mm-nav-bg, #1a1a2e);
|
||||
--_border: var(--mm-nav-border, rgba(255, 255, 255, 0.14));
|
||||
--_accent: var(--mm-nav-accent, #4ecdc4);
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
button.trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
width: 2.1rem;
|
||||
height: 2.1rem;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: var(--_fg);
|
||||
background: transparent;
|
||||
border: 1px solid var(--_border);
|
||||
border-radius: 8px;
|
||||
font: inherit;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
button.trigger:hover { border-color: var(--_accent); }
|
||||
button.trigger:focus-visible {
|
||||
outline: 2px solid var(--_accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
button.trigger[aria-expanded="true"] { border-color: var(--_accent); }
|
||||
.bars { display: block; width: 15px; }
|
||||
.bars span {
|
||||
display: block;
|
||||
height: 2px;
|
||||
margin: 3px 0;
|
||||
border-radius: 2px;
|
||||
background: currentColor;
|
||||
}
|
||||
.panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
min-width: 13rem;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
padding: 0.3rem;
|
||||
background: var(--_bg);
|
||||
border: 1px solid var(--_border);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.panel[hidden] { display: none; }
|
||||
a.item, .item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
color: var(--_fg);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
a.item:hover { background: color-mix(in srgb, var(--_accent) 18%, transparent); }
|
||||
.item.active { color: var(--_accent); font-weight: 600; cursor: default; }
|
||||
.item.dead { opacity: 0.55; cursor: not-allowed; }
|
||||
.icon { font-size: 1rem; line-height: 1; }
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin-left: auto;
|
||||
border-radius: 50%;
|
||||
background: var(--_accent);
|
||||
}
|
||||
.empty {
|
||||
padding: 0.6rem;
|
||||
opacity: 0.7;
|
||||
font-size: 0.85em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Respect a host that opts out of motion. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
button.trigger { transition: none; }
|
||||
}
|
||||
</style>
|
||||
<button class="trigger" type="button" aria-haspopup="menu" aria-expanded="false">
|
||||
<span class="bars" aria-hidden="true"><span></span><span></span><span></span></span>
|
||||
</button>
|
||||
<div class="panel" role="menu" hidden></div>
|
||||
`;
|
||||
|
||||
class MetaServiceMenu extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.shadowRoot.innerHTML = TEMPLATE;
|
||||
this._btn = this.shadowRoot.querySelector("button.trigger");
|
||||
this._panel = this.shadowRoot.querySelector(".panel");
|
||||
this._neighbors = [];
|
||||
this._timer = null;
|
||||
this._open = false;
|
||||
|
||||
this._onDocClick = (e) => {
|
||||
if (!this._open) return;
|
||||
if (e.composedPath().includes(this)) return;
|
||||
this._setOpen(false);
|
||||
};
|
||||
this._onKey = (e) => {
|
||||
if (e.key === "Escape" && this._open) {
|
||||
this._setOpen(false);
|
||||
this._btn.focus();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this._btn.title = this.getAttribute("label") || "Services";
|
||||
this._btn.setAttribute("aria-label", this._btn.title);
|
||||
this._btn.addEventListener("click", () => this._setOpen(!this._open));
|
||||
document.addEventListener("click", this._onDocClick);
|
||||
document.addEventListener("keydown", this._onKey);
|
||||
|
||||
this.refresh();
|
||||
this._timer = setInterval(() => this.refresh(), POLL_MS);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this._timer) clearInterval(this._timer);
|
||||
this._timer = null;
|
||||
document.removeEventListener("click", this._onDocClick);
|
||||
document.removeEventListener("keydown", this._onKey);
|
||||
}
|
||||
|
||||
get endpoint() {
|
||||
return this.getAttribute("endpoint") || "/api/neighbors";
|
||||
}
|
||||
|
||||
get current() {
|
||||
return this.getAttribute("current") || "";
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
try {
|
||||
const res = await fetch(this.endpoint, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const data = await res.json();
|
||||
// `services` is the legacy alias some backends still emit.
|
||||
this._neighbors = data.neighbors || data.services || [];
|
||||
} catch {
|
||||
// Silent: the menu is navigation, not a health indicator. Keep whatever
|
||||
// we last had rather than blanking the list on one failed poll.
|
||||
}
|
||||
this._render();
|
||||
}
|
||||
|
||||
_setOpen(open) {
|
||||
this._open = open;
|
||||
this._panel.hidden = !open;
|
||||
this._btn.setAttribute("aria-expanded", String(open));
|
||||
if (open) this.refresh();
|
||||
}
|
||||
|
||||
_render() {
|
||||
const panel = this._panel;
|
||||
panel.textContent = "";
|
||||
|
||||
const seen = new Set();
|
||||
const rows = [];
|
||||
for (const n of this._neighbors) {
|
||||
const name = n.name || "";
|
||||
if (!name || seen.has(name)) continue;
|
||||
// Beacon v2 neighbours include plugins; the nav lists services only.
|
||||
// (A row without `caps` comes from a pre-v2 backend — keep it.)
|
||||
const isService = (c) => String(c).startsWith("metamesh.service/");
|
||||
if (Array.isArray(n.caps) && !n.caps.some(isService)) continue;
|
||||
seen.add(name);
|
||||
rows.push(n);
|
||||
}
|
||||
// Always show the host itself, even before its own echo comes back.
|
||||
if (this.current && !seen.has(this.current)) {
|
||||
rows.push({ name: this.current, baseUrl: "" });
|
||||
}
|
||||
rows.sort((a, b) => String(a.name).localeCompare(String(b.name)));
|
||||
|
||||
if (rows.length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "empty";
|
||||
empty.textContent = "No services found";
|
||||
panel.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const n of rows) {
|
||||
const isActive = n.name === this.current;
|
||||
let url = n.baseUrl || "";
|
||||
if (typeof this.rewriteUrl === "function") {
|
||||
try {
|
||||
url = this.rewriteUrl(n.name, url, n) || "";
|
||||
} catch {
|
||||
/* a bad hook must not blank the menu */
|
||||
}
|
||||
}
|
||||
|
||||
// A neighbour with no reachable URL is shown but not linked — better
|
||||
// than a link that goes nowhere.
|
||||
const el = document.createElement(isActive || !url ? "span" : "a");
|
||||
el.className =
|
||||
"item" + (isActive ? " active" : "") + (!isActive && !url ? " dead" : "");
|
||||
if (el.tagName === "A") {
|
||||
el.href = url;
|
||||
el.setAttribute("role", "menuitem");
|
||||
}
|
||||
if (!isActive && !url) el.title = "No reachable URL advertised";
|
||||
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "icon";
|
||||
icon.textContent = ICONS[n.name] || DEFAULT_ICON;
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.textContent = titleCase(n.name);
|
||||
|
||||
el.append(icon, label);
|
||||
// Beacon v2 omits `status` when running.
|
||||
if ((n.status || "running") === "running" || isActive) {
|
||||
const dot = document.createElement("span");
|
||||
dot.className = "dot";
|
||||
dot.title = n.status || "running";
|
||||
el.appendChild(dot);
|
||||
}
|
||||
panel.appendChild(el);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get("meta-service-menu")) {
|
||||
customElements.define("meta-service-menu", MetaServiceMenu);
|
||||
}
|
||||
|
||||
export default MetaServiceMenu;
|
||||
Reference in New Issue
Block a user