initial commit

This commit is contained in:
Pierre
2026-01-11 17:27:01 +01:00
commit b9654c5b8d
20 changed files with 6635 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
name: Build and Push Docker Image
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: 'Tag for the image (optional, defaults to sha)'
required: false
type: string
meta_core_version:
description: 'meta-core version to use (default: latest)'
required: false
type: string
default: 'latest'
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=${{ inputs.tag }},enable=${{ inputs.tag != '' }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
META_CORE_IMAGE=ghcr.io/worph/meta-core:${{ inputs.meta_core_version || 'latest' }}
cache-from: type=gha
cache-to: type=gha,mode=max
+22
View File
@@ -0,0 +1,22 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
.venv/
*.egg-info/
dist/
build/
# IDE
.idea/
.vscode/
*.swp
*.swo
# Cache
.cache/
*.log
+76
View File
@@ -0,0 +1,76 @@
# Meta-Stremio Docker Image
# Standalone Stremio addon with HLS transcoding and KV storage integration
#
# Build standalone:
# docker build -t meta-stremio .
#
# Build with custom meta-core (for development):
# docker build --build-arg META_CORE_IMAGE=meta-core:local -t meta-stremio .
# Stage 0: Get meta-core binary from published image
ARG META_CORE_IMAGE=ghcr.io/worph/meta-core:latest
FROM ${META_CORE_IMAGE} AS meta-core
# Stage 1: Runtime
FROM python:3.11-slim
# Container registry metadata
LABEL org.opencontainers.image.source=https://github.com/worph/meta-stremio
LABEL org.opencontainers.image.description="MetaMesh Stremio addon with HLS transcoding"
LABEL org.opencontainers.image.licenses=MIT
# Install FFmpeg, curl (for healthcheck), and other dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
curl \
&& rm -rf /var/lib/apt/lists/*
# Set working directory
WORKDIR /app
# Copy requirements and install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy source files
COPY src/ ./src/
COPY www/ ./www/
# Copy meta-core sidecar binary
COPY --from=meta-core /usr/local/bin/meta-core /usr/local/bin/meta-core
RUN chmod +x /usr/local/bin/meta-core
# Copy startup script
COPY docker/start.sh /app/start.sh
RUN chmod +x /app/start.sh
# Create directories for cache (media comes from volume)
RUN mkdir -p /data/cache
# Environment variables with defaults
# Storage modes: 'leader' (discover via lock file), 'redis' (direct URL)
ENV PORT=7000 \
MEDIA_DIR=/files \
CACHE_DIR=/data/cache \
STORAGE_MODE=leader \
META_CORE_PATH=/meta-core \
FILES_PATH=/files \
REDIS_URL=redis://localhost:6379 \
REDIS_PREFIX= \
SEGMENT_DURATION=4 \
PREFETCH_SEGMENTS=4 \
SCHEME=auto \
SERVICE_NAME=meta-stremio \
SERVICE_VERSION=1.0.0 \
META_CORE_HTTP_PORT=9000 \
LEADER_ELECTION_ENABLED=false
# Expose port
EXPOSE 7000
# Health check
HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:7000/health || exit 1
# Run the server via startup script (starts meta-core + Python server)
CMD ["/app/start.sh"]
+265
View File
@@ -0,0 +1,265 @@
# Meta-Stremio
A standalone Stremio addon service for streaming personal media with on-the-fly HLS transcoding. Part of the MetaMesh decentralized media ecosystem.
## Overview
Meta-Stremio is a **process-read** service that:
- Reads file metadata from the shared KV store (Redis)
- Reads media files from the shared DATA volume
- Provides HLS streaming with adaptive real-time FFmpeg transcoding
- Serves as a Stremio addon for seamless playback
```
┌─────────────────────────────────────────────────────────────────────┐
│ MetaMesh Ecosystem │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ meta-sort │ │ meta-stremio │ │ meta-fuse │ │
│ │ [write meta] │ │ [read meta] │ │ [read meta] │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Shared KV Store (Redis) │ │
│ │ Leader election via flock on shared filesystem │ │
│ │ DB stored in: DATA/Apps/meta-core/DB │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Shared DATA Volume (media files) │ │
│ │ meta-sort writes → meta-stremio/meta-fuse reads │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
## Features
### High Performance
- **Adaptive Quality** - Dynamic preset + CRF adjustment targeting 60-80% transcode ratio
- **Intelligent Prefetch** - 4 segments ahead for smooth playback
- **Single-threaded Encode** - 100% CPU per segment for fastest encoding
- **Muxed Segments** - Video+audio in one FFmpeg call
- **Segment Caching** - Persistent cache for repeated access
### Stremio Integration
- **Catalog Endpoint** - Browse media library by type (movies, series)
- **Meta Endpoint** - Detailed video information with codec details
- **Stream Endpoint** - Multiple stream options per video
- **Multi-Audio** - Separate HLS streams per audio track
- **Subtitles** - Extract and serve embedded subtitles as VTT
- **Language Configuration** - Configure display language (uses metadata stored by meta-sort's TMDB plugin)
### Storage Abstraction
- **Leader Storage** - Auto-discovers meta-sort's Redis via leader election (recommended)
- **Redis Storage** - Direct Redis connection via URL
## Project Structure
```
meta-stremio/
├── README.md
├── CLAUDE.md # Development guide
├── Dockerfile
├── docker-compose.yml
├── requirements.txt # Just redis
├── src/
│ ├── server.py # HTTP server + routing
│ ├── stremio.py # Stremio handlers + storage integration
│ ├── transcoder.py # HLS transcoding engine
│ └── storage/
│ ├── __init__.py
│ ├── provider.py # Abstract StorageProvider
│ ├── redis_storage.py # Direct Redis connection
│ ├── leader_storage.py # Leader-aware Redis connection
│ └── leader_discovery.py # Leader election discovery
└── www/
└── index.html # Dashboard
```
## Installation
### Docker (Recommended)
```bash
# Build
docker build -t meta-stremio .
# Run with leader discovery (recommended - auto-discovers meta-sort's Redis)
docker run -d \
-p 7000:7000 \
-v /path/to/media:/files:ro \
-v /path/to/meta-core:/meta-core \
meta-stremio
# Run with direct Redis connection
docker run -d \
-p 7000:7000 \
-v /path/to/media:/files:ro \
-e STORAGE_MODE=redis \
-e REDIS_URL=redis://your-redis:6379 \
meta-stremio
```
### Docker Compose
```bash
# Run as part of the MetaMesh stack (uses leader discovery)
cd dev
./start.sh
```
### Standalone (Development)
```bash
# Prerequisites: Python 3.9+, FFmpeg
# Install dependencies
pip install -r requirements.txt
# Run
cd src
python server.py
```
## Configuration
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | `7000` | Server listen port |
| `MEDIA_DIR` | `/data/media` | Media files directory |
| `CACHE_DIR` | `/data/cache` | Transcoded segment cache |
| `STORAGE_MODE` | `leader` | `leader` or `redis` |
| `META_CORE_PATH` | `/meta-core` | Path to meta-core shared volume (for leader discovery) |
| `FILES_PATH` | `/files` | Path to files volume |
| `REDIS_URL` | `redis://localhost:6379` | Redis connection URL (for `redis` mode) |
| `REDIS_PREFIX` | `meta-sort:` | Redis key prefix |
| `SEGMENT_DURATION` | `4` | HLS segment length (seconds) |
| `PREFETCH_SEGMENTS` | `4` | Segments to prefetch ahead |
| `SCHEME` | `auto` | URL scheme (`http`, `https`, `auto`) |
## API Endpoints
### Dashboard
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/` | GET | Setup dashboard |
| `/health` | GET | Health check with storage status |
### Dashboard API
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/stats` | GET | Library statistics |
| `/api/storage-status` | GET | KV connection status |
| `/api/library` | GET | Full library list |
| `/api/library/refresh` | POST | Refresh library from KV |
### Stremio Protocol
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/manifest.json` | GET | Addon manifest |
| `/catalog/:type/:id.json` | GET | Video catalog |
| `/meta/:type/:id.json` | GET | Video metadata |
| `/stream/:type/:id.json` | GET | Stream URLs |
### Transcoding
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/transcode/:path/master.m3u8` | GET | ABR master playlist |
| `/transcode/:path/master_original_a0.m3u8` | GET | Original quality playlist |
| `/transcode/:path/stream_a0_720p.m3u8` | GET | Quality stream playlist |
| `/transcode/:path/seg_a0_720p_0.ts` | GET | Video segment |
| `/transcode/:path/subtitle_0.vtt` | GET | Subtitle track |
| `/direct/:path` | GET | Direct file serving |
| `/transcode/metrics` | GET | Transcoding metrics |
## KV Store Schema
Meta-stremio reads from meta-sort's Redis using this key format:
```
meta-sort:file:{hashId} → Hash containing all metadata
- filePath → /media/movies/Movie.mkv
- title → Movie Title
- type → movie|series|anime
- year → 2024
- season → 1 (for series)
- episode → 1 (for series)
- duration → 7200 (seconds)
- width → 1920
- height → 1080
- videoCodec → h264
- audioCodec → aac
- audioTracks → JSON string
- subtitles → JSON string
- imdbId → tt1234567
- poster → URL
```
## Stream Types
Each video provides multiple stream options:
1. **Direct File** - Original file, no transcoding
- Best quality, may not play on all devices
2. **HLS Original** - Transcoded at source resolution
- H.264/AAC in HLS container
- One stream per audio track
3. **HLS ABR** - Adaptive bitrate with quality ladder
- Multiple resolutions (1080p, 720p, 480p, 360p)
- Automatic quality switching
## Transcoding Details
### Adaptive Quality System
The transcoder automatically adjusts encoding settings to maintain a 60-80% transcode ratio (segment encode time / segment duration):
- **Too Fast (<60%)**: Increase quality (lower CRF, slower preset)
- **Too Slow (>80%)**: Decrease quality (higher CRF, faster preset)
### Quality Ladder
| Resolution | Video Bitrate | Audio Bitrate |
|------------|---------------|---------------|
| 1080p | 5000 kbps | 192 kbps |
| 720p | 3000 kbps | 128 kbps |
| 480p | 1500 kbps | 128 kbps |
| 360p | 800 kbps | 96 kbps |
## Adding to Stremio
1. Open the dashboard at `http://localhost:7000`
2. Click "Install in Stremio" or copy the manifest URL
3. In Stremio: Settings → Addons → Enter URL → Install
## Development
```bash
# Run in development mode (requires meta-sort running with Redis)
cd src
python server.py
# Or with direct Redis URL
STORAGE_MODE=redis REDIS_URL=redis://localhost:6379 python server.py
# View logs
tail -f /data/cache/*.log
```
## Related Projects
- **[meta-sort](../meta-sort)** - File indexer (writes metadata to Redis)
- **[meta-fuse](../meta-fuse)** - Virtual filesystem (reads metadata)
- **[meta-orbit](../meta-orbit)** - P2P metadata sharing
## License
MIT
+54
View File
@@ -0,0 +1,54 @@
# Meta-Stremio Docker Compose
# For local development and testing
version: '3.8'
services:
meta-stremio:
build: .
container_name: meta-stremio
ports:
- "7000:7000"
volumes:
# Mount your media directory
- ${MEDIA_DIR:-./media}:/data/media:ro
# Cache for transcoded segments
- meta-stremio-cache:/data/cache
environment:
- PORT=7000
- MEDIA_DIR=/data/media
- CACHE_DIR=/data/cache
- STORAGE_MODE=${STORAGE_MODE:-leader}
- REDIS_URL=${REDIS_URL:-redis://redis:6379}
- REDIS_PREFIX=${REDIS_PREFIX:-meta-sort:}
- SEGMENT_DURATION=${SEGMENT_DURATION:-4}
- PREFETCH_SEGMENTS=${PREFETCH_SEGMENTS:-4}
- SCHEME=${SCHEME:-auto}
restart: unless-stopped
depends_on:
redis:
condition: service_healthy
required: false
# Optional Redis for production use
# Comment out if connecting to external Redis
redis:
image: redis:7-alpine
container_name: meta-stremio-redis
ports:
- "6379:6379"
volumes:
- meta-stremio-redis:/data
command: redis-server --appendonly yes
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
profiles:
- with-redis
volumes:
meta-stremio-cache:
meta-stremio-redis:
+43
View File
@@ -0,0 +1,43 @@
#!/bin/bash
# Meta-Stremio startup script
# Starts meta-core sidecar before the Python server (when using leader discovery)
set -e
# Only start meta-core when using leader discovery mode
if [ "${STORAGE_MODE}" = "leader" ]; then
echo "[start.sh] Starting meta-core sidecar (leader discovery mode)..."
# Start meta-core in background
/usr/local/bin/meta-core &
META_CORE_PID=$!
# Wait for meta-core to be ready (health endpoint)
echo "[start.sh] Waiting for meta-core to be ready..."
for i in {1..30}; do
if curl -sf http://localhost:9000/health > /dev/null 2>&1; then
echo "[start.sh] meta-core is ready"
break
fi
if [ $i -eq 30 ]; then
echo "[start.sh] Warning: meta-core health check timeout, continuing anyway..."
fi
sleep 1
done
else
echo "[start.sh] Using direct Redis connection (STORAGE_MODE=${STORAGE_MODE}), skipping meta-core..."
fi
# Start the Python server (foreground)
echo "[start.sh] Starting meta-stremio server..."
# Use dev source if available (for development with hot reload)
if [ -d "/app/src-dev" ] && [ -f "/app/src-dev/server.py" ]; then
echo "[start.sh] Using development source (/app/src-dev)"
cd /app/src-dev
else
echo "[start.sh] Using production source (/app/src)"
cd /app/src
fi
exec python server.py
+10
View File
@@ -0,0 +1,10 @@
# Meta-Stremio dependencies
# Redis client for KV storage
redis>=5.0.0
# File watching for leader discovery (optional but recommended)
watchdog>=4.0.0
# Image processing for poster resizing
Pillow>=10.0.0
+174
View File
@@ -0,0 +1,174 @@
"""
File Server API for Meta-Stremio
Serves files by CID by looking up the path from metadata.
This is a generic file serving endpoint that can serve any file
whose CID and path are stored in the metadata.
Endpoints:
- GET /file/{cid} - Serve file by CID
- GET /file/{cid}/w{width} - Serve image resized to width (images only)
"""
from __future__ import annotations
import os
import io
from typing import Optional, Tuple
# Optional PIL for image resizing
try:
from PIL import Image
PIL_AVAILABLE = True
except ImportError:
PIL_AVAILABLE = False
Image = None
# Configuration
FILES_PATH = os.environ.get('FILES_PATH', '/files')
# Storage reference (set by init)
_storage = None
def init(storage) -> None:
"""Initialize with storage reference."""
global _storage
_storage = storage
def lookup_path_by_cid(cid: str) -> Optional[str]:
"""
Look up a file path by its CID from Redis.
Files (including poster images) are stored in Redis as file:{cid} entries.
The file path is stored in the 'path' field.
Returns the relative path if found, None otherwise.
"""
if not _storage:
return None
try:
# Look up directly by CID - poster files are processed like any other file
file_path = _storage.get_file_path_by_cid(cid)
if file_path:
return file_path
return None
except Exception as e:
print(f"[FileServer] Error looking up CID {cid}: {e}")
return None
def get_file_path(cid: str) -> Optional[str]:
"""Get the absolute file path for a CID."""
rel_path = lookup_path_by_cid(cid)
if rel_path:
return os.path.join(FILES_PATH, rel_path)
return None
def resize_image(image_data: bytes, width: int) -> bytes:
"""Resize an image to the specified width, maintaining aspect ratio."""
if not PIL_AVAILABLE:
raise RuntimeError("PIL not available for image resizing")
img = Image.open(io.BytesIO(image_data))
# Calculate new height maintaining aspect ratio
ratio = width / img.width
height = int(img.height * ratio)
# Use high-quality resampling
resized = img.resize((width, height), Image.Resampling.LANCZOS)
# Save to bytes
output = io.BytesIO()
# Preserve format, default to JPEG
fmt = img.format or 'JPEG'
if fmt.upper() == 'PNG' and img.mode == 'RGBA':
resized.save(output, format='PNG', optimize=True)
else:
# Convert to RGB for JPEG
if resized.mode in ('RGBA', 'P'):
resized = resized.convert('RGB')
resized.save(output, format='JPEG', quality=85, optimize=True)
return output.getvalue()
def serve_file(cid: str, width: Optional[int] = None) -> Tuple[Optional[bytes], str, int]:
"""
Serve a file by CID.
Args:
cid: The CID of the file
width: Optional width for resizing (images only)
Returns:
Tuple of (file_data, content_type, status_code)
"""
# Get file path from CID
file_path = get_file_path(cid)
if not file_path:
return None, 'text/plain', 404
if not os.path.exists(file_path):
print(f"[FileServer] File not found: {file_path}")
return None, 'text/plain', 404
# Determine content type from extension
ext = os.path.splitext(file_path)[1].lower()
content_types = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp',
'.gif': 'image/gif',
'.mp4': 'video/mp4',
'.mkv': 'video/x-matroska',
'.avi': 'video/x-msvideo',
'.webm': 'video/webm',
}
content_type = content_types.get(ext, 'application/octet-stream')
try:
with open(file_path, 'rb') as f:
file_data = f.read()
# Resize if width is specified and it's an image
if width and PIL_AVAILABLE and content_type.startswith('image/'):
try:
file_data = resize_image(file_data, width)
# After resize, it's always JPEG (unless PNG with alpha)
if ext != '.png':
content_type = 'image/jpeg'
except Exception as e:
print(f"[FileServer] Resize error: {e}")
# Fall through to serve original
return file_data, content_type, 200
except Exception as e:
print(f"[FileServer] Error reading {file_path}: {e}")
return None, 'text/plain', 500
def get_file_url(cid: str, base_url: str, width: Optional[int] = None) -> str:
"""
Generate a file URL for a CID.
Args:
cid: The CID of the file
base_url: Base URL of the server
width: Optional width for resizing (images only)
Returns:
Full URL to the file
"""
if not cid:
return ""
if width:
return f"{base_url}/file/{cid}/w{width}"
return f"{base_url}/file/{cid}"
+265
View File
@@ -0,0 +1,265 @@
"""
Plugin Completion Subscriber for meta-stremio.
Subscribes to Redis pub/sub channel 'meta-sort:plugin:complete' to be notified
when specific plugins finish processing files. This allows Stremio to:
1. Log metadata updates for debugging
2. Invalidate any local caches (if implemented)
3. Track which files have complete metadata
Plugins of interest to Stremio:
- filename-parser: Provides season/episode info for TV vs Movie categorization
- tmdb: Provides rich metadata (title, year, poster, description, etc.)
"""
from __future__ import annotations
import os
import json
import threading
from typing import Callable, Optional, List, Set
try:
import redis
REDIS_AVAILABLE = True
except ImportError:
REDIS_AVAILABLE = False
redis = None
# Redis configuration
REDIS_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379')
# Plugins that Stremio cares about
WATCHED_PLUGINS: Set[str] = {'filename-parser', 'tmdb'}
# Channel for plugin completion events
PLUGIN_COMPLETE_CHANNEL = 'meta-sort:plugin:complete'
class PluginSubscriber:
"""
Subscribes to plugin completion events from meta-sort.
When filename-parser or tmdb plugins complete, the subscriber
receives notifications that can be used to update Stremio's view
of the metadata.
"""
def __init__(
self,
url: str = None,
watched_plugins: Set[str] = None,
on_plugin_complete: Callable[[str, str, str], None] = None
):
"""
Initialize the plugin subscriber.
Args:
url: Redis connection URL
watched_plugins: Set of plugin IDs to watch (default: filename-parser, tmdb)
on_plugin_complete: Callback when a watched plugin completes
Signature: (file_hash, plugin_id, file_path) -> None
"""
if not REDIS_AVAILABLE:
raise ImportError("redis package not installed. Run: pip install redis")
self._url = url or REDIS_URL
self._watched_plugins = watched_plugins or WATCHED_PLUGINS
self._on_plugin_complete = on_plugin_complete
self._pubsub: Optional[redis.client.PubSub] = None
self._thread: Optional[threading.Thread] = None
self._running = False
self._client: Optional[redis.Redis] = None
# Track files with complete metadata (all watched plugins finished)
self._file_plugin_status: dict[str, Set[str]] = {}
def start(self) -> bool:
"""Start the subscriber in a background thread."""
if self._running:
return True
try:
self._client = redis.from_url(self._url, decode_responses=True)
self._client.ping() # Test connection
self._pubsub = self._client.pubsub()
self._pubsub.subscribe(PLUGIN_COMPLETE_CHANNEL)
self._running = True
self._thread = threading.Thread(
target=self._listen_loop,
daemon=True,
name='PluginSubscriber'
)
self._thread.start()
print(f"[PluginSubscriber] Started, watching plugins: {', '.join(self._watched_plugins)}")
return True
except Exception as e:
print(f"[PluginSubscriber] Failed to start: {e}")
self._running = False
return False
def stop(self) -> None:
"""Stop the subscriber."""
self._running = False
if self._pubsub:
try:
self._pubsub.unsubscribe()
self._pubsub.close()
except Exception:
pass
self._pubsub = None
if self._client:
try:
self._client.close()
except Exception:
pass
self._client = None
print("[PluginSubscriber] Stopped")
def _listen_loop(self) -> None:
"""Background loop that listens for plugin completion events."""
while self._running and self._pubsub:
try:
message = self._pubsub.get_message(timeout=1.0)
if message and message['type'] == 'message':
self._handle_message(message['data'])
except redis.ConnectionError:
print("[PluginSubscriber] Connection lost, attempting reconnect...")
self._reconnect()
except Exception as e:
if self._running:
print(f"[PluginSubscriber] Error in listen loop: {e}")
def _reconnect(self) -> None:
"""Attempt to reconnect to Redis."""
import time
for attempt in range(5):
if not self._running:
return
try:
time.sleep(2 ** attempt) # Exponential backoff
if self._pubsub:
self._pubsub.close()
if self._client:
self._client.close()
self._client = redis.from_url(self._url, decode_responses=True)
self._client.ping()
self._pubsub = self._client.pubsub()
self._pubsub.subscribe(PLUGIN_COMPLETE_CHANNEL)
print("[PluginSubscriber] Reconnected successfully")
return
except Exception as e:
print(f"[PluginSubscriber] Reconnect attempt {attempt + 1} failed: {e}")
print("[PluginSubscriber] Failed to reconnect after 5 attempts")
self._running = False
def _handle_message(self, data: str) -> None:
"""Handle a plugin completion message."""
try:
payload = json.loads(data)
file_hash = payload.get('fileHash', '')
plugin_id = payload.get('pluginId', '')
file_path = payload.get('filePath', '')
# Only process plugins we're watching
if plugin_id not in self._watched_plugins:
return
# Track plugin completion for this file
if file_hash not in self._file_plugin_status:
self._file_plugin_status[file_hash] = set()
self._file_plugin_status[file_hash].add(plugin_id)
# Log the completion
print(f"[PluginSubscriber] Plugin '{plugin_id}' completed for {file_hash[:12]}...")
# Check if all watched plugins have completed for this file
completed_plugins = self._file_plugin_status[file_hash]
if self._watched_plugins.issubset(completed_plugins):
print(f"[PluginSubscriber] All watched plugins complete for {file_hash[:12]}...")
# Could trigger a cache refresh here if we had local caching
# Call the callback if provided
if self._on_plugin_complete:
try:
self._on_plugin_complete(file_hash, plugin_id, file_path)
except Exception as e:
print(f"[PluginSubscriber] Callback error: {e}")
except json.JSONDecodeError:
print(f"[PluginSubscriber] Invalid JSON message: {data[:100]}")
except Exception as e:
print(f"[PluginSubscriber] Error handling message: {e}")
def is_running(self) -> bool:
"""Check if the subscriber is running."""
return self._running
def get_status(self) -> dict:
"""Get subscriber status for debugging."""
return {
'running': self._running,
'watched_plugins': list(self._watched_plugins),
'tracked_files': len(self._file_plugin_status),
}
# Global subscriber instance
_subscriber: Optional[PluginSubscriber] = None
def init_subscriber(
on_plugin_complete: Callable[[str, str, str], None] = None
) -> Optional[PluginSubscriber]:
"""
Initialize and start the global plugin subscriber.
Args:
on_plugin_complete: Optional callback when watched plugins complete
Returns:
The subscriber instance, or None if Redis is unavailable
"""
global _subscriber
if not REDIS_AVAILABLE:
print("[PluginSubscriber] Redis not available, skipping subscriber init")
return None
if _subscriber and _subscriber.is_running():
return _subscriber
_subscriber = PluginSubscriber(on_plugin_complete=on_plugin_complete)
if _subscriber.start():
return _subscriber
_subscriber = None
return None
def get_subscriber() -> Optional[PluginSubscriber]:
"""Get the global subscriber instance."""
return _subscriber
def stop_subscriber() -> None:
"""Stop the global subscriber."""
global _subscriber
if _subscriber:
_subscriber.stop()
_subscriber = None
+178
View File
@@ -0,0 +1,178 @@
"""
Poster API for Meta-Stremio
Serves poster and backdrop images by CID with optional resizing.
Builds a CID -> file path index from video metadata.
Endpoints:
- GET /poster/{cid} - Serve original image
- GET /poster/{cid}/w{width} - Serve resized image (width specified)
"""
from __future__ import annotations
import os
import io
from typing import Optional, Dict, Tuple
from threading import Lock
# Optional PIL for image resizing
try:
from PIL import Image
PIL_AVAILABLE = True
except ImportError:
PIL_AVAILABLE = False
Image = None
# Configuration
FILES_PATH = os.environ.get('FILES_PATH', '/files')
# CID -> file path index
_cid_index: Dict[str, str] = {}
_index_lock = Lock()
_index_built = False
def build_cid_index(storage) -> None:
"""Build CID to file path index from storage metadata."""
global _cid_index, _index_built
with _index_lock:
_cid_index.clear()
try:
videos = storage.get_all_videos()
for video in videos:
# Index poster
if video.poster and video.poster_path:
_cid_index[video.poster] = video.poster_path
# Index backdrop
if video.backdrop and video.backdrop_path:
_cid_index[video.backdrop] = video.backdrop_path
_index_built = True
print(f"[Poster] Built CID index: {len(_cid_index)} images")
except Exception as e:
print(f"[Poster] Error building CID index: {e}")
def get_image_path(cid: str) -> Optional[str]:
"""Get the file path for a CID."""
with _index_lock:
rel_path = _cid_index.get(cid)
if rel_path:
return os.path.join(FILES_PATH, rel_path)
return None
def is_index_built() -> bool:
"""Check if the CID index has been built."""
return _index_built
def get_index_size() -> int:
"""Get the number of images in the index."""
with _index_lock:
return len(_cid_index)
def resize_image(image_data: bytes, width: int) -> bytes:
"""Resize an image to the specified width, maintaining aspect ratio."""
if not PIL_AVAILABLE:
raise RuntimeError("PIL not available for image resizing")
img = Image.open(io.BytesIO(image_data))
# Calculate new height maintaining aspect ratio
ratio = width / img.width
height = int(img.height * ratio)
# Use high-quality resampling
resized = img.resize((width, height), Image.Resampling.LANCZOS)
# Save to bytes
output = io.BytesIO()
# Preserve format, default to JPEG
fmt = img.format or 'JPEG'
if fmt.upper() == 'PNG' and img.mode == 'RGBA':
resized.save(output, format='PNG', optimize=True)
else:
# Convert to RGB for JPEG
if resized.mode in ('RGBA', 'P'):
resized = resized.convert('RGB')
resized.save(output, format='JPEG', quality=85, optimize=True)
return output.getvalue()
def serve_poster(cid: str, width: Optional[int] = None) -> Tuple[Optional[bytes], str, int]:
"""
Serve a poster image by CID.
Args:
cid: The CID of the image
width: Optional width for resizing
Returns:
Tuple of (image_data, content_type, status_code)
"""
# Get file path from CID
file_path = get_image_path(cid)
if not file_path:
return None, 'text/plain', 404
if not os.path.exists(file_path):
print(f"[Poster] File not found: {file_path}")
return None, 'text/plain', 404
# Determine content type from extension
ext = os.path.splitext(file_path)[1].lower()
content_types = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp',
'.gif': 'image/gif',
}
content_type = content_types.get(ext, 'image/jpeg')
try:
with open(file_path, 'rb') as f:
image_data = f.read()
# Resize if width is specified
if width and PIL_AVAILABLE:
try:
image_data = resize_image(image_data, width)
# After resize, it's always JPEG (unless PNG with alpha)
if not (ext == '.png'):
content_type = 'image/jpeg'
except Exception as e:
print(f"[Poster] Resize error: {e}")
# Fall through to serve original
return image_data, content_type, 200
except Exception as e:
print(f"[Poster] Error reading {file_path}: {e}")
return None, 'text/plain', 500
def get_poster_url(cid: str, base_url: str, width: Optional[int] = None) -> str:
"""
Generate a poster URL for a CID.
Args:
cid: The CID of the image
base_url: Base URL of the server
width: Optional width for resizing
Returns:
Full URL to the poster image
"""
if not cid:
return ""
if width:
return f"{base_url}/poster/{cid}/w{width}"
return f"{base_url}/poster/{cid}"
+955
View File
@@ -0,0 +1,955 @@
#!/usr/bin/env python3
"""
Meta-Stremio Server
A standalone Stremio addon that:
1. Reads video metadata from KV storage (written by meta-sort)
2. Transcodes video on-the-fly using FFmpeg with adaptive quality
3. Serves HLS streams with muxed video+audio segments
4. Provides a dashboard for monitoring and setup
5. Registers with service discovery for inter-service navigation
Environment Variables:
- MEDIA_DIR: Directory containing video files (default: /data/media)
- CACHE_DIR: Directory for transcoded segments (default: /data/cache)
- PORT: HTTP server port (default: 7000)
- STORAGE_MODE: Storage backend 'leader' or 'redis' (default: leader)
- META_CORE_PATH: Path to meta-core shared volume (default: /meta-core)
- FILES_PATH: Path to files shared volume (default: /files)
- REDIS_URL: Redis connection URL (default: redis://localhost:6379)
- REDIS_PREFIX: Redis key prefix (default: meta-sort:)
- SCHEME: URL scheme for generated URLs (http/https/auto)
- SEGMENT_DURATION: HLS segment length in seconds (default: 4)
- PREFETCH_SEGMENTS: How many segments to prefetch ahead (default: 4)
"""
from __future__ import annotations
import os
import re
import json
import signal
import sys
import base64
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, unquote
import threading
import stremio
import transcoder
from storage import init_service_discovery, get_service_discovery
import fileserver
import plugin_subscriber
# Configuration
PORT = int(os.environ.get('PORT', '7000'))
SCHEME = os.environ.get('SCHEME', '').lower().strip()
META_CORE_PATH = os.environ.get('META_CORE_PATH', '/meta-core')
BASE_URL = os.environ.get('BASE_URL', '')
# Initialize storage
storage = stremio.init_storage()
# Initialize service discovery (only if META_CORE_PATH exists)
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)
except Exception as e:
print(f"[Server] Service discovery init failed: {e}")
# Global Stremio handler
stremio_handler = stremio.StremioHandler()
def parse_config_from_path(path: str) -> tuple[str, dict]:
"""
Parse Stremio config from URL path.
Stremio passes config as URL-safe base64-encoded JSON in the path:
/{config}/manifest.json -> config dict
/manifest.json -> empty dict
Returns:
(remaining_path, config_dict)
"""
# Check if path starts with a potential config segment
# Config paths look like: /eyJsYW5n.../manifest.json
parts = path.strip('/').split('/', 1)
if len(parts) >= 1 and parts[0]:
potential_config = parts[0]
# Skip known non-config paths
if potential_config in ('manifest.json', 'catalog', 'meta', 'stream', 'stremio',
'health', 'api', 'transcode', 'direct', 'poster', 'configure'):
return path, {}
# Try to decode as base64 config
try:
# Add padding if needed
padded = potential_config + '=' * (4 - len(potential_config) % 4)
decoded = base64.urlsafe_b64decode(padded).decode('utf-8')
config = json.loads(decoded)
# Return remaining path with leading slash
remaining = '/' + parts[1] if len(parts) > 1 else '/'
return remaining, config
except (ValueError, json.JSONDecodeError, UnicodeDecodeError):
# Not a valid config, return original path
pass
return path, {}
def encode_config(config: dict) -> str:
"""Encode config dict to URL-safe base64 string."""
json_str = json.dumps(config, separators=(',', ':'))
encoded = base64.urlsafe_b64encode(json_str.encode('utf-8')).decode('utf-8')
return encoded.rstrip('=')
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
print(f"[{self.address_string()}] {fmt % args}")
def send_json(self, data: dict, status: int = 200):
body = json.dumps(data).encode()
self.send_response(status)
self.send_header('Content-Type', 'application/json')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(body)
def send_data(self, data: bytes, content_type: str):
self.send_response(200)
self.send_header('Content-Type', content_type)
self.send_header('Content-Length', len(data))
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(data)
def do_OPTIONS(self):
self.send_response(200)
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'GET, HEAD, POST, OPTIONS')
self.send_header('Access-Control-Allow-Headers', 'Content-Type, Range')
self.end_headers()
def do_HEAD(self):
parsed = urlparse(self.path)
path = unquote(parsed.path)
if path.startswith('/stremio/') or path == '/' or path.startswith('/manifest.json'):
self.send_response(200)
self.send_header('Access-Control-Allow-Origin', '*')
if path.endswith('.json'):
self.send_header('Content-Type', 'application/json')
else:
self.send_header('Content-Type', 'text/html')
self.end_headers()
elif path.startswith('/transcode/') or path.startswith('/direct/'):
self.send_response(200)
self.send_header('Access-Control-Allow-Origin', '*')
if path.endswith('.m3u8'):
self.send_header('Content-Type', 'application/vnd.apple.mpegurl')
elif path.endswith('.ts'):
self.send_header('Content-Type', 'video/mp2t')
elif path.endswith('.vtt'):
self.send_header('Content-Type', 'text/vtt')
else:
self.send_header('Content-Type', 'application/octet-stream')
self.end_headers()
elif path.startswith('/file/') or path.startswith('/poster/'):
# Generic file serving by CID - /file/{cid} or /poster/{cid} (backward compat)
m = re.match(r'^/(?:file|poster)/([a-zA-Z0-9]+)(?:/w(\d+))?$', path)
if m:
cid = m.group(1)
# Check if file exists by looking up CID in metadata
file_path = fileserver.get_file_path(cid)
if file_path and os.path.exists(file_path):
self.send_response(200)
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Content-Type', 'image/jpeg')
self.send_header('Cache-Control', 'public, max-age=604800')
self.end_headers()
else:
self.send_error(404, f"File not found: {cid}")
else:
self.send_error(404, "Invalid file path")
else:
self.send_error(404)
def do_POST(self):
parsed = urlparse(self.path)
path = unquote(parsed.path)
# Library refresh endpoint
if path == '/api/library/refresh':
try:
# Reconnect storage to refresh
storage = stremio.get_storage()
if hasattr(storage, 'disconnect'):
storage.disconnect()
stremio.init_storage()
# Reinitialize fileserver with new storage
fileserver.init(stremio.get_storage())
return self.send_json({'status': 'ok', 'message': 'Library refreshed'})
except Exception as e:
return self.send_json({'status': 'error', 'message': str(e)}, 500)
# Reset metrics
if path == '/transcode/reset-metrics':
transcoder.reset_metrics()
return self.send_json({'status': 'ok'})
self.send_error(404)
def do_GET(self):
parsed = urlparse(self.path)
raw_path = unquote(parsed.path)
# Parse config from path (e.g., /eyJsYW5n.../manifest.json)
path, config = parse_config_from_path(raw_path)
# Root - serve setup page
if path == '/' or path == '/index.html':
return self.serve_setup_page()
# Configure page
if path == '/configure':
return self.serve_configure_page()
# API: Get supported languages
if path == '/api/languages':
return self.send_json({
'languages': [{'code': code, 'name': name} for code, name in stremio.get_supported_languages()],
})
# Health check
if path == '/health':
storage = stremio.get_storage()
return self.send_json({
'status': 'ok',
'storage': storage.get_status() if storage else {'connected': False},
})
# === Dashboard API ===
# Storage status
if path == '/api/storage-status':
storage = stremio.get_storage()
return self.send_json(storage.get_status() if storage else {'connected': False})
# Library stats
if path == '/api/stats':
stats = stremio.get_library_stats()
return self.send_json(stats)
# Library list
if path == '/api/library':
storage = stremio.get_storage()
videos = storage.get_all_videos() if storage else []
return self.send_json({
'videos': [v.to_dict() for v in videos],
'count': len(videos),
})
# Discovered services (for inter-service navigation)
if path == '/api/services':
return self.handle_services_api()
# Plugin subscriber status
if path == '/api/plugin-subscriber':
subscriber = plugin_subscriber.get_subscriber()
if subscriber:
return self.send_json(subscriber.get_status())
return self.send_json({'running': False, 'reason': 'Not initialized'})
# === File API ===
# /file/{cid} or /poster/{cid} (backward compat) - serve file by CID
# /file/{cid}/w{width} - serve resized image
m = re.match(r'^/(?:file|poster)/([a-zA-Z0-9]+)(?:/w(\d+))?$', path)
if m:
return self.handle_file(m.group(1), int(m.group(2)) if m.group(2) else None)
# === Stremio manifest ===
if path == '/manifest.json' or path == '/stremio/manifest.json':
return self.handle_stremio_manifest(config)
# Stremio catalog
m = re.match(r'^(?:/stremio)?/catalog/(\w+)/([^/]+)(?:/([^.]+))?\.json$', path)
if m:
return self.handle_stremio_catalog(m.group(1), m.group(2), m.group(3), config)
# Stremio meta
m = re.match(r'^(?:/stremio)?/meta/(\w+)/([^/]+)\.json$', path)
if m:
return self.handle_stremio_meta(m.group(1), m.group(2), config)
# Stremio stream
m = re.match(r'^(?:/stremio)?/stream/(\w+)/([^/]+)\.json$', path)
if m:
return self.handle_stremio_stream(m.group(1), m.group(2), config)
# === Transcoder endpoints ===
# Metrics
if path == '/transcode/metrics':
metrics = transcoder.get_metrics()
metrics['total_files'] = stremio.get_storage().get_video_count()
return self.send_json(metrics)
# Direct file serving
m = re.match(r'^/direct/(.+)$', path)
if m:
return self.handle_direct_file(m.group(1))
# Master playlist variants
m = re.match(r'^/transcode/(.+?)/master_(\w+)_a(\d+)\.m3u8$', path)
if m:
return self.handle_master_playlist(m.group(1), m.group(2), int(m.group(3)))
m = re.match(r'^/transcode/(.+?)/master_a(\d+)\.m3u8$', path)
if m:
return self.handle_master_playlist(m.group(1), None, int(m.group(2)))
m = re.match(r'^/transcode/(.+?)/master\.m3u8$', path)
if m:
return self.handle_master_playlist(m.group(1))
m = re.match(r'^/transcode/(.+?)/master_(\w+)\.m3u8$', path)
if m:
return self.handle_master_playlist(m.group(1), m.group(2))
# Stream playlist
m = re.match(r'^/transcode/(.+?)/stream_a(\d+)_(\w+)\.m3u8$', path)
if m:
return self.handle_stream_playlist(m.group(1), int(m.group(2)), m.group(3))
# Segment
m = re.match(r'^/transcode/(.+?)/seg_a(\d+)_(\w+)_(\d+)\.ts$', path)
if m:
return self.handle_segment(m.group(1), int(m.group(2)), m.group(3), int(m.group(4)))
# Subtitle playlist
m = re.match(r'^/transcode/(.+?)/subtitle_(\d+)\.m3u8$', path)
if m:
return self.handle_subtitle_playlist(m.group(1), int(m.group(2)))
# Subtitle VTT
m = re.match(r'^/transcode/(.+?)/subtitle_(\d+)\.vtt$', path)
if m:
return self.handle_subtitle_vtt(m.group(1), int(m.group(2)))
self.send_error(404)
def serve_setup_page(self):
"""Serve the setup/dashboard page."""
html_path = os.path.join(os.path.dirname(__file__), 'www', 'index.html')
if not os.path.exists(html_path):
# Try parent directory
html_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'www', 'index.html')
if os.path.exists(html_path):
with open(html_path, 'r', encoding='utf-8') as f:
content = f.read()
self.send_response(200)
self.send_header('Content-Type', 'text/html')
self.send_header('Content-Length', len(content.encode('utf-8')))
self.end_headers()
self.wfile.write(content.encode('utf-8'))
else:
# Fallback minimal page
content = f"""<!DOCTYPE html>
<html>
<head><title>Meta-Stremio</title></head>
<body style="font-family: sans-serif; background: #1a1a2e; color: #fff; padding: 2rem;">
<h1>Meta-Stremio</h1>
<p>Install URL: <code>{self.get_base_url()}/manifest.json</code></p>
<p><a href="stremio://{self.headers.get('Host', 'localhost')}/manifest.json" style="color: #4dabf7;">Install in Stremio</a></p>
</body>
</html>"""
self.send_response(200)
self.send_header('Content-Type', 'text/html')
self.end_headers()
self.wfile.write(content.encode('utf-8'))
def serve_configure_page(self):
"""Serve the configuration page for language settings."""
html_path = os.path.join(os.path.dirname(__file__), 'www', 'configure.html')
if not os.path.exists(html_path):
html_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'www', 'configure.html')
if os.path.exists(html_path):
with open(html_path, 'r', encoding='utf-8') as f:
content = f.read()
self.send_response(200)
self.send_header('Content-Type', 'text/html')
self.send_header('Content-Length', len(content.encode('utf-8')))
self.end_headers()
self.wfile.write(content.encode('utf-8'))
else:
# Inline configure page (fallback)
base_url = self.get_base_url()
host = self.headers.get('Host', 'localhost')
languages = stremio.get_supported_languages()
lang_options = '\n'.join(
f'<option value="{code}">{name}</option>'
for code, name in languages
)
content = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Configure Meta-Stremio</title>
<style>
:root {{
--bg-primary: #0a0a0f;
--bg-secondary: #12121a;
--bg-tertiary: #1a1a2e;
--text-primary: #e0e0e0;
--text-secondary: #a0a0a0;
--accent-primary: #4ecdc4;
--border-color: #2a2a3e;
}}
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
padding: 2rem;
display: flex;
justify-content: center;
align-items: center;
}}
.container {{
max-width: 500px;
width: 100%;
}}
h1 {{
font-size: 1.8rem;
margin-bottom: 0.5rem;
background: linear-gradient(135deg, var(--accent-primary), #ff6b6b);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}}
.subtitle {{ color: var(--text-secondary); margin-bottom: 2rem; }}
.card {{
background: var(--bg-secondary);
border-radius: 12px;
padding: 1.5rem;
border: 1px solid var(--border-color);
margin-bottom: 1.5rem;
}}
.form-group {{ margin-bottom: 1.5rem; }}
.form-group:last-child {{ margin-bottom: 0; }}
label {{
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}}
.hint {{ font-size: 0.85rem; color: var(--text-secondary); margin-top: 0.25rem; }}
select {{
width: 100%;
padding: 0.75rem;
border-radius: 8px;
border: 1px solid var(--border-color);
background: var(--bg-tertiary);
color: var(--text-primary);
font-size: 1rem;
}}
.url-box {{
background: var(--bg-tertiary);
border-radius: 8px;
padding: 1rem;
font-family: monospace;
font-size: 0.85rem;
word-break: break-all;
margin-bottom: 1rem;
border: 1px solid var(--border-color);
color: var(--accent-primary);
}}
.btn {{
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border-radius: 8px;
text-decoration: none;
font-weight: 600;
font-size: 0.9rem;
border: none;
cursor: pointer;
width: 100%;
margin-bottom: 0.75rem;
}}
.btn-primary {{
background: var(--accent-primary);
color: var(--bg-primary);
}}
.btn-secondary {{
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-color);
}}
.warning {{
background: rgba(255, 217, 61, 0.15);
color: #ffd93d;
padding: 0.75rem;
border-radius: 8px;
font-size: 0.85rem;
margin-bottom: 1rem;
}}
</style>
</head>
<body>
<div class="container">
<h1>Configure Meta-Stremio</h1>
<p class="subtitle">Set your language preferences for metadata display</p>
<div class="card">
<div class="form-group">
<label for="displayLang">Display Language</label>
<select id="displayLang">
{lang_options}
</select>
<p class="hint">Language preference for titles (uses metadata from meta-sort's TMDB plugin)</p>
</div>
</div>
<div class="card">
<p style="margin-bottom: 0.5rem; font-weight: 500;">Install URL:</p>
<div class="url-box" id="installUrl">{base_url}/manifest.json</div>
<a href="#" id="installBtn" class="btn btn-primary">Install in Stremio</a>
<button onclick="copyUrl()" class="btn btn-secondary">Copy URL</button>
</div>
</div>
<script>
const baseUrl = '{base_url}';
const host = '{host}';
function encodeConfig(config) {{
const json = JSON.stringify(config);
// URL-safe base64 encode
const encoded = btoa(json).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');
return encoded;
}}
function updateUrl() {{
const displayLang = document.getElementById('displayLang').value;
let url, stremioUrl;
if (displayLang === 'en') {{
// No config needed for default
url = baseUrl + '/manifest.json';
stremioUrl = 'stremio://' + host + '/manifest.json';
}} else {{
const config = {{ displayLanguage: displayLang }};
const encoded = encodeConfig(config);
url = baseUrl + '/' + encoded + '/manifest.json';
stremioUrl = 'stremio://' + host + '/' + encoded + '/manifest.json';
}}
document.getElementById('installUrl').textContent = url;
document.getElementById('installBtn').href = stremioUrl;
}}
function copyUrl() {{
const url = document.getElementById('installUrl').textContent;
navigator.clipboard.writeText(url).then(() => {{
const btn = event.target;
const original = btn.textContent;
btn.textContent = 'Copied!';
setTimeout(() => btn.textContent = original, 2000);
}});
}}
// Update URL when language changes
document.getElementById('displayLang').addEventListener('change', updateUrl);
// Try to detect browser language
const browserLang = navigator.language.split('-')[0];
const langSelect = document.getElementById('displayLang');
for (const opt of langSelect.options) {{
if (opt.value === browserLang) {{
opt.selected = true;
break;
}}
}}
// Initial URL update
updateUrl();
</script>
</body>
</html>"""
self.send_response(200)
self.send_header('Content-Type', 'text/html')
self.end_headers()
self.wfile.write(content.encode('utf-8'))
def handle_services_api(self):
"""Return list of discovered services for dashboard navigation."""
services = []
leader_info = None
try:
sd = get_service_discovery()
if sd and sd.is_started():
all_services = sd.discover_all_services()
for svc in all_services:
# Build dashboard URL from API URL
api_url = svc.get('api', '')
dashboard_path = svc.get('endpoints', {}).get('dashboard', '/')
services.append({
'name': svc.get('name', 'Unknown'),
'url': api_url + dashboard_path if api_url else '',
'api': api_url,
'status': svc.get('status', 'unknown'),
'capabilities': svc.get('capabilities', []),
'version': svc.get('version', ''),
})
except Exception as e:
print(f"[Server] Error discovering services: {e}")
# Get leader info from leader storage
try:
storage = stremio.get_storage()
if storage and hasattr(storage, '_leader_discovery'):
discovery = storage._leader_discovery
if discovery:
info = discovery.get_leader_info()
if info:
leader_info = {
'host': info.host if hasattr(info, 'host') else info.get('host', ''),
'api': info.api if hasattr(info, 'api') else info.get('api', ''),
'http': info.http if hasattr(info, 'http') else info.get('http', ''),
}
except Exception as e:
print(f"[Server] Error getting leader info: {e}")
return self.send_json({
'services': services,
'current': 'meta-stremio',
'leader': leader_info,
'isLeader': False, # meta-stremio is never the leader
})
def handle_file(self, cid: str, width: int = None):
"""Serve a file by CID with optional resizing (for images)."""
file_data, content_type, status = fileserver.serve_file(cid, width)
if status == 200 and file_data:
self.send_response(200)
self.send_header('Content-Type', content_type)
self.send_header('Content-Length', len(file_data))
self.send_header('Cache-Control', 'public, max-age=604800') # 7 days
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(file_data)
elif status == 404:
self.send_error(404, f"File not found: {cid}")
else:
self.send_error(status, "Error serving file")
def handle_stremio_manifest(self, config: dict = None):
host = self.get_host()
# Set base URL for poster URLs in responses
stremio.set_base_url(self.get_base_url())
data, content_type = stremio_handler.handle_manifest(host, config)
self.send_data(data, content_type)
def get_host(self) -> str:
host = self.headers.get('X-Forwarded-Host') or self.headers.get('Host', 'localhost')
if ':' in host and not host.startswith('['):
host = host.rsplit(':', 1)[0]
return host
def handle_stremio_catalog(self, catalog_type: str, catalog_id: str, extra: str = None, config: dict = None):
extra_dict = {}
if extra:
for param in extra.split('&'):
if '=' in param:
key, value = param.split('=', 1)
extra_dict[key] = unquote(value)
# Set base URL for poster URLs in responses
stremio.set_base_url(self.get_base_url())
data, content_type = stremio_handler.handle_catalog(catalog_type, catalog_id, extra_dict, config)
self.send_data(data, content_type)
def handle_stremio_meta(self, meta_type: str, meta_id: str, config: dict = None):
# Set base URL for poster URLs in responses
stremio.set_base_url(self.get_base_url())
data, content_type = stremio_handler.handle_meta(meta_type, meta_id, config)
if data:
self.send_data(data, content_type)
else:
self.send_error(404, "Video not found")
def handle_stremio_stream(self, stream_type: str, stream_id: str, config: dict = None):
base_url = self.get_base_url()
# Set base URL for poster URLs in responses
stremio.set_base_url(base_url)
data, content_type = stremio_handler.handle_stream(stream_type, stream_id, base_url, config)
if data:
self.send_data(data, content_type)
else:
self.send_error(404, "Video not found")
# === Transcoder handlers ===
def get_file_info(self, filepath: str):
# Try multiple path resolutions:
# 1. Relative to MEDIA_DIR (e.g., /files/watch/filename.mp4)
# 2. Absolute path starting with /files/ (e.g., /files/test/filename.mp4)
# 3. Prepend /files/ if path looks like files/... (from URL encoding)
candidates = [
os.path.join(transcoder.MEDIA_DIR, filepath), # Relative to MEDIA_DIR
'/' + filepath if not filepath.startswith('/') else filepath, # Make absolute
]
full_path = None
for candidate in candidates:
if os.path.exists(candidate):
full_path = candidate
break
if not full_path:
self.send_error(404, f"File not found: {filepath}")
return None, None, None
info = transcoder.get_video_info(full_path)
if not info:
self.send_error(500, "Could not probe file")
return None, None, None
return full_path, transcoder.get_file_hash(filepath), info
def handle_master_playlist(self, filepath: str, resolution: str = None, audio: int = None):
full_path, file_hash, info = self.get_file_info(filepath)
if not info:
return
playlist = transcoder.generate_master_playlist(info, resolution, audio)
self.send_data(playlist.encode(), 'application/vnd.apple.mpegurl')
def handle_stream_playlist(self, filepath: str, audio: int, resolution: str):
full_path, file_hash, info = self.get_file_info(filepath)
if not info:
return
playlist = transcoder.generate_stream_playlist(info, audio, resolution)
self.send_data(playlist.encode(), 'application/vnd.apple.mpegurl')
def handle_segment(self, filepath: str, audio: int, resolution: str, segment: int):
full_path, file_hash, info = self.get_file_info(filepath)
if not info:
return
video_codec, audio_codec = transcoder.extract_codecs(info)
transcoder.segment_manager.set_codec_info(video_codec, audio_codec, filepath)
data = transcoder.get_or_transcode_segment(filepath, file_hash, audio, resolution, segment, info)
if data:
self.send_data(data, 'video/mp2t')
else:
self.send_error(500, "Transcode failed")
def handle_subtitle_playlist(self, filepath: str, sub_index: int):
full_path, file_hash, info = self.get_file_info(filepath)
if not info:
return
playlist = transcoder.generate_subtitle_playlist(info, sub_index)
self.send_data(playlist.encode(), 'application/vnd.apple.mpegurl')
def handle_subtitle_vtt(self, filepath: str, sub_index: int):
full_path, file_hash, info = self.get_file_info(filepath)
if not info:
return
key = f"{file_hash}:sub:{sub_index}"
content, error = transcoder.subtitle_manager.get_subtitle(key, full_path, file_hash, sub_index, info)
if content:
self.send_data(content.encode('utf-8'), 'text/vtt')
else:
error_vtt = f"WEBVTT\n\nNOTE Subtitle extraction failed: {error or 'Unknown error'}\n"
self.send_data(error_vtt.encode('utf-8'), 'text/vtt')
def handle_direct_file(self, filepath: str):
# Try multiple path resolutions (same logic as get_file_info)
candidates = [
os.path.join(transcoder.MEDIA_DIR, filepath), # Relative to MEDIA_DIR
'/' + filepath if not filepath.startswith('/') else filepath, # Make absolute
]
full_path = None
for candidate in candidates:
if os.path.exists(candidate):
full_path = candidate
break
if not full_path:
self.send_error(404, f"File not found: {filepath}")
return
ext = os.path.splitext(filepath)[1].lower()
content_types = {
'.mp4': 'video/mp4',
'.mkv': 'video/x-matroska',
'.webm': 'video/webm',
'.avi': 'video/x-msvideo',
'.mov': 'video/quicktime',
'.m4v': 'video/x-m4v',
'.ts': 'video/mp2t',
'.m2ts': 'video/mp2t',
}
content_type = content_types.get(ext, 'application/octet-stream')
file_size = os.path.getsize(full_path)
range_header = self.headers.get('Range')
if range_header:
range_match = re.match(r'bytes=(\d*)-(\d*)', range_header)
if range_match:
start = int(range_match.group(1)) if range_match.group(1) else 0
end = int(range_match.group(2)) if range_match.group(2) else file_size - 1
end = min(end, file_size - 1)
length = end - start + 1
self.send_response(206)
self.send_header('Content-Type', content_type)
self.send_header('Content-Length', length)
self.send_header('Content-Range', f'bytes {start}-{end}/{file_size}')
self.send_header('Accept-Ranges', 'bytes')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
with open(full_path, 'rb') as f:
f.seek(start)
remaining = length
chunk_size = 64 * 1024
while remaining > 0:
chunk = f.read(min(chunk_size, remaining))
if not chunk:
break
self.wfile.write(chunk)
remaining -= len(chunk)
return
self.send_response(200)
self.send_header('Content-Type', content_type)
self.send_header('Content-Length', file_size)
self.send_header('Accept-Ranges', 'bytes')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
with open(full_path, 'rb') as f:
chunk_size = 64 * 1024
while True:
chunk = f.read(chunk_size)
if not chunk:
break
self.wfile.write(chunk)
def get_base_url(self) -> str:
host = self.headers.get('X-Forwarded-Host') or self.headers.get('Host', 'localhost')
# SCHEME can be 'http', 'https', 'auto', or empty
# 'auto' or empty means auto-detect based on host
if SCHEME and SCHEME not in ('auto', ''):
proto = SCHEME
else:
is_localhost = 'localhost' in host or '127.0.0.1' in host or '::1' in host
proto = 'http' if is_localhost else 'https'
clean_host = host.replace(':80', '').replace(':443', '')
return f"{proto}://{clean_host}"
class ThreadedServer(HTTPServer):
def process_request(self, request, client_address):
t = threading.Thread(target=self._handle, args=(request, client_address), daemon=True)
t.start()
def _handle(self, request, client_address):
try:
self.finish_request(request, client_address)
except Exception:
self.handle_error(request, client_address)
finally:
self.shutdown_request(request)
def shutdown_handler(signum, frame):
"""Handle graceful shutdown."""
print("\n[Server] Shutting down...")
# Stop plugin subscriber
try:
plugin_subscriber.stop_subscriber()
except Exception as e:
print(f"[Server] Error stopping plugin subscriber: {e}")
# Stop service discovery
try:
sd = get_service_discovery()
if sd:
sd.stop()
except Exception as e:
print(f"[Server] Error stopping service discovery: {e}")
sys.exit(0)
def main():
# Register shutdown handlers
signal.signal(signal.SIGINT, shutdown_handler)
signal.signal(signal.SIGTERM, shutdown_handler)
storage = stremio.get_storage()
storage_type = storage.get_status().get('type', 'unknown')
video_count = storage.get_video_count()
# Initialize fileserver with storage reference
fileserver.init(storage)
# Initialize plugin subscriber to receive notifications when
# filename-parser and tmdb plugins complete
subscriber = plugin_subscriber.init_subscriber()
print(f"Meta-Stremio starting on port {PORT}")
print(f"Storage: {storage_type} | Videos: {video_count}")
print(f"Media: {transcoder.MEDIA_DIR} | Cache: {transcoder.CACHE_DIR}")
print(f"Segment: {transcoder.SEGMENT_DURATION}s | Prefetch: {transcoder.PREFETCH_SEGMENTS} segments")
print(f"Manifest URL: http://localhost:{PORT}/manifest.json")
print(f"Dashboard: http://localhost:{PORT}/")
print("Adaptive quality: target 60-80% transcode ratio")
if service_discovery:
print(f"Service discovery: enabled (registered as meta-stremio)")
else:
print(f"Service discovery: disabled (META_CORE_PATH not found)")
if subscriber:
print(f"Plugin subscriber: enabled (watching: filename-parser, tmdb)")
else:
print(f"Plugin subscriber: disabled (Redis not available)")
ThreadedServer(('0.0.0.0', PORT), Handler).serve_forever()
if __name__ == '__main__':
main()
+33
View File
@@ -0,0 +1,33 @@
"""
Storage abstraction layer for meta-stremio.
Provides a unified interface for reading video metadata from different backends:
- LeaderStorage: Discovers and connects to the KV leader (recommended)
- RedisStorage: Reads from Redis with direct URL (legacy)
Also provides service discovery for inter-service communication.
"""
from .provider import StorageProvider, VideoMetadata
from .redis_storage import RedisStorage
from .leader_storage import LeaderStorage
from .leader_discovery import LeaderDiscovery, LeaderLockInfo
from .service_discovery import (
ServiceDiscovery,
ServiceInfo,
get_service_discovery,
init_service_discovery
)
__all__ = [
'StorageProvider',
'VideoMetadata',
'RedisStorage',
'LeaderStorage',
'LeaderDiscovery',
'LeaderLockInfo',
'ServiceDiscovery',
'ServiceInfo',
'get_service_discovery',
'init_service_discovery',
]
+300
View File
@@ -0,0 +1,300 @@
"""
Leader Discovery for meta-stremio (FOLLOWER-only)
This is a Python implementation of the leader discovery pattern used in meta-fuse.
meta-stremio never spawns Redis - it only connects to the leader's Redis instance.
Architecture:
- Reads info file at META_CORE_PATH/locks/kv-leader.info
- The leader holds flock on kv-leader.lock, writes metadata to kv-leader.info
- Parses leader info (Redis URL, HTTP URL)
- Watches for leader changes via polling and file watching
- Triggers reconnection callbacks on leader failure/change
"""
from __future__ import annotations
import os
import json
import time
import threading
from dataclasses import dataclass
from pathlib import Path
from typing import Optional, Callable
from urllib.request import urlopen
from urllib.error import URLError
# 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
@dataclass
class LeaderLockInfo:
"""Leader lock file content format."""
host: str
api: str # Redis connection URL (e.g., redis://10.0.1.50:6379)
http: str # HTTP API URL for the leader service
timestamp: int
pid: int
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 LeaderDiscovery:
"""
Leader Discovery for meta-stremio.
Discovers and monitors the KV leader via a lock file in the shared filesystem.
"""
def __init__(
self,
meta_core_path: str,
health_check_interval: float = 5.0,
max_failures: int = 3
):
self.meta_core_path = meta_core_path
self.health_check_interval = health_check_interval
self.max_failures = max_failures
self.lock_file_path = os.path.join(meta_core_path, 'locks', 'kv-leader.info')
self.leader_info: Optional[LeaderLockInfo] = None
self._is_started = False
self._stop_event = threading.Event()
self._health_check_thread: Optional[threading.Thread] = None
self._file_observer = None
self._consecutive_failures = 0
# Event callbacks
self._on_leader_found_callback: Optional[Callable[[LeaderLockInfo], None]] = None
self._on_leader_lost_callback: Optional[Callable[[], None]] = None
def start(self) -> None:
"""Start watching for leader."""
if self._is_started:
print("[LeaderDiscovery] Already started")
return
print(f"[LeaderDiscovery] Starting, lock file: {self.lock_file_path}")
# Ensure lock directory exists
self._ensure_lock_dir()
# Try to read existing leader info
self._check_for_leader()
# Start file watcher (if watchdog is available)
self._start_file_watcher()
# Start health check thread
self._start_health_check()
self._is_started = True
def stop(self) -> None:
"""Stop watching."""
if not self._is_started:
return
print("[LeaderDiscovery] Stopping...")
self._stop_event.set()
# Stop health check thread
if self._health_check_thread and self._health_check_thread.is_alive():
self._health_check_thread.join(timeout=2.0)
# Stop file observer
if self._file_observer:
self._file_observer.stop()
self._file_observer.join(timeout=2.0)
self._file_observer = None
self._is_started = False
def _ensure_lock_dir(self) -> None:
"""Ensure lock directory exists."""
lock_dir = os.path.dirname(self.lock_file_path)
try:
os.makedirs(lock_dir, exist_ok=True)
except OSError as e:
print(f"[LeaderDiscovery] Could not create lock directory: {e}")
def _check_for_leader(self) -> None:
"""Check for leader by reading lock file."""
try:
with open(self.lock_file_path, 'r') as f:
content = f.read()
data = json.loads(content)
# Validate leader info
if data and data.get('api') and data.get('http'):
info = LeaderLockInfo(
host=data.get('host', 'unknown'),
api=data['api'],
http=data['http'],
timestamp=data.get('timestamp', 0),
pid=data.get('pid', 0)
)
is_new = (
self.leader_info is None or
self.leader_info.api != info.api
)
self.leader_info = info
self._consecutive_failures = 0
if is_new:
print(f"[LeaderDiscovery] Leader found: {info.host} at {info.api}")
if self._on_leader_found_callback:
self._on_leader_found_callback(info)
except FileNotFoundError:
# Lock file doesn't exist - no leader yet
if self.leader_info:
print("[LeaderDiscovery] Lock file removed, leader lost")
self._handle_leader_lost()
except json.JSONDecodeError as e:
print(f"[LeaderDiscovery] Error parsing lock file: {e}")
except Exception as e:
print(f"[LeaderDiscovery] Error reading lock file: {e}")
def _is_leader_healthy(self) -> bool:
"""Check if current leader is still healthy."""
if not self.leader_info:
return False
try:
health_url = f"{self.leader_info.http}/health"
with urlopen(health_url, timeout=5) as response:
return response.status == 200
except (URLError, TimeoutError, Exception):
return False
def _handle_leader_lost(self) -> None:
"""Handle leader being lost."""
self.leader_info = None
if self._on_leader_lost_callback:
self._on_leader_lost_callback()
def _start_file_watcher(self) -> None:
"""Start watching lock file for changes."""
if not WATCHDOG_AVAILABLE:
print("[LeaderDiscovery] watchdog not available, using polling only")
return
try:
lock_dir = os.path.dirname(self.lock_file_path)
handler = LockFileHandler(
callback=self._check_for_leader,
lock_filename='kv-leader.info'
)
self._file_observer = Observer()
self._file_observer.schedule(handler, lock_dir, recursive=False)
self._file_observer.start()
print(f"[LeaderDiscovery] File watcher started for {lock_dir}")
except Exception as e:
print(f"[LeaderDiscovery] Could not start file watcher: {e}")
def _start_health_check(self) -> None:
"""Start health check loop."""
def health_check_loop():
while not self._stop_event.is_set():
try:
if not self.leader_info:
# No leader known, try to find one
self._check_for_leader()
else:
# Check if current leader is healthy
healthy = self._is_leader_healthy()
if healthy:
self._consecutive_failures = 0
else:
self._consecutive_failures += 1
print(f"[LeaderDiscovery] Leader health check failed ({self._consecutive_failures}/{self.max_failures})")
if self._consecutive_failures >= self.max_failures:
print("[LeaderDiscovery] Leader appears dead, marking as lost")
self._handle_leader_lost()
# Try to find new leader
self._check_for_leader()
except Exception as e:
print(f"[LeaderDiscovery] Health check error: {e}")
self._stop_event.wait(self.health_check_interval)
self._health_check_thread = threading.Thread(
target=health_check_loop,
daemon=True,
name="leader-health-check"
)
self._health_check_thread.start()
# ========================================================================
# Event Registration
# ========================================================================
def on_leader_found(self, callback: Callable[[LeaderLockInfo], None]) -> 'LeaderDiscovery':
"""Register callback for when leader is found/changed."""
self._on_leader_found_callback = callback
# If we already have leader info, call immediately
if self.leader_info:
callback(self.leader_info)
return self
def on_leader_lost(self, callback: Callable[[], None]) -> 'LeaderDiscovery':
"""Register callback for when leader is lost."""
self._on_leader_lost_callback = callback
return self
# ========================================================================
# Getters
# ========================================================================
def get_leader_info(self) -> Optional[LeaderLockInfo]:
"""Get current leader info."""
return self.leader_info
def get_redis_url(self) -> Optional[str]:
"""Get Redis URL of current leader."""
return self.leader_info.api if self.leader_info else None
def has_leader(self) -> bool:
"""Check if a leader is known."""
return self.leader_info is not None
+496
View File
@@ -0,0 +1,496 @@
"""
Leader-aware Storage Provider for meta-stremio.
Uses leader discovery to automatically connect to the KV leader.
Handles reconnection when the leader changes or becomes unavailable.
"""
from __future__ import annotations
import os
import threading
from typing import List, Optional, Callable
from .provider import StorageProvider, VideoMetadata
from .leader_discovery import LeaderDiscovery, LeaderLockInfo
try:
import redis
REDIS_AVAILABLE = True
except ImportError:
REDIS_AVAILABLE = False
redis = None
# Configuration from environment
META_CORE_PATH = os.environ.get('META_CORE_PATH', '/meta-core')
FILES_PATH = os.environ.get('FILES_PATH', '/files')
REDIS_PREFIX = os.environ.get('REDIS_PREFIX', 'meta-sort:')
class LeaderStorage(StorageProvider):
"""
Storage provider that discovers and connects to the KV leader.
Uses leader discovery to automatically find the Redis instance
managed by meta-sort. Handles reconnection on leader failure.
"""
def __init__(
self,
meta_core_path: str = None,
files_path: str = None,
prefix: str = None,
redis_url: str = None # Override - skip leader discovery
):
if not REDIS_AVAILABLE:
raise ImportError("redis package not installed. Run: pip install redis")
self._meta_core_path = meta_core_path or META_CORE_PATH
self._files_path = files_path or FILES_PATH
self._prefix = prefix or REDIS_PREFIX
self._override_url = redis_url # Direct Redis URL (skip leader discovery)
self._leader_discovery: Optional[LeaderDiscovery] = None
self._client: Optional[redis.Redis] = None
self._connected = False
self._lock = threading.Lock()
# Callbacks
self._on_ready_callbacks: List[Callable[[], None]] = []
self._on_disconnect_callbacks: List[Callable[[], None]] = []
def connect(self) -> None:
"""Connect to the storage backend via leader discovery."""
# If direct URL provided, skip leader discovery
if self._override_url:
print(f"[LeaderStorage] Using direct Redis URL: {self._override_url}")
self._connect_to_redis(self._override_url)
return
# Use leader discovery
self._leader_discovery = LeaderDiscovery(
meta_core_path=self._meta_core_path
)
# Set up callbacks
self._leader_discovery.on_leader_found(self._on_leader_found)
self._leader_discovery.on_leader_lost(self._on_leader_lost)
# Start discovery
self._leader_discovery.start()
def disconnect(self) -> None:
"""Disconnect from the storage backend."""
# Stop leader discovery
if self._leader_discovery:
self._leader_discovery.stop()
self._leader_discovery = None
# Disconnect Redis
self._disconnect_redis()
def is_connected(self) -> bool:
"""Check if connected to the storage backend."""
with self._lock:
if not self._connected or not self._client:
return False
try:
self._client.ping()
return True
except Exception:
self._connected = False
return False
def _on_leader_found(self, info: LeaderLockInfo) -> None:
"""Handle leader found event."""
print(f"[LeaderStorage] Connecting to leader at {info.api}...")
self._connect_to_redis(info.api)
def _on_leader_lost(self) -> None:
"""Handle leader lost event."""
print("[LeaderStorage] Leader lost, disconnecting...")
self._disconnect_redis()
self._notify_disconnect()
def _connect_to_redis(self, url: str) -> None:
"""Connect to Redis."""
with self._lock:
try:
# Disconnect existing client
self._disconnect_redis_unlocked()
# Create new client
self._client = redis.from_url(url, decode_responses=True)
self._client.ping() # Test connection
self._connected = True
print(f"[LeaderStorage] Connected to Redis at {url}")
# Notify ready
self._notify_ready()
except Exception as e:
print(f"[LeaderStorage] Failed to connect to Redis: {e}")
self._client = None
self._connected = False
def _disconnect_redis(self) -> None:
"""Disconnect from Redis."""
with self._lock:
self._disconnect_redis_unlocked()
def _disconnect_redis_unlocked(self) -> None:
"""Disconnect from Redis (without lock)."""
if self._client:
try:
self._client.close()
except Exception:
pass
self._client = None
self._connected = False
def _notify_ready(self) -> None:
"""Notify ready callbacks."""
for callback in self._on_ready_callbacks:
try:
callback()
except Exception as e:
print(f"[LeaderStorage] Error in ready callback: {e}")
def _notify_disconnect(self) -> None:
"""Notify disconnect callbacks."""
for callback in self._on_disconnect_callbacks:
try:
callback()
except Exception as e:
print(f"[LeaderStorage] Error in disconnect callback: {e}")
# ========================================================================
# Event Registration
# ========================================================================
def on_ready(self, callback: Callable[[], None]) -> 'LeaderStorage':
"""Register callback for when storage is ready."""
self._on_ready_callbacks.append(callback)
if self._connected:
callback()
return self
def on_disconnect(self, callback: Callable[[], None]) -> 'LeaderStorage':
"""Register callback for disconnection."""
self._on_disconnect_callbacks.append(callback)
return self
# ========================================================================
# Key Schema Helpers
# ========================================================================
def _get_file_key(self, hash_id: str) -> str:
"""Get the Redis key for a file hash."""
return f"{self._prefix}file:{hash_id}"
def _resolve_path(self, path: str) -> str:
"""Resolve path by prepending FILES_PATH if needed."""
if not path:
return path
if path.startswith(self._files_path):
return path
if path.startswith('/'):
return f"{self._files_path}{path}"
return f"{self._files_path}/{path}"
def _parse_nested_array(self, data: dict, prefix: str) -> list:
"""Parse nested array format (prefix/0, prefix/1, etc.) into a list."""
items = []
index = 0
while True:
key = f'{prefix}/{index}'
if key in data:
items.append(data[key])
index += 1
else:
break
return items
def _parse_video(self, hash_id: str, data: dict) -> Optional[VideoMetadata]:
"""Parse Redis hash data into VideoMetadata.
Handles both flat keys and nested keys (e.g., 'plot/eng' for description).
meta-sort stores data using a flattened nested key format.
"""
if not data:
return None
import json as jsonlib
# Parse JSON fields
audio_tracks = []
subtitles = []
genres = []
studios = []
if 'audioTracks' in data:
try:
audio_tracks = jsonlib.loads(data['audioTracks'])
except (jsonlib.JSONDecodeError, TypeError):
pass
if 'subtitles' in data:
try:
subtitles = jsonlib.loads(data['subtitles'])
except (jsonlib.JSONDecodeError, TypeError):
pass
# Parse genres - can be JSON array or nested keys (genres/0, genres/1, etc.)
if 'genres' in data:
try:
genres = jsonlib.loads(data['genres'])
except (jsonlib.JSONDecodeError, TypeError):
if isinstance(data['genres'], str):
genres = [g.strip() for g in data['genres'].split(',') if g.strip()]
else:
# Try nested key format (genres/0, genres/1, etc.)
genres = self._parse_nested_array(data, 'genres')
# Parse studios - nested keys (studio/0, studio/1, etc.)
studios = self._parse_nested_array(data, 'studio')
# Parse numeric fields
def parse_int(val):
try:
return int(val) if val else None
except (ValueError, TypeError):
return None
def parse_float(val):
try:
return float(val) if val else None
except (ValueError, TypeError):
return None
# Resolve file path with FILES_PATH
file_path = data.get('filePath', data.get('sourcePath', ''))
resolved_path = self._resolve_path(file_path)
# Normalize video type: 'tvshow' -> 'series' for Stremio compatibility
raw_type = data.get('type', data.get('videoType', 'movie'))
video_type = 'series' if raw_type == 'tvshow' else raw_type
# Get description from nested plot/eng or flat description/plot
description = data.get('description')
if not description:
description = data.get('plot')
if not description:
# Try nested format: plot/eng, plot/en, etc.
for lang in ['eng', 'en', 'english', 'und']:
description = data.get(f'plot/{lang}')
if description:
break
# Parse rating (stored as string by TMDBProcessor)
rating = parse_float(data.get('rating'))
# Parse release date
release_date = data.get('releasedate', data.get('releaseDate'))
# Parse episode title - try episodeTitle or extract from titles/eng
episode_title = data.get('episodeTitle', data.get('episodeName'))
if not episode_title:
episode_title = data.get('titles/eng', data.get('titles/en'))
# Parse tagline
tagline = data.get('tagline')
return VideoMetadata(
hash_id=hash_id,
file_path=resolved_path,
title=data.get('title', data.get('originalTitle', '')),
video_type=video_type,
year=parse_int(data.get('year', data.get('movieYear'))),
season=parse_int(data.get('season')),
episode=parse_int(data.get('episode')),
duration=parse_float(data.get('duration', data.get('fileinfo/duration'))),
width=parse_int(data.get('width', data.get('fileinfo/streamdetails/video/0/width'))),
height=parse_int(data.get('height', data.get('fileinfo/streamdetails/video/0/height'))),
video_codec=data.get('videoCodec', data.get('fileinfo/streamdetails/video/0/codec')),
audio_codec=data.get('audioCodec', data.get('fileinfo/streamdetails/audio/0/codec')),
container=data.get('container'),
file_size=parse_int(data.get('fileSize', data.get('sizeByte'))),
audio_tracks=audio_tracks,
subtitles=subtitles,
imdb_id=data.get('imdbId', data.get('imdbid')),
tmdb_id=data.get('tmdbId', data.get('tmdbid')),
poster=data.get('poster') or data.get('posterUrl'), # Fallback to posterUrl for backward compat
backdrop=data.get('backdrop') or data.get('backdropUrl'), # Fallback to backdropUrl
poster_path=data.get('posterPath'),
backdrop_path=data.get('backdropPath'),
description=description,
genres=genres,
episode_title=episode_title,
rating=rating,
release_date=release_date,
tagline=tagline,
studios=studios,
)
# ========================================================================
# StorageProvider Interface
# ========================================================================
def get_all_videos(self) -> List[VideoMetadata]:
"""Get all videos from storage."""
if not self.is_connected():
return []
videos = []
pattern = f"{self._prefix}file:*"
try:
for key in self._client.scan_iter(pattern, count=100):
# Skip index key
if '__index__' in key:
continue
# Check file type - only include video files
file_type = self._client.hget(key, 'fileType')
if file_type != 'video':
continue
hash_id = key.replace(f"{self._prefix}file:", "")
data = self._client.hgetall(key)
video = self._parse_video(hash_id, data)
if video and video.file_path:
videos.append(video)
except Exception as e:
print(f"[LeaderStorage] Error getting all videos: {e}")
# Sort by title
videos.sort(key=lambda v: v.title.lower())
return videos
def get_video_by_hash(self, hash_id: str) -> Optional[VideoMetadata]:
"""Get a video by its hash ID."""
if not self.is_connected():
return None
try:
key = self._get_file_key(hash_id)
data = self._client.hgetall(key)
return self._parse_video(hash_id, data)
except Exception as e:
print(f"[LeaderStorage] Error getting video {hash_id}: {e}")
return None
def get_videos_by_type(self, video_type: str) -> List[VideoMetadata]:
"""Get all videos of a specific type."""
videos = self.get_all_videos()
return [v for v in videos if v.video_type == video_type]
def search_videos(self, query: str) -> List[VideoMetadata]:
"""Search videos by title."""
if not query:
return self.get_all_videos()
query_lower = query.lower()
videos = self.get_all_videos()
return [v for v in videos if query_lower in v.title.lower()]
def get_video_by_imdb_id(self, imdb_id: str) -> Optional[VideoMetadata]:
"""Get a video by its IMDB ID (e.g., 'tt1727587')."""
if not self.is_connected() or not imdb_id:
return None
# Normalize IMDB ID format
imdb_id = imdb_id.lower()
if not imdb_id.startswith('tt'):
imdb_id = f"tt{imdb_id}"
try:
# Scan all videos looking for matching IMDB ID
pattern = f"{self._prefix}file:*"
for key in self._client.scan_iter(pattern, count=100):
if '__index__' in key:
continue
# Check imdbid/imdbId field
stored_imdb = self._client.hget(key, 'imdbid') or self._client.hget(key, 'imdbId')
if stored_imdb and stored_imdb.lower() == imdb_id:
hash_id = key.replace(f"{self._prefix}file:", "")
data = self._client.hgetall(key)
return self._parse_video(hash_id, data)
except Exception as e:
print(f"[LeaderStorage] Error finding video by IMDB ID {imdb_id}: {e}")
return None
def get_video_count(self) -> int:
"""Get total number of videos."""
if not self.is_connected():
return 0
try:
# Try index first
index_key = f"{self._prefix}file:__index__"
count = self._client.scard(index_key)
if count > 0:
return count
# Fallback to scanning
pattern = f"{self._prefix}file:*"
count = 0
for key in self._client.scan_iter(pattern, count=100):
if '__index__' not in key:
count += 1
return count
except Exception as e:
print(f"[LeaderStorage] Error counting videos: {e}")
return 0
def get_status(self) -> dict:
"""Get storage status for dashboard."""
leader_info = None
if self._leader_discovery:
info = self._leader_discovery.get_leader_info()
if info:
leader_info = {
'host': info.host,
'api': info.api,
'http': info.http,
}
status = {
'type': 'leader',
'connected': self.is_connected(),
'meta_core_path': self._meta_core_path,
'files_path': self._files_path,
'prefix': self._prefix,
'video_count': 0,
'leader': leader_info,
}
if status['connected']:
try:
status['video_count'] = self.get_video_count()
info = self._client.info('memory')
status['memory_used'] = info.get('used_memory_human', 'N/A')
except Exception:
pass
return status
# ========================================================================
# Pub/Sub (Future enhancement)
# ========================================================================
def on_video_added(self, callback: Callable[[VideoMetadata], None]) -> None:
"""Subscribe to video added events."""
# TODO: Implement pub/sub for real-time updates
pass
def on_video_removed(self, callback: Callable[[str], None]) -> None:
"""Subscribe to video removed events."""
# TODO: Implement pub/sub for real-time updates
pass
+148
View File
@@ -0,0 +1,148 @@
"""
Abstract StorageProvider interface for video metadata.
meta-stremio is a read-only consumer - it does NOT write metadata.
All writes are handled by meta-sort.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import List, Optional, Callable
@dataclass
class VideoMetadata:
"""Video metadata stored in KV by meta-sort, read by meta-stremio."""
hash_id: str
file_path: str
title: str
video_type: str = 'movie' # 'movie' | 'series' | 'anime'
year: Optional[int] = None
season: Optional[int] = None
episode: Optional[int] = None
duration: Optional[float] = None
width: Optional[int] = None
height: Optional[int] = None
video_codec: Optional[str] = None
audio_codec: Optional[str] = None
container: Optional[str] = None
file_size: Optional[int] = None
audio_tracks: List[dict] = field(default_factory=list)
subtitles: List[dict] = field(default_factory=list)
imdb_id: Optional[str] = None
tmdb_id: Optional[str] = None
poster: Optional[str] = None
backdrop: Optional[str] = None
poster_path: Optional[str] = None
backdrop_path: Optional[str] = None
description: Optional[str] = None
genres: List[str] = field(default_factory=list)
# New fields for improved listings
episode_title: Optional[str] = None # Title specific to this episode
rating: Optional[float] = None # TMDB/IMDB rating (0-10)
release_date: Optional[str] = None # Full release date (YYYY-MM-DD)
tagline: Optional[str] = None # Movie/series tagline
studios: List[str] = field(default_factory=list) # Production studios
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
return {
'hash_id': self.hash_id,
'file_path': self.file_path,
'title': self.title,
'video_type': self.video_type,
'year': self.year,
'season': self.season,
'episode': self.episode,
'duration': self.duration,
'width': self.width,
'height': self.height,
'video_codec': self.video_codec,
'audio_codec': self.audio_codec,
'container': self.container,
'file_size': self.file_size,
'audio_tracks': self.audio_tracks,
'subtitles': self.subtitles,
'imdb_id': self.imdb_id,
'tmdb_id': self.tmdb_id,
'poster': self.poster,
'backdrop': self.backdrop,
'poster_path': self.poster_path,
'backdrop_path': self.backdrop_path,
'description': self.description,
'genres': self.genres,
'episode_title': self.episode_title,
'rating': self.rating,
'release_date': self.release_date,
'tagline': self.tagline,
'studios': self.studios,
}
class StorageProvider(ABC):
"""
Abstract interface for video metadata storage.
meta-stremio only reads - all writes are handled by meta-sort.
"""
@abstractmethod
def connect(self) -> None:
"""Connect to the storage backend."""
pass
@abstractmethod
def disconnect(self) -> None:
"""Disconnect from the storage backend."""
pass
@abstractmethod
def is_connected(self) -> bool:
"""Check if connected to the storage backend."""
pass
@abstractmethod
def get_all_videos(self) -> List[VideoMetadata]:
"""Get all videos from storage."""
pass
@abstractmethod
def get_video_by_hash(self, hash_id: str) -> Optional[VideoMetadata]:
"""Get a video by its hash ID."""
pass
@abstractmethod
def get_videos_by_type(self, video_type: str) -> List[VideoMetadata]:
"""Get all videos of a specific type (movie, series, anime)."""
pass
@abstractmethod
def search_videos(self, query: str) -> List[VideoMetadata]:
"""Search videos by title."""
pass
@abstractmethod
def get_video_by_imdb_id(self, imdb_id: str) -> Optional[VideoMetadata]:
"""Get a video by its IMDB ID (e.g., 'tt1727587')."""
pass
@abstractmethod
def get_video_count(self) -> int:
"""Get total number of videos in storage."""
pass
def on_video_added(self, callback: Callable[[VideoMetadata], None]) -> None:
"""Subscribe to video added events (optional)."""
pass
def on_video_removed(self, callback: Callable[[str], None]) -> None:
"""Subscribe to video removed events (optional)."""
pass
def get_status(self) -> dict:
"""Get storage status for dashboard."""
return {
'connected': self.is_connected(),
'video_count': self.get_video_count() if self.is_connected() else 0,
}
+379
View File
@@ -0,0 +1,379 @@
"""
Redis storage provider for meta-stremio.
Reads video metadata from Redis (written by meta-sort).
Uses the same key schema as meta-sort for compatibility.
"""
from __future__ import annotations
import os
import json
from typing import List, Optional
try:
import redis
REDIS_AVAILABLE = True
except ImportError:
REDIS_AVAILABLE = False
redis = None
from .provider import StorageProvider, VideoMetadata
# Redis configuration
REDIS_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379')
REDIS_PREFIX = os.environ.get('REDIS_PREFIX', 'meta-sort:')
class RedisStorage(StorageProvider):
"""
Redis storage provider.
Reads video metadata from Redis using the meta-sort key schema:
- meta-sort:file:{hashId} -> Hash containing all metadata fields
"""
def __init__(self, url: str = None, prefix: str = None):
if not REDIS_AVAILABLE:
raise ImportError("redis package not installed. Run: pip install redis")
self._url = url or REDIS_URL
self._prefix = prefix or REDIS_PREFIX
self._client: Optional[redis.Redis] = None
self._connected = False
def connect(self) -> None:
"""Connect to Redis."""
try:
self._client = redis.from_url(self._url, decode_responses=True)
# Test connection
self._client.ping()
self._connected = True
print(f"[RedisStorage] Connected to {self._url}")
except Exception as e:
self._connected = False
print(f"[RedisStorage] Failed to connect: {e}")
raise
def disconnect(self) -> None:
"""Disconnect from Redis."""
if self._client:
self._client.close()
self._client = None
self._connected = False
print("[RedisStorage] Disconnected")
def is_connected(self) -> bool:
"""Check if connected to Redis."""
if not self._connected or not self._client:
return False
try:
self._client.ping()
return True
except Exception:
self._connected = False
return False
def get_file_path_by_cid(self, cid: str) -> Optional[str]:
"""
Get the file path for a file by its CID.
Looks up file:{cid} in Redis and returns the path.
Tries 'path' field first, then falls back to 'filePath'.
This works for any file including poster images.
Returns the relative path if found, None otherwise.
"""
if not self.is_connected():
return None
try:
key = self._get_file_key(cid)
# Try 'path' field first (relative path)
path = self._client.hget(key, 'path')
if path:
return path
# Fall back to 'filePath' (full path from meta-sort)
file_path = self._client.hget(key, 'filePath')
if file_path:
# Convert absolute path to relative by removing /files/ prefix
if file_path.startswith('/files/'):
return file_path[7:] # Remove '/files/'
return file_path
return None
except Exception as e:
print(f"[RedisStorage] Error getting path for CID {cid}: {e}")
return None
def _get_file_key(self, hash_id: str) -> str:
"""Get the Redis key for a file hash."""
return f"{self._prefix}file:{hash_id}"
def _parse_video(self, hash_id: str, data: dict) -> Optional[VideoMetadata]:
"""Parse Redis hash data into VideoMetadata.
Handles both flat keys and nested keys (e.g., 'plot/eng' for description).
meta-sort stores data using a flattened nested key format.
"""
if not data:
return None
# Parse JSON fields
audio_tracks = []
subtitles = []
genres = []
studios = []
if 'audioTracks' in data:
try:
audio_tracks = json.loads(data['audioTracks'])
except (json.JSONDecodeError, TypeError):
pass
if 'subtitles' in data:
try:
subtitles = json.loads(data['subtitles'])
except (json.JSONDecodeError, TypeError):
pass
# Parse genres - can be JSON array or nested keys (genres/0, genres/1, etc.)
if 'genres' in data:
try:
genres = json.loads(data['genres'])
except (json.JSONDecodeError, TypeError):
if isinstance(data['genres'], str):
genres = [g.strip() for g in data['genres'].split(',') if g.strip()]
else:
# Try nested key format (genres/0, genres/1, etc.)
genres = self._parse_nested_array(data, 'genres')
# Parse studios - nested keys (studio/0, studio/1, etc.)
studios = self._parse_nested_array(data, 'studio')
# Parse numeric fields
def parse_int(val):
try:
return int(val) if val else None
except (ValueError, TypeError):
return None
def parse_float(val):
try:
return float(val) if val else None
except (ValueError, TypeError):
return None
# Get description from nested plot/eng or flat description/plot
description = data.get('description')
if not description:
description = data.get('plot')
if not description:
# Try nested format: plot/eng, plot/en, etc.
for lang in ['eng', 'en', 'english', 'und']:
description = data.get(f'plot/{lang}')
if description:
break
# Parse rating (stored as string by TMDBProcessor)
rating = parse_float(data.get('rating'))
# Parse release date
release_date = data.get('releasedate', data.get('releaseDate'))
# Parse episode title (for series episodes)
episode_title = data.get('episodeTitle', data.get('episodeName'))
# Parse tagline
tagline = data.get('tagline')
# Get video/audio codec and resolution from various field formats
# meta-sort stores these in fileinfo/streamdetails/video/0/... format
video_codec = data.get('videoCodec')
if not video_codec:
video_codec = data.get('fileinfo/streamdetails/video/0/codec')
audio_codec = data.get('audioCodec')
if not audio_codec:
audio_codec = data.get('fileinfo/streamdetails/audio/0/codec')
width = parse_int(data.get('width'))
if not width:
width = parse_int(data.get('fileinfo/streamdetails/video/0/width'))
height = parse_int(data.get('height'))
if not height:
height = parse_int(data.get('fileinfo/streamdetails/video/0/height'))
duration = parse_float(data.get('duration'))
if not duration:
duration = parse_float(data.get('fileinfo/duration'))
return VideoMetadata(
hash_id=hash_id,
file_path=data.get('filePath', ''),
title=data.get('title', data.get('originalTitle', '')),
video_type=data.get('type', data.get('videoType', 'movie')),
year=parse_int(data.get('year', data.get('movieYear'))),
season=parse_int(data.get('season')),
episode=parse_int(data.get('episode')),
duration=duration,
width=width,
height=height,
video_codec=video_codec,
audio_codec=audio_codec,
container=data.get('container'),
file_size=parse_int(data.get('fileSize', data.get('sizeByte'))),
audio_tracks=audio_tracks,
subtitles=subtitles,
imdb_id=data.get('imdbId', data.get('imdbid')),
tmdb_id=data.get('tmdbId', data.get('tmdbid')),
poster=data.get('poster') or data.get('posterUrl'), # Fallback to posterUrl for backward compat
backdrop=data.get('backdrop') or data.get('backdropUrl'), # Fallback to backdropUrl
poster_path=data.get('posterPath'),
backdrop_path=data.get('backdropPath'),
description=description,
genres=genres,
episode_title=episode_title,
rating=rating,
release_date=release_date,
tagline=tagline,
studios=studios,
)
def _parse_nested_array(self, data: dict, prefix: str) -> list:
"""Parse nested array format (prefix/0, prefix/1, etc.) into a list."""
items = []
index = 0
while True:
key = f'{prefix}/{index}'
if key in data:
items.append(data[key])
index += 1
else:
break
return items
def get_all_videos(self) -> List[VideoMetadata]:
"""Get all videos from Redis."""
if not self.is_connected():
return []
videos = []
pattern = f"{self._prefix}file:*"
try:
for key in self._client.scan_iter(pattern, count=100):
# Skip special keys (like file:__index__ which is a set, not a hash)
if '__index__' in key or not key.startswith(f"{self._prefix}file:"):
continue
# Check key type - only process hash keys
key_type = self._client.type(key)
if key_type != 'hash':
continue
# Check file type - only include video files
file_type = self._client.hget(key, 'fileType')
if file_type != 'video':
continue
# Extract hash_id from key
hash_id = key.replace(f"{self._prefix}file:", "")
data = self._client.hgetall(key)
video = self._parse_video(hash_id, data)
if video and video.file_path:
videos.append(video)
except Exception as e:
print(f"[RedisStorage] Error getting all videos: {e}")
# Sort by title
videos.sort(key=lambda v: v.title.lower())
return videos
def get_video_by_hash(self, hash_id: str) -> Optional[VideoMetadata]:
"""Get a video by its hash ID."""
if not self.is_connected():
return None
try:
key = self._get_file_key(hash_id)
data = self._client.hgetall(key)
return self._parse_video(hash_id, data)
except Exception as e:
print(f"[RedisStorage] Error getting video {hash_id}: {e}")
return None
def get_videos_by_type(self, video_type: str) -> List[VideoMetadata]:
"""Get all videos of a specific type."""
videos = self.get_all_videos()
return [v for v in videos if v.video_type == video_type]
def search_videos(self, query: str) -> List[VideoMetadata]:
"""Search videos by title."""
if not query:
return self.get_all_videos()
query_lower = query.lower()
videos = self.get_all_videos()
return [v for v in videos if query_lower in v.title.lower()]
def get_video_by_imdb_id(self, imdb_id: str) -> Optional[VideoMetadata]:
"""Get a video by its IMDB ID (e.g., 'tt1727587')."""
if not self.is_connected() or not imdb_id:
return None
# Normalize IMDB ID format
imdb_id = imdb_id.lower()
if not imdb_id.startswith('tt'):
imdb_id = f"tt{imdb_id}"
try:
# Scan all videos looking for matching IMDB ID
pattern = f"{self._prefix}file:*"
for key in self._client.scan_iter(pattern, count=100):
# Check imdbid/imdbId field
stored_imdb = self._client.hget(key, 'imdbid') or self._client.hget(key, 'imdbId')
if stored_imdb and stored_imdb.lower() == imdb_id:
hash_id = key.replace(f"{self._prefix}file:", "")
data = self._client.hgetall(key)
return self._parse_video(hash_id, data)
except Exception as e:
print(f"[RedisStorage] Error finding video by IMDB ID {imdb_id}: {e}")
return None
def get_video_count(self) -> int:
"""Get total number of videos."""
if not self.is_connected():
return 0
try:
pattern = f"{self._prefix}file:*"
count = 0
for _ in self._client.scan_iter(pattern, count=100):
count += 1
return count
except Exception as e:
print(f"[RedisStorage] Error counting videos: {e}")
return 0
def get_status(self) -> dict:
"""Get storage status for dashboard."""
status = {
'type': 'redis',
'connected': self.is_connected(),
'url': self._url,
'prefix': self._prefix,
'video_count': 0,
}
if status['connected']:
try:
status['video_count'] = self.get_video_count()
info = self._client.info('memory')
status['memory_used'] = info.get('used_memory_human', 'N/A')
except Exception:
pass
return status
+367
View File
@@ -0,0 +1,367 @@
"""
Service Discovery for meta-stremio.
Python implementation of the service discovery pattern used in meta-sort.
Each service registers itself in /meta-core/services/{service-name}.json
and can discover other registered services.
Features:
- Service registration with heartbeat
- Discovery of all running services
- Stale service detection (60 second threshold)
- Automatic heartbeat loop
"""
from __future__ import annotations
import os
import json
import socket
import threading
import time
from dataclasses import dataclass, field, 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."""
name: str
version: str
api: str # HTTP API URL (e.g., http://10.0.1.50:7000)
status: str = 'starting' # starting | running | stopping | stopped
pid: int = 0
hostname: str = ''
startedAt: str = ''
lastHeartbeat: str = ''
capabilities: List[str] = field(default_factory=list)
endpoints: Dict[str, str] = field(default_factory=dict)
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
return asdict(self)
class ServiceDiscovery:
"""
Service Discovery using shared filesystem.
Each service registers itself in /meta-core/services/{service-name}.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
self.version = version or SERVICE_VERSION
# Use base_url if provided, then api_url, then env BASE_URL, then auto-detect
self.api_url = base_url or api_url or BASE_URL or self._get_default_api_url()
self.capabilities = capabilities or ['streaming', 'transcoding', 'hls']
self.endpoints = endpoints or {
'health': '/health',
'manifest': '/manifest.json',
'dashboard': '/',
'api': '/api',
}
self.heartbeat_interval = heartbeat_interval
self.stale_threshold = stale_threshold
self.services_dir = os.path.join(self.meta_core_path, 'services')
self.service_file_path = os.path.join(self.services_dir, f'{self.service_name}.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 = 'starting') -> ServiceInfo:
"""Build service info for this service."""
now = datetime.utcnow().isoformat() + 'Z'
return ServiceInfo(
name=self.service_name,
version=self.version,
api=self.api_url,
status=status,
pid=os.getpid(),
hostname=socket.gethostname(),
startedAt=now,
lastHeartbeat=now,
capabilities=self.capabilities,
endpoints=self.endpoints
)
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'[ServiceDiscovery] 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'[ServiceDiscovery] 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'[ServiceDiscovery] 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'[ServiceDiscovery] 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'[ServiceDiscovery] Unregistered {self.service_name}')
except Exception as e:
print(f'[ServiceDiscovery] Failed to unregister: {e}')
# ========================================================================
# Discovery Methods
# ========================================================================
def _is_service_stale(self, info: dict) -> bool:
"""Check if a service is stale based on lastHeartbeat."""
try:
last_heartbeat_str = info.get('lastHeartbeat', '')
if not last_heartbeat_str:
return True
# Parse ISO timestamp
last_heartbeat_str = last_heartbeat_str.rstrip('Z')
last_heartbeat = datetime.fromisoformat(last_heartbeat_str)
age = (datetime.utcnow() - last_heartbeat).total_seconds()
return age > self.stale_threshold
except Exception:
return True
def discover_service(self, name: str) -> Optional[dict]:
"""Discover a service by name."""
service_path = os.path.join(self.services_dir, f'{name}.json')
try:
with open(service_path, 'r') as f:
info = json.load(f)
# Check if service is alive
if self._is_service_stale(info):
print(f'[ServiceDiscovery] Service {name} appears stale')
return None
return info
except FileNotFoundError:
return None
except Exception as e:
print(f'[ServiceDiscovery] Error discovering {name}: {e}')
return None
def discover_all_services(self) -> List[dict]:
"""Discover all registered services."""
services = []
try:
if not os.path.exists(self.services_dir):
return services
for filename in os.listdir(self.services_dir):
if not filename.endswith('.json'):
continue
name = filename.replace('.json', '')
service = self.discover_service(name)
if service and service.get('status') == 'running':
services.append(service)
except Exception as e:
print(f'[ServiceDiscovery] Error discovering services: {e}')
return services
def is_service_healthy(self, name: str) -> bool:
"""Check if a service is healthy via HTTP ping."""
service = self.discover_service(name)
if not service:
return False
health_endpoint = service.get('endpoints', {}).get('health', '/health')
url = f"{service['api']}{health_endpoint}"
try:
with urlopen(url, timeout=5) as response:
return response.status == 200
except Exception:
return False
# ========================================================================
# 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'[ServiceDiscovery] Started {self.service_name} at {self.api_url}')
except Exception as e:
print(f'[ServiceDiscovery] Registration failed (read-only?): {e}')
print(f'[ServiceDiscovery] Running in discovery-only mode')
else:
print(f'[ServiceDiscovery] 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_discovery: Optional[ServiceDiscovery] = None
def get_service_discovery() -> ServiceDiscovery:
"""Get or create the service discovery singleton."""
global _service_discovery
if _service_discovery is None:
_service_discovery = ServiceDiscovery()
return _service_discovery
def init_service_discovery(
api_url: str = None,
base_url: str = None,
**kwargs
) -> ServiceDiscovery:
"""Initialize and start the service discovery singleton."""
global _service_discovery
_service_discovery = ServiceDiscovery(api_url=api_url, base_url=base_url, **kwargs)
_service_discovery.start()
return _service_discovery
+1204
View File
File diff suppressed because it is too large Load Diff
+863
View File
@@ -0,0 +1,863 @@
#!/usr/bin/env python3
"""
HLS Live Transcoder Engine
Transcodes video segments on-demand using FFmpeg with adaptive quality.
Design: Single-threaded transcoding with multi-ahead prefetch.
- One FFmpeg process gets 100% CPU for fastest segment time
- After serving segment N, prefetch N+1..N+4 in background
- No parallel transcoding (would split CPU and increase latency)
Adapted from SegmentPlayer's transcoder for standalone Stremio addon use.
"""
from __future__ import annotations
import os
import subprocess
import hashlib
import threading
import time
import json
import re
from typing import Optional
# Configuration
MEDIA_DIR = os.environ.get('MEDIA_DIR', '/data/media')
CACHE_DIR = os.environ.get('CACHE_DIR', '/data/cache')
SEGMENT_DURATION = int(os.environ.get('SEGMENT_DURATION', '4'))
PREFETCH_SEGMENTS = int(os.environ.get('PREFETCH_SEGMENTS', '4'))
os.makedirs(CACHE_DIR, exist_ok=True)
# Resolution presets: (width, height, crf)
RESOLUTIONS = {
'original': (None, None, 23),
'1080p': (1920, 1080, 23),
'720p': (1280, 720, 24),
'480p': (854, 480, 25),
'360p': (640, 360, 26),
}
# x264 presets ordered from fastest to slowest
X264_PRESETS = ['ultrafast', 'superfast', 'veryfast', 'faster', 'fast', 'medium', 'slow', 'slower', 'veryslow']
# Subtitle codecs that cannot be converted to WebVTT (image-based)
UNSUPPORTED_SUBTITLE_CODECS = {'hdmv_pgs_subtitle', 'dvd_subtitle', 'dvb_subtitle', 'xsub'}
class AdaptiveQuality:
"""
Coordinated adaptive quality control using both preset and CRF.
Strategy - PRIORITIZE LOW CRF (quality over compression efficiency):
- CRF directly affects output quality (lower = better visual quality)
- Preset affects encoding speed (faster = less CPU, slightly worse compression)
- For streaming, visual quality matters more than file size
- So: keep CRF low, use faster presets when needed
Target: 60-80% of segment duration (transcode ratio)
- Below 60%: We have headroom, can increase quality
- 60-80%: Sweet spot, maintain current settings
- Above 80%: Too slow, decrease quality (preset first!)
- Above 100%: EMERGENCY, drop to fastest settings
Coordination rules:
- DECREASE quality: Drop preset first (faster encoding), CRF only as last resort
- INCREASE quality: Decrease CRF first (better quality), then preset (capped at 'medium')
- Max preset is 'medium' - slower presets waste CPU for minimal streaming benefit
"""
MAX_PRESET_INDEX = 5 # 'medium' - slower presets not worth it for streaming
def __init__(self, initial_preset: str = 'fast'):
self._lock = threading.Lock()
# Preset state (capped at medium)
initial_index = X264_PRESETS.index(initial_preset) if initial_preset in X264_PRESETS else 4
self._preset_index = min(initial_index, self.MAX_PRESET_INDEX)
# CRF state
self._crf_offset = 0 # 0 to 7
# Shared state
self._recent_ratios: list[float] = []
self._window_size = 5
self._consecutive_good_signals = 0
self._last_change_time = 0.0
# Thresholds
self._target_min = 60.0
self._target_max = 80.0
# Stats
self._preset_ups = 0
self._preset_downs = 0
self._crf_ups = 0
self._crf_downs = 0
@property
def preset(self) -> str:
with self._lock:
return X264_PRESETS[self._preset_index]
def get_crf(self, base_crf: int) -> int:
with self._lock:
return min(base_crf + self._crf_offset, 30)
def record_transcode(self, elapsed: float) -> dict | None:
"""Record transcode time and adjust quality settings."""
last_ratio = (elapsed / SEGMENT_DURATION) * 100
with self._lock:
self._recent_ratios.append(last_ratio)
if len(self._recent_ratios) > self._window_size:
self._recent_ratios.pop(0)
avg_ratio = sum(self._recent_ratios) / len(self._recent_ratios)
current_time = time.time()
old_preset = X264_PRESETS[self._preset_index]
old_crf = self._crf_offset
# EMERGENCY: ratio > 100%
if last_ratio > 100:
if self._preset_index > 0 or self._crf_offset < 7:
self._preset_index = 0 # ultrafast
self._crf_offset = 7
self._consecutive_good_signals = 0
self._last_change_time = current_time
self._recent_ratios.clear()
self._preset_downs += 1
self._crf_ups += 1
print(f"[AdaptiveQuality] EMERGENCY {last_ratio:.1f}% → ultrafast, CRF +7")
return {'preset': 'ultrafast', 'crf_offset': 7, 'emergency': True}
# DECREASE QUALITY: last ratio > 80%
if last_ratio > self._target_max:
self._consecutive_good_signals = 0
if self._preset_index > 0:
self._preset_index -= 1
self._preset_downs += 1
self._last_change_time = current_time
new_preset = X264_PRESETS[self._preset_index]
print(f"[AdaptiveQuality] Last {last_ratio:.1f}% > {self._target_max}% → {old_preset} → {new_preset}")
return {'preset': new_preset}
elif self._crf_offset < 7:
self._crf_offset = min(self._crf_offset + 2, 7)
self._crf_ups += 1
self._last_change_time = current_time
print(f"[AdaptiveQuality] Last {last_ratio:.1f}% > {self._target_max}% (ultrafast) → CRF +{old_crf} → +{self._crf_offset}")
return {'crf_offset': self._crf_offset}
# INCREASE QUALITY: avg ratio < 60%
elif avg_ratio < self._target_min:
self._consecutive_good_signals += 1
if self._consecutive_good_signals >= 3 and current_time - self._last_change_time >= 5.0:
if self._crf_offset > 0:
self._crf_offset -= 1
self._crf_downs += 1
self._consecutive_good_signals = 0
self._last_change_time = current_time
print(f"[AdaptiveQuality] Avg {avg_ratio:.1f}% < {self._target_min}% → CRF +{old_crf} → +{self._crf_offset}")
return {'crf_offset': self._crf_offset}
elif self._preset_index < self.MAX_PRESET_INDEX:
self._preset_index += 1
self._preset_ups += 1
self._consecutive_good_signals = 0
self._last_change_time = current_time
new_preset = X264_PRESETS[self._preset_index]
print(f"[AdaptiveQuality] Avg {avg_ratio:.1f}% < {self._target_min}% → {old_preset} → {new_preset}")
return {'preset': new_preset}
else:
self._consecutive_good_signals = 0
return None
def get_stats(self) -> dict:
with self._lock:
avg_ratio = sum(self._recent_ratios) / len(self._recent_ratios) if self._recent_ratios else 0
last_ratio = self._recent_ratios[-1] if self._recent_ratios else 0
return {
'preset': X264_PRESETS[self._preset_index],
'crf_offset': self._crf_offset,
'target_range': f"{self._target_min:.0f}-{self._target_max:.0f}%",
'avg_ratio': round(avg_ratio, 1),
'last_ratio': round(last_ratio, 1),
'preset_adjustments': {'up': self._preset_ups, 'down': self._preset_downs},
'crf_adjustments': {'up': self._crf_ups, 'down': self._crf_downs},
}
class SegmentManager:
"""
Manages segment transcoding with single-threaded execution.
Design rationale:
- Single FFmpeg gets 100% CPU → fastest per-segment time
- Multi-ahead prefetch uses idle time while user watches current segment
- Multiple requests for same segment wait on shared Event
"""
def __init__(self):
self._lock = threading.Lock()
self._in_progress: dict[str, threading.Event] = {}
self._prefetch_thread: threading.Thread | None = None
self._prefetch_queue: list[tuple[str, callable]] = []
# Metrics
self.total_segments = 0
self.cache_hits = 0
self.total_transcode_time = 0.0
self.last_segment_time = 0.0
self.min_segment_time = float('inf')
self.max_segment_time = 0.0
self._transcode_times: list[float] = []
# Current file codec info
self.current_video_codec: str | None = None
self.current_audio_codec: str | None = None
self.current_file: str | None = None
def get_segment(self, key: str, transcode_fn) -> str | None:
"""Get a segment, transcoding if necessary."""
with self._lock:
self.total_segments += 1
if key in self._in_progress:
event = self._in_progress[key]
wait_for_other = True
else:
event = threading.Event()
self._in_progress[key] = event
wait_for_other = False
if wait_for_other:
completed = event.wait(timeout=120)
if completed:
return transcode_fn()
else:
return None
try:
result = transcode_fn()
return result
finally:
with self._lock:
self._in_progress.pop(key, None)
event.set()
def schedule_prefetch(self, key: str, transcode_fn):
"""Schedule prefetch for a segment."""
with self._lock:
if key in self._in_progress:
return
if any(k == key for k, _ in self._prefetch_queue):
return
self._prefetch_queue.append((key, transcode_fn))
if not self._prefetch_thread or not self._prefetch_thread.is_alive():
self._prefetch_thread = threading.Thread(
target=self._process_prefetch_queue,
daemon=True
)
self._prefetch_thread.start()
def _process_prefetch_queue(self):
"""Process prefetch queue sequentially."""
while True:
with self._lock:
if not self._prefetch_queue:
return
key, transcode_fn = self._prefetch_queue.pop(0)
if key in self._in_progress:
continue
event = threading.Event()
self._in_progress[key] = event
try:
transcode_fn()
finally:
with self._lock:
self._in_progress.pop(key, None)
event.set()
def is_in_progress(self, key: str) -> bool:
with self._lock:
return key in self._in_progress
def record_cache_hit(self):
with self._lock:
self.total_segments += 1
self.cache_hits += 1
def record_transcode_time(self, elapsed: float):
with self._lock:
self.total_transcode_time += elapsed
self.last_segment_time = elapsed
if elapsed < self.min_segment_time:
self.min_segment_time = elapsed
if elapsed > self.max_segment_time:
self.max_segment_time = elapsed
self._transcode_times.append(elapsed)
if len(self._transcode_times) > 100:
self._transcode_times.pop(0)
def set_codec_info(self, video_codec: str | None, audio_codec: str | None, filepath: str | None = None):
with self._lock:
self.current_video_codec = video_codec
self.current_audio_codec = audio_codec
if filepath:
self.current_file = filepath
def get_metrics(self) -> dict:
with self._lock:
avg_time = sum(self._transcode_times) / len(self._transcode_times) if self._transcode_times else 0
last_ratio = (self.last_segment_time / SEGMENT_DURATION * 100) if self.last_segment_time > 0 else 0
avg_ratio = (avg_time / SEGMENT_DURATION * 100) if avg_time > 0 else 0
return {
'total_segments': self.total_segments,
'cache_hits': self.cache_hits,
'cache_hit_rate': round((self.cache_hits / self.total_segments * 100), 1) if self.total_segments > 0 else 0,
'prefetch_queue_size': len(self._prefetch_queue),
'total_transcode_time': round(self.total_transcode_time, 2),
'last_segment_time': round(self.last_segment_time, 2),
'avg_segment_time': round(avg_time, 2),
'segment_duration': SEGMENT_DURATION,
'transcode_ratio_last': round(last_ratio, 1),
'transcode_ratio_avg': round(avg_ratio, 1),
'video_codec': self.current_video_codec,
'audio_codec': self.current_audio_codec,
'current_file': self.current_file,
}
def reset_metrics(self):
with self._lock:
self.total_segments = 0
self.cache_hits = 0
self.total_transcode_time = 0.0
self.last_segment_time = 0.0
self.min_segment_time = float('inf')
self.max_segment_time = 0.0
self._transcode_times.clear()
class SubtitleManager:
"""Manages subtitle extraction with background processing."""
def __init__(self):
self._lock = threading.Lock()
self._extractions: dict[str, tuple[threading.Event, list]] = {}
def get_subtitle(self, key: str, filepath: str, file_hash: str, sub_index: int,
info: dict = None, timeout: float = 300) -> tuple[str | None, str | None]:
"""Get subtitle content, extracting if necessary."""
cache_dir = os.path.join(CACHE_DIR, file_hash)
vtt_file = os.path.join(cache_dir, f"subtitle_{sub_index}.vtt")
error_file = os.path.join(cache_dir, f"subtitle_{sub_index}.error")
# Fast path: already cached
if os.path.exists(vtt_file):
with open(vtt_file, 'r', encoding='utf-8') as f:
return f.read(), None
if os.path.exists(error_file):
with open(error_file, 'r', encoding='utf-8') as f:
return None, f.read()
with self._lock:
if os.path.exists(vtt_file):
with open(vtt_file, 'r', encoding='utf-8') as f:
return f.read(), None
if os.path.exists(error_file):
with open(error_file, 'r', encoding='utf-8') as f:
return None, f.read()
if key in self._extractions:
event, result_holder = self._extractions[key]
else:
event = threading.Event()
result_holder = [None]
self._extractions[key] = (event, result_holder)
thread = threading.Thread(
target=self._extract_background,
args=(key, filepath, file_hash, sub_index, info, event, result_holder),
daemon=True
)
thread.start()
completed = event.wait(timeout=timeout)
if completed:
if result_holder[0] is not None:
return result_holder[0]
if os.path.exists(vtt_file):
with open(vtt_file, 'r', encoding='utf-8') as f:
return f.read(), None
if os.path.exists(error_file):
with open(error_file, 'r', encoding='utf-8') as f:
return None, f.read()
return None, "Extraction completed but result not found"
return None, f"Extraction timed out after {timeout}s"
def _extract_background(self, key: str, filepath: str, file_hash: str, sub_index: int,
info: dict, event: threading.Event, result_holder: list):
try:
result = self._do_extract(filepath, file_hash, sub_index, info)
result_holder[0] = result
except Exception as e:
result_holder[0] = (None, f"Extraction thread error: {e}")
finally:
event.set()
def cleanup():
time.sleep(30)
with self._lock:
self._extractions.pop(key, None)
threading.Thread(target=cleanup, daemon=True).start()
def _do_extract(self, filepath: str, file_hash: str, sub_index: int,
info: dict = None) -> tuple[str | None, str | None]:
cache_dir = os.path.join(CACHE_DIR, file_hash)
os.makedirs(cache_dir, exist_ok=True)
vtt_file = os.path.join(cache_dir, f"subtitle_{sub_index}.vtt")
temp_file = vtt_file + '.tmp'
error_file = os.path.join(cache_dir, f"subtitle_{sub_index}.error")
if os.path.exists(vtt_file):
with open(vtt_file, 'r', encoding='utf-8') as f:
return f.read(), None
if os.path.exists(error_file):
with open(error_file, 'r', encoding='utf-8') as f:
return None, f.read()
# Get subtitle stream info
codec = None
track_index = None
if info:
subtitle_streams = [s for s in info.get('streams', []) if s.get('codec_type') == 'subtitle']
if sub_index < len(subtitle_streams):
stream = subtitle_streams[sub_index]
codec = stream.get('codec_name', '')
track_index = stream.get('index')
if codec in UNSUPPORTED_SUBTITLE_CODECS:
error_msg = f"Subtitle format '{codec}' is image-based and cannot be converted to WebVTT"
with open(error_file, 'w', encoding='utf-8') as f:
f.write(error_msg)
return None, error_msg
try:
print(f"[Subtitle {sub_index}] Extracting from {os.path.basename(filepath)}...")
is_mkv = filepath.lower().endswith(('.mkv', '.mka', '.mks'))
# Determine intermediate format based on codec
codec_ext_map = {
'subrip': 'srt', 'srt': 'srt',
'ass': 'ass', 'ssa': 'ssa',
'webvtt': 'vtt', 'mov_text': 'srt'
}
intermediate_ext = codec_ext_map.get(codec, 'srt') if codec else 'srt'
intermediate_file = os.path.join(cache_dir, f"subtitle_{sub_index}.{intermediate_ext}")
extracted = False
# Method 1: Try mkvextract for MKV files (fastest)
if is_mkv and track_index is not None:
try:
result = subprocess.run(
['mkvextract', 'tracks', filepath, f'{track_index}:{intermediate_file}'],
capture_output=True,
timeout=120
)
if result.returncode == 0 and os.path.exists(intermediate_file):
extracted = True
print(f"[Subtitle {sub_index}] Extracted via mkvextract")
except FileNotFoundError:
pass # mkvextract not installed, fall back to ffmpeg
except Exception as e:
print(f"[Subtitle {sub_index}] mkvextract failed: {e}, trying ffmpeg")
# Method 2: FFmpeg copy codec (fast demux, no re-encoding)
if not extracted:
result = subprocess.run(
['ffmpeg', '-y', '-hide_banner', '-loglevel', 'error',
'-probesize', '1M', '-analyzeduration', '1M',
'-i', filepath,
'-map', f'0:s:{sub_index}',
'-c:s', 'copy', intermediate_file],
capture_output=True,
timeout=300
)
if result.returncode == 0 and os.path.exists(intermediate_file):
extracted = True
print(f"[Subtitle {sub_index}] Extracted via ffmpeg copy")
# Convert to WebVTT if needed
if extracted and os.path.exists(intermediate_file):
if intermediate_ext == 'vtt':
# Already VTT, just rename
os.rename(intermediate_file, temp_file)
else:
# Convert to WebVTT
result = subprocess.run(
['ffmpeg', '-y', '-hide_banner', '-loglevel', 'error',
'-i', intermediate_file,
'-c:s', 'webvtt', '-f', 'webvtt', temp_file],
capture_output=True,
timeout=60
)
# Clean up intermediate file
if os.path.exists(intermediate_file):
os.remove(intermediate_file)
if result.returncode != 0:
stderr = result.stderr.decode('utf-8', errors='replace') if result.stderr else 'Unknown error'
error_msg = f"VTT conversion failed: {stderr[:200]}"
with open(error_file, 'w', encoding='utf-8') as f:
f.write(error_msg)
return None, error_msg
# Validate and finalize
if os.path.exists(temp_file):
file_size = os.path.getsize(temp_file)
if file_size > 10:
os.rename(temp_file, vtt_file)
print(f"[Subtitle {sub_index}] Extraction complete ({file_size} bytes)")
with open(vtt_file, 'r', encoding='utf-8') as f:
return f.read(), None
else:
error_msg = "Extraction produced empty VTT file"
os.remove(temp_file)
else:
error_msg = "Extraction failed - no output file"
with open(error_file, 'w', encoding='utf-8') as f:
f.write(error_msg)
return None, error_msg
except subprocess.TimeoutExpired:
error_msg = "Extraction timed out"
with open(error_file, 'w', encoding='utf-8') as f:
f.write(error_msg)
return None, error_msg
except Exception as e:
error_msg = f"Unexpected error: {str(e)}"
with open(error_file, 'w', encoding='utf-8') as f:
f.write(error_msg)
return None, error_msg
# Global managers
adaptive_quality = AdaptiveQuality(initial_preset='fast')
segment_manager = SegmentManager()
subtitle_manager = SubtitleManager()
def get_file_hash(filepath: str) -> str:
"""Generate cache key from filepath."""
return hashlib.md5(filepath.encode()).hexdigest()[:16]
def get_video_info(filepath: str) -> dict | None:
"""Get video metadata using ffprobe."""
full_path = os.path.join(MEDIA_DIR, filepath) if not filepath.startswith('/') else filepath
try:
result = subprocess.run(
['ffprobe', '-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', full_path],
capture_output=True, text=True, timeout=30
)
return json.loads(result.stdout) if result.returncode == 0 else None
except Exception:
return None
def extract_codecs(info: dict) -> tuple[str | None, str | None]:
"""Extract video and audio codec names from ffprobe info."""
streams = info.get('streams', [])
video_codec = None
audio_codec = None
for stream in streams:
codec_type = stream.get('codec_type')
if codec_type == 'video' and video_codec is None:
video_codec = stream.get('codec_name')
elif codec_type == 'audio' and audio_codec is None:
audio_codec = stream.get('codec_name')
return video_codec, audio_codec
def get_segment_path(file_hash: str, audio: int, resolution: str, segment: int) -> str:
"""Get cache path for a muxed video+audio segment."""
return os.path.join(CACHE_DIR, file_hash, f"seg_a{audio}_{resolution}_{segment:05d}.ts")
def transcode_segment(filepath: str, file_hash: str, audio: int, resolution: str, segment: int) -> str | None:
"""
Transcode a single segment using FFmpeg.
Optimized for speed with adaptive quality:
- adaptive preset: auto-adjusts based on transcode ratio (target 60-80%)
- threads 0: use all CPU cores
- tune zerolatency: reduce encoding latency
- Muxed video+audio: single segment contains both streams
"""
cache_dir = os.path.join(CACHE_DIR, file_hash)
os.makedirs(cache_dir, exist_ok=True)
output = get_segment_path(file_hash, audio, resolution, segment)
# Already cached?
if os.path.exists(output):
return output
full_path = os.path.join(MEDIA_DIR, filepath) if not filepath.startswith('/') else filepath
start_offset = segment * SEGMENT_DURATION
res_preset = RESOLUTIONS.get(resolution, RESOLUTIONS['original'])
width, height, base_crf = res_preset
# Get current adaptive preset and CRF
current_preset = adaptive_quality.preset
current_crf = adaptive_quality.get_crf(base_crf)
cpu_count = os.cpu_count() or 8
cmd = [
'ffmpeg', '-y', '-hide_banner', '-loglevel', 'error',
'-threads', str(cpu_count),
'-ss', str(start_offset),
'-i', full_path,
'-t', str(SEGMENT_DURATION),
'-map', '0:v:0',
'-map', f'0:a:{audio}?', # ? = optional, don't fail if audio track doesn't exist
# Video: H.264 with adaptive quality
'-c:v', 'libx264',
'-preset', current_preset,
'-tune', 'zerolatency',
'-crf', str(current_crf),
'-pix_fmt', 'yuv420p',
'-x264-params', f'threads={cpu_count}:lookahead_threads={min(cpu_count, 8)}',
'-force_key_frames', 'expr:gte(t,0)',
# Audio: AAC (muxed with video)
'-c:a', 'aac', '-b:a', '128k', '-ac', '2',
# Output format
'-f', 'mpegts',
'-mpegts_copyts', '1',
'-output_ts_offset', str(start_offset),
]
if width and height:
cmd.extend(['-vf', f'scale={width}:{height}:force_original_aspect_ratio=decrease,pad={width}:{height}:(ow-iw)/2:(oh-ih)/2'])
cmd.append(output)
start_time = time.time()
try:
result = subprocess.run(cmd, capture_output=True, timeout=120)
elapsed = time.time() - start_time
if result.returncode == 0 and os.path.exists(output):
segment_manager.record_transcode_time(elapsed)
adaptive_quality.record_transcode(elapsed)
return output
print(f"FFmpeg error: {result.stderr.decode()[-500:]}")
except Exception as e:
print(f"Transcode error: {e}")
return None
def get_or_transcode_segment(filepath: str, file_hash: str, audio: int, resolution: str,
segment: int, info: dict) -> bytes | None:
"""Get segment data, transcoding if necessary."""
output = get_segment_path(file_hash, audio, resolution, segment)
key = f"{file_hash}:{audio}:{resolution}:{segment}"
# Check if transcode in progress
if segment_manager.is_in_progress(key):
result = segment_manager.get_segment(
key,
lambda: transcode_segment(filepath, file_hash, audio, resolution, segment)
)
if result and os.path.exists(result):
trigger_prefetch(filepath, file_hash, audio, resolution, segment, info)
with open(result, 'rb') as f:
return f.read()
return None
# Fast path: cached
if os.path.exists(output):
segment_manager.record_cache_hit()
trigger_prefetch(filepath, file_hash, audio, resolution, segment, info)
with open(output, 'rb') as f:
return f.read()
# Need to transcode
result = segment_manager.get_segment(
key,
lambda: transcode_segment(filepath, file_hash, audio, resolution, segment)
)
if result and os.path.exists(result):
trigger_prefetch(filepath, file_hash, audio, resolution, segment, info)
with open(result, 'rb') as f:
return f.read()
return None
def trigger_prefetch(filepath: str, file_hash: str, audio: int, resolution: str,
current: int, info: dict):
"""Prefetch upcoming segments."""
duration = float(info.get('format', {}).get('duration', 0))
total = int(duration / SEGMENT_DURATION) + 1
for offset in range(1, PREFETCH_SEGMENTS + 1):
next_seg = current + offset
if next_seg >= total:
break
output = get_segment_path(file_hash, audio, resolution, next_seg)
if os.path.exists(output):
continue
key = f"{file_hash}:{audio}:{resolution}:{next_seg}"
seg = next_seg
segment_manager.schedule_prefetch(
key,
lambda s=seg: transcode_segment(filepath, file_hash, audio, resolution, s)
)
def generate_master_playlist(info: dict, resolution_filter: str = None, audio_filter: int = None) -> str:
"""
Generate HLS master playlist.
Args:
info: ffprobe output
resolution_filter: Specific resolution (e.g., 'original', '720p') or None for ABR
audio_filter: Specific audio track index or None for all tracks
When audio_filter is specified, generates a simple playlist with just that
audio track muxed in - no AUDIO group needed since there's only one option.
"""
streams = info.get('streams', [])
video = next((s for s in streams if s.get('codec_type') == 'video'), None)
audio_streams = [s for s in streams if s.get('codec_type') == 'audio']
subtitle_streams = [s for s in streams if s.get('codec_type') == 'subtitle']
lines = ["#EXTM3U", "#EXT-X-VERSION:4", ""]
# Subtitle tracks
sub_counter = 0
for i, s in enumerate(subtitle_streams):
codec = s.get('codec_name', '')
if codec in UNSUPPORTED_SUBTITLE_CODECS:
continue
lang = s.get('tags', {}).get('language', 'und')
title = s.get('tags', {}).get('title', '')
name = f"{title}" if title else (lang.upper() if lang != 'und' else f"Subtitle {sub_counter+1}")
lines.append(f'#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="{name}",LANGUAGE="{lang}",DEFAULT=NO,AUTOSELECT=YES,URI="subtitle_{i}.m3u8"')
sub_counter += 1
if sub_counter > 0:
lines.append("")
# Video variants
if video:
src_w = video.get('width', 1920)
src_h = video.get('height', 1080)
subs_ref = ',SUBTITLES="subs"' if sub_counter > 0 else ''
# Build quality ladder
if resolution_filter:
res_info = RESOLUTIONS.get(resolution_filter, RESOLUTIONS['original'])
bw_map = {None: 5000000, 1080: 4000000, 720: 2500000, 480: 1200000, 360: 800000}
quality_order = [(resolution_filter, res_info[1], bw_map.get(res_info[1], 5000000))]
else:
quality_order = [
('original', None, 5000000),
('1080p', 1080, 4000000),
('720p', 720, 2500000),
('480p', 480, 1200000),
('360p', 360, 800000),
]
# Determine audio track to use
audio_index = audio_filter if audio_filter is not None else 0
# Simple playlist - just stream variants, no AUDIO group
# Audio is muxed into each stream variant
for res_key, target_h, bw in quality_order:
if target_h and target_h > src_h:
continue
if target_h and target_h == src_h:
continue
w, h, _ = RESOLUTIONS.get(res_key, RESOLUTIONS['original'])
width = w or src_w
height = h or src_h
res_label = f"{height}p (Original)" if res_key == 'original' else f"{height}p"
lines.append(f'#EXT-X-STREAM-INF:BANDWIDTH={bw},RESOLUTION={width}x{height}{subs_ref},NAME="{res_label}"')
lines.append(f"stream_a{audio_index}_{res_key}.m3u8")
return "\n".join(lines) + "\n"
def generate_stream_playlist(info: dict, audio: int, resolution: str) -> str:
"""Generate HLS stream playlist with muxed video+audio segments."""
duration = float(info.get('format', {}).get('duration', 0))
num_segments = int(duration / SEGMENT_DURATION) + 1
lines = [
"#EXTM3U",
"#EXT-X-VERSION:3",
f"#EXT-X-TARGETDURATION:{SEGMENT_DURATION}",
"#EXT-X-MEDIA-SEQUENCE:0",
"#EXT-X-PLAYLIST-TYPE:VOD",
""
]
for i in range(num_segments):
seg_dur = min(SEGMENT_DURATION, duration - (i * SEGMENT_DURATION))
if seg_dur > 0.1:
lines.append(f"#EXTINF:{seg_dur:.3f},")
lines.append(f"seg_a{audio}_{resolution}_{i:05d}.ts")
lines.append("#EXT-X-ENDLIST")
return "\n".join(lines)
def generate_subtitle_playlist(info: dict, sub_index: int) -> str:
"""Generate subtitle playlist."""
duration = float(info.get('format', {}).get('duration', 0))
return "\n".join([
"#EXTM3U",
"#EXT-X-VERSION:3",
f"#EXT-X-TARGETDURATION:{int(duration) + 1}",
"#EXT-X-MEDIA-SEQUENCE:0",
"#EXT-X-PLAYLIST-TYPE:VOD",
"",
f"#EXTINF:{duration:.3f},",
f"subtitle_{sub_index}.vtt",
"#EXT-X-ENDLIST"
])
def get_metrics() -> dict:
"""Get transcoding metrics."""
return {
**segment_manager.get_metrics(),
'adaptive_quality': adaptive_quality.get_stats(),
}
def reset_metrics():
"""Reset transcoding metrics."""
segment_manager.reset_metrics()
+737
View File
@@ -0,0 +1,737 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>meta-stremio</title>
<style>
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #12121a;
--bg-tertiary: #1a1a2e;
--text-primary: #e0e0e0;
--text-secondary: #a0a0a0;
--accent-primary: #4ecdc4;
--accent-secondary: #ff6b6b;
--accent-warning: #ffd93d;
--border-color: #2a2a3e;
--success: #4ecdc4;
--error: #ff6b6b;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
padding: 2rem;
}
.container { max-width: 1200px; margin: 0 auto; }
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
}
.header h1 {
font-size: 1.8rem;
font-weight: 600;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.header-subtitle {
color: var(--text-secondary);
font-size: 0.9rem;
margin-top: 0.25rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.card {
background: var(--bg-secondary);
border-radius: 12px;
padding: 1.5rem;
border: 1px solid var(--border-color);
transition: border-color 0.2s;
}
.card:hover {
border-color: rgba(78, 205, 196, 0.3);
}
.card h2 {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.card h2 .icon { font-size: 1.2rem; }
/* Setup Card */
.setup-url {
background: var(--bg-tertiary);
border-radius: 8px;
padding: 1rem;
font-family: monospace;
font-size: 0.85rem;
word-break: break-all;
margin-bottom: 1rem;
border: 1px solid var(--border-color);
color: var(--accent-primary);
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border-radius: 8px;
text-decoration: none;
font-weight: 600;
font-size: 0.9rem;
transition: all 0.2s;
border: none;
cursor: pointer;
}
.btn-primary {
background: var(--accent-primary);
color: var(--bg-primary);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(78, 205, 196, 0.3);
}
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn-secondary:hover {
background: var(--border-color);
}
.btn-group { display: flex; gap: 0.75rem; flex-wrap: wrap; }
/* Status Indicators */
.status {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-connected {
background: rgba(78, 205, 196, 0.15);
color: var(--success);
}
.status-connected .status-dot {
background: var(--success);
box-shadow: 0 0 8px var(--success);
}
.status-disconnected {
background: rgba(255, 107, 107, 0.15);
color: var(--error);
}
.status-disconnected .status-dot { background: var(--error); }
.status-loading {
background: rgba(255, 217, 61, 0.15);
color: var(--accent-warning);
}
.status-loading .status-dot {
background: var(--accent-warning);
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* Stats */
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
.stat {
text-align: center;
padding: 1rem;
background: var(--bg-tertiary);
border-radius: 8px;
}
.stat-value {
font-size: 2rem;
font-weight: 700;
color: var(--accent-primary);
}
.stat-label {
font-size: 0.75rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 0.25rem;
}
/* Metrics */
.metric-row {
display: flex;
justify-content: space-between;
padding: 0.75rem 0;
border-bottom: 1px solid var(--border-color);
}
.metric-row:last-child { border-bottom: none; }
.metric-label { color: var(--text-secondary); }
.metric-value { font-weight: 600; color: var(--text-primary); }
.metric-value.good { color: var(--success); }
.metric-value.warning { color: var(--accent-warning); }
.metric-value.bad { color: var(--error); }
/* Progress bar */
.progress-bar {
height: 8px;
background: var(--bg-tertiary);
border-radius: 4px;
overflow: hidden;
margin-top: 0.5rem;
}
.progress-fill {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
.progress-fill.good { background: linear-gradient(90deg, var(--success), #69e0d8); }
.progress-fill.warning { background: linear-gradient(90deg, var(--accent-warning), #ffe066); }
.progress-fill.bad { background: linear-gradient(90deg, var(--error), #ff8787); }
/* Steps */
.steps { list-style: none; }
.steps li {
padding: 0.75rem 0;
padding-left: 2rem;
position: relative;
border-left: 2px solid var(--border-color);
}
.steps li:before {
content: '';
position: absolute;
left: -6px;
top: 1rem;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--accent-primary);
}
.steps li:last-child { border-left-color: transparent; }
.steps code {
background: var(--bg-tertiary);
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.85rem;
}
/* Storage info */
.storage-info {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.storage-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.storage-type {
font-size: 1.1rem;
font-weight: 600;
color: var(--accent-primary);
}
/* Footer */
footer {
text-align: center;
color: var(--text-secondary);
font-size: 0.85rem;
margin-top: 2rem;
}
footer a {
color: var(--accent-primary);
text-decoration: none;
}
footer a:hover { text-decoration: underline; }
/* Services Navigation */
.services-nav {
background: var(--bg-secondary);
border-radius: 12px;
padding: 0.5rem;
margin-bottom: 1.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
border: 1px solid var(--border-color);
}
.services-nav-label {
color: var(--text-secondary);
font-size: 0.85rem;
padding: 0.5rem 0.75rem;
white-space: nowrap;
}
.services-nav-items {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.service-link {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1rem;
border-radius: 8px;
text-decoration: none;
font-size: 0.9rem;
font-weight: 500;
transition: all 0.2s;
color: var(--text-primary);
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
}
.service-link:hover {
background: var(--border-color);
border-color: rgba(78, 205, 196, 0.5);
}
.service-link.active {
background: linear-gradient(135deg, rgba(78, 205, 196, 0.2), rgba(68, 160, 141, 0.2));
border-color: rgba(78, 205, 196, 0.5);
}
.service-link .service-icon { font-size: 1.1rem; }
.service-link .service-status {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--success);
margin-left: 0.25rem;
}
/* Responsive */
@media (max-width: 600px) {
body { padding: 1rem; }
.header h1 { font-size: 1.5rem; }
.stats-grid { grid-template-columns: 1fr; }
.btn-group { flex-direction: column; }
.btn { justify-content: center; }
.services-nav { flex-direction: column; align-items: flex-start; }
}
</style>
</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>
<header class="header">
<div>
<h1>meta-stremio</h1>
<p class="header-subtitle">Stream your media library with on-the-fly HLS transcoding</p>
</div>
</header>
<div class="grid">
<!-- Setup Card -->
<div class="card">
<h2><span class="icon">🎬</span> Quick Setup</h2>
<p style="margin-bottom: 1rem; color: var(--text-secondary);">Install URL for Stremio:</p>
<div class="setup-url" id="manifestUrl"></div>
<div class="btn-group">
<a href="#" id="installLink" class="btn btn-primary">
📥 Install in Stremio
</a>
<button onclick="copyUrl()" class="btn btn-secondary">
📋 Copy URL
</button>
<a href="/configure" class="btn btn-secondary">
🌐 Configure Language
</a>
</div>
</div>
<!-- Storage Status -->
<div class="card">
<h2><span class="icon">💾</span> Storage Status</h2>
<div class="storage-info">
<div class="storage-row">
<span class="storage-type" id="storageType">Loading...</span>
<span class="status status-loading" id="storageStatus">
<span class="status-dot"></span>
<span id="storageStatusText">Checking...</span>
</span>
</div>
<div class="storage-row" id="redisUrlRow" style="display: none;">
<span class="metric-label">Leader</span>
<span class="metric-value" id="redisUrl"></span>
</div>
<div class="storage-row">
<span class="metric-label">Prefix</span>
<span class="metric-value" id="storagePrefix">meta-sort:</span>
</div>
<div style="margin-top: 0.5rem;">
<button onclick="refreshLibrary()" class="btn btn-secondary" style="width: 100%;">
🔄 Refresh Library
</button>
</div>
</div>
</div>
<!-- Library Stats -->
<div class="card">
<h2><span class="icon">📚</span> Library</h2>
<div class="stats-grid">
<div class="stat">
<div class="stat-value" id="totalVideos">-</div>
<div class="stat-label">Total</div>
</div>
<div class="stat">
<div class="stat-value" id="movieCount">-</div>
<div class="stat-label">Movies</div>
</div>
<div class="stat">
<div class="stat-value" id="seriesCount">-</div>
<div class="stat-label">Series</div>
</div>
</div>
</div>
<!-- Transcoding Metrics -->
<div class="card">
<h2><span class="icon">⚡</span> Transcoding</h2>
<div class="metric-row">
<span class="metric-label">Transcode Ratio</span>
<span class="metric-value" id="transcodeRatio">-</span>
</div>
<div class="progress-bar">
<div class="progress-fill good" id="ratioBar" style="width: 0%"></div>
</div>
<div class="metric-row">
<span class="metric-label">Active Streams</span>
<span class="metric-value" id="activeStreams">0</span>
</div>
<div class="metric-row">
<span class="metric-label">Avg Segment Time</span>
<span class="metric-value" id="avgSegmentTime">-</span>
</div>
<div class="metric-row">
<span class="metric-label">Cache Hits</span>
<span class="metric-value good" id="cacheHits">-</span>
</div>
</div>
<!-- Setup Instructions -->
<div class="card" style="grid-column: 1 / -1;">
<h2><span class="icon">📖</span> Setup Instructions</h2>
<ol class="steps">
<li>
<strong>Install Stremio</strong><br>
<span style="color: var(--text-secondary);">Download from</span> <a href="https://www.stremio.com" target="_blank">stremio.com</a>
</li>
<li>
<strong>Add this addon</strong><br>
<span style="color: var(--text-secondary);">Click "Install in Stremio" above, or paste the URL into Stremio's addon search</span>
</li>
<li>
<strong>Browse your library</strong><br>
<span style="color: var(--text-secondary);">Your videos indexed by meta-sort will appear in the addon's catalog</span>
</li>
<li>
<strong>Start streaming</strong><br>
<span style="color: var(--text-secondary);">Select a video and choose your preferred stream quality</span>
</li>
</ol>
</div>
</div>
<footer>
<p>Part of the <a href="https://github.com/user/metamesh">MetaMesh</a> ecosystem</p>
<p style="margin-top: 0.5rem;">Powered by meta-sort metadata | FFmpeg HLS transcoding</p>
</footer>
</div>
<script>
const baseUrl = window.location.origin;
const manifestUrl = `${baseUrl}/manifest.json`;
// Set URLs
document.getElementById('manifestUrl').textContent = manifestUrl;
document.getElementById('installLink').href = `stremio://${window.location.host}/manifest.json`;
function copyUrl() {
navigator.clipboard.writeText(manifestUrl).then(() => {
const btn = event.target.closest('.btn');
const original = btn.innerHTML;
btn.innerHTML = '✓ Copied!';
setTimeout(() => btn.innerHTML = original, 2000);
});
}
async function fetchStorageStatus() {
try {
const res = await fetch('/api/storage-status');
const data = await res.json();
const statusEl = document.getElementById('storageStatus');
const statusText = document.getElementById('storageStatusText');
const typeEl = document.getElementById('storageType');
// Display storage type
const typeNames = {
'leader': 'Leader Discovery',
'redis': 'Redis Direct'
};
typeEl.textContent = typeNames[data.type] || data.type;
if (data.connected) {
statusEl.className = 'status status-connected';
statusText.textContent = 'Connected';
} else {
statusEl.className = 'status status-disconnected';
statusText.textContent = 'Disconnected';
}
// Show leader info for leader mode
const redisUrlRow = document.getElementById('redisUrlRow');
if (data.leader && data.leader.api) {
redisUrlRow.style.display = 'flex';
document.getElementById('redisUrl').textContent = data.leader.host + ' (' + data.leader.api.replace('redis://', '') + ')';
} else if (data.type === 'redis' && data.url) {
redisUrlRow.style.display = 'flex';
document.getElementById('redisUrl').textContent = data.url.replace(/\/\/[^:]+:[^@]+@/, '//***:***@');
} else {
redisUrlRow.style.display = 'none';
}
if (data.prefix) {
document.getElementById('storagePrefix').textContent = data.prefix;
}
} catch (e) {
console.error('Failed to fetch storage status:', e);
}
}
async function fetchStats() {
try {
const res = await fetch('/api/stats');
const data = await res.json();
document.getElementById('totalVideos').textContent = data.total || 0;
document.getElementById('movieCount').textContent = data.movies || 0;
document.getElementById('seriesCount').textContent = data.series || 0;
} catch (e) {
console.error('Failed to fetch stats:', e);
}
}
async function fetchMetrics() {
try {
const res = await fetch('/transcode/metrics');
const data = await res.json();
// Transcode ratio
const ratio = data.transcode_ratio || 0;
const ratioPercent = Math.round(ratio * 100);
document.getElementById('transcodeRatio').textContent = `${ratioPercent}%`;
const ratioBar = document.getElementById('ratioBar');
ratioBar.style.width = `${ratioPercent}%`;
// Color based on target (60-80% is ideal)
if (ratio >= 0.6 && ratio <= 0.8) {
ratioBar.className = 'progress-fill good';
} else if (ratio >= 0.5 && ratio <= 0.9) {
ratioBar.className = 'progress-fill warning';
} else {
ratioBar.className = 'progress-fill bad';
}
// Active streams
document.getElementById('activeStreams').textContent = data.active_transcodes || 0;
// Avg segment time
const avgTime = data.avg_segment_time;
if (avgTime !== undefined && avgTime !== null) {
document.getElementById('avgSegmentTime').textContent = `${avgTime.toFixed(2)}s`;
}
// Cache hits
const total = (data.cache_hits || 0) + (data.cache_misses || 0);
if (total > 0) {
const hitRate = Math.round((data.cache_hits / total) * 100);
document.getElementById('cacheHits').textContent = `${hitRate}% (${data.cache_hits}/${total})`;
}
} catch (e) {
console.error('Failed to fetch metrics:', e);
}
}
async function refreshLibrary() {
try {
const btn = event.target.closest('.btn');
btn.disabled = true;
btn.innerHTML = '⏳ Refreshing...';
await fetch('/api/library/refresh', { method: 'POST' });
await fetchStats();
await fetchStorageStatus();
btn.innerHTML = '✓ Refreshed!';
setTimeout(() => {
btn.disabled = false;
btn.innerHTML = '🔄 Refresh Library';
}, 2000);
} catch (e) {
console.error('Failed to refresh library:', e);
const btn = event.target.closest('.btn');
btn.disabled = false;
btn.innerHTML = '❌ Failed';
setTimeout(() => {
btn.innerHTML = '🔄 Refresh Library';
}, 2000);
}
}
// Service icons mapping
const serviceIcons = {
'meta-sort': '📁',
'meta-fuse': '🗂️',
'meta-stremio': '🎬',
'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.url;
return `
<a href="${href}" class="service-link${activeClass}" onclick="event.preventDefault(); ${isActive ? '' : `window.location.href='${svc.url}'`}">
<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(' ');
}
// Initial fetch
fetchStorageStatus();
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>