Files
meta-stremio/src/transcoder.py
T
2026-01-18 00:07:58 +01:00

879 lines
34 KiB
Python

#!/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
import webdav_client
# Configuration
MEDIA_DIR = os.environ.get('MEDIA_DIR', '/files/watch')
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'))
# Use WebDAV URL if available (ffmpeg can read HTTP URLs)
ffmpeg_input = webdav_client.to_webdav_url(filepath) or filepath
use_webdav = webdav_client.WEBDAV_ENABLED and ffmpeg_input.startswith('http')
# 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, but requires local file)
if is_mkv and track_index is not None and not use_webdav:
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, works with WebDAV)
if not extracted:
result = subprocess.run(
['ffmpeg', '-y', '-hide_banner', '-loglevel', 'error',
'-probesize', '1M', '-analyzeduration', '1M',
'-i', ffmpeg_input,
'-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
mode = "WebDAV" if use_webdav else "ffmpeg copy"
print(f"[Subtitle {sub_index}] Extracted via {mode}")
# 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
# Use WebDAV URL if available (ffprobe can read HTTP URLs)
ffprobe_input = webdav_client.to_webdav_url(full_path) or full_path
try:
result = subprocess.run(
['ffprobe', '-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', ffprobe_input],
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
# Use WebDAV URL if available (ffmpeg can read HTTP URLs)
ffmpeg_input = webdav_client.to_webdav_url(full_path) or full_path
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', ffmpeg_input,
'-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()