From a60c1ff60e8c3ec905c857020eda351db608f6b2 Mon Sep 17 00:00:00 2001 From: Worph Date: Mon, 14 Sep 2026 11:24:20 +0200 Subject: [PATCH] replace file discovery by udp discovery --- src/server.py | 29 ++- src/storage/__init__.py | 14 -- src/storage/leader_client.py | 5 +- src/storage/service_registration.py | 297 ---------------------------- www/index.html | 83 ++------ 5 files changed, 45 insertions(+), 383 deletions(-) delete mode 100644 src/storage/service_registration.py diff --git a/src/server.py b/src/server.py index a5f0e46..361f0fd 100644 --- a/src/server.py +++ b/src/server.py @@ -57,7 +57,9 @@ 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', }) @@ -254,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 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() @@ -289,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 === @@ -370,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') diff --git a/src/storage/__init__.py b/src/storage/__init__.py index 323fb2e..b16db48 100644 --- a/src/storage/__init__.py +++ b/src/storage/__init__.py @@ -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', ] diff --git a/src/storage/leader_client.py b/src/storage/leader_client.py index f57c404..aacb29d 100644 --- a/src/storage/leader_client.py +++ b/src/storage/leader_client.py @@ -289,6 +289,9 @@ def get_leader_client() -> Optional[LeaderClient]: _leader_client = LeaderClient( meta_core_path=os.environ.get("META_CORE_PATH"), meta_core_url=os.environ.get("META_CORE_URL"), - base_url=os.environ.get("BASE_URL") or None, + # 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 diff --git a/src/storage/service_registration.py b/src/storage/service_registration.py deleted file mode 100644 index d81e0dc..0000000 --- a/src/storage/service_registration.py +++ /dev/null @@ -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 diff --git a/www/index.html b/www/index.html index f9a818e..91525f3 100644 --- a/www/index.html +++ b/www/index.html @@ -292,6 +292,14 @@ footer a:hover { text-decoration: underline; } /* Services Navigation */ + /* 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; } } +
- - + +
@@ -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 ` - - ${icon} - ${formatServiceName(svc.name)} - - - `; - }).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 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);