initial commit
This commit is contained in:
@@ -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
@@ -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
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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:
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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}"
|
||||||
@@ -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
@@ -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
@@ -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()
|
||||||
@@ -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',
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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>
|
||||||
Reference in New Issue
Block a user