Initial commit - Video Service for Coolify

This commit is contained in:
2025-12-17 10:07:44 +01:00
commit baa1675738
46 changed files with 2386 additions and 0 deletions

1
app/services/__init__.py Executable file
View File

@@ -0,0 +1 @@
# Services Module

347
app/services/converter.py Executable file
View File

@@ -0,0 +1,347 @@
"""
Video Converter Service
FFmpeg wrapper for HLS conversion with multiple qualities
"""
import json
import subprocess
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from app.config import Settings, get_settings
from app.models.schemas import VideoMetadata
@dataclass
class QualityPreset:
"""Video quality preset for HLS."""
name: str
height: int
bitrate: str # e.g., "1000k"
audio_bitrate: str # e.g., "128k"
QUALITY_PRESETS: dict[str, QualityPreset] = {
"360p": QualityPreset(name="360p", height=360, bitrate="800k", audio_bitrate="96k"),
"720p": QualityPreset(
name="720p", height=720, bitrate="2500k", audio_bitrate="128k"
),
"1080p": QualityPreset(
name="1080p", height=1080, bitrate="5000k", audio_bitrate="192k"
),
}
class ConverterService:
"""FFmpeg video converter for HLS streaming."""
def __init__(self, settings: Settings | None = None):
self.settings = settings or get_settings()
def is_ffmpeg_available(self) -> bool:
"""Check if FFmpeg is installed and accessible."""
try:
result = subprocess.run(
["ffmpeg", "-version"],
check=False,
capture_output=True,
text=True,
timeout=10,
)
return result.returncode == 0
except (subprocess.SubprocessError, FileNotFoundError):
return False
def get_ffmpeg_version(self) -> str | None:
"""Get FFmpeg version string."""
try:
result = subprocess.run(
["ffmpeg", "-version"],
check=False,
capture_output=True,
text=True,
timeout=10,
)
if result.returncode == 0:
# First line contains version
return result.stdout.split("\n")[0]
return None
except (subprocess.SubprocessError, FileNotFoundError):
return None
def extract_metadata(self, input_path: Path) -> VideoMetadata:
"""Extract video metadata using ffprobe."""
cmd = [
"ffprobe",
"-v",
"quiet",
"-print_format",
"json",
"-show_format",
"-show_streams",
str(input_path),
]
result = subprocess.run(
cmd, check=False, capture_output=True, text=True, timeout=60
)
if result.returncode != 0:
raise RuntimeError(f"ffprobe failed: {result.stderr}")
data = json.loads(result.stdout)
# Find video stream
video_stream = None
for stream in data.get("streams", []):
if stream.get("codec_type") == "video":
video_stream = stream
break
if not video_stream:
raise RuntimeError("No video stream found")
format_info = data.get("format", {})
# Parse frame rate (e.g., "30/1" or "29.97")
fps_str = video_stream.get("r_frame_rate", "0/1")
if "/" in fps_str:
num, den = fps_str.split("/")
fps = float(num) / float(den) if float(den) > 0 else 0
else:
fps = float(fps_str)
return VideoMetadata(
duration_seconds=float(format_info.get("duration", 0)),
width=int(video_stream.get("width", 0)),
height=int(video_stream.get("height", 0)),
fps=round(fps, 2),
codec=video_stream.get("codec_name", ""),
bitrate=int(format_info.get("bit_rate", 0)),
file_size_bytes=int(format_info.get("size", 0)),
)
def has_audio_stream(self, input_path: Path) -> bool:
"""Check if video file has an audio stream."""
cmd = [
"ffprobe",
"-v",
"quiet",
"-print_format",
"json",
"-show_streams",
"-select_streams",
"a",
str(input_path),
]
result = subprocess.run(
cmd, check=False, capture_output=True, text=True, timeout=60
)
if result.returncode != 0:
return False
data = json.loads(result.stdout)
return len(data.get("streams", [])) > 0
def generate_thumbnail(
self,
input_path: Path,
output_path: Path,
time_offset: float = 5.0,
) -> bool:
"""Generate thumbnail at specified time offset."""
cmd = [
"ffmpeg",
"-y", # Overwrite
"-ss",
str(time_offset),
"-i",
str(input_path),
"-vframes",
"1",
"-vf",
"scale=640:-1", # 640px width, maintain aspect
"-q:v",
"2", # High quality JPEG
str(output_path),
]
result = subprocess.run(
cmd, check=False, capture_output=True, text=True, timeout=60
)
return result.returncode == 0
def convert_to_hls(
self,
input_path: Path,
output_dir: Path,
qualities: list[str] | None = None,
progress_callback: Callable[[int], None] | None = None,
) -> dict:
"""
Convert video to HLS with multiple quality levels.
Returns dict with:
- master_playlist: path to master.m3u8
- qualities: list of generated quality levels
- success: bool
- error: optional error message
"""
if qualities is None:
qualities = self.settings.video_qualities_list
output_dir.mkdir(parents=True, exist_ok=True)
hls_dir = output_dir / "hls"
hls_dir.mkdir(exist_ok=True)
# Get source video metadata to determine which qualities to generate
try:
metadata = self.extract_metadata(input_path)
source_height = metadata.height
except Exception:
source_height = 1080 # Assume HD if can't detect
# Filter qualities based on source resolution
valid_qualities = []
for q in qualities:
preset = QUALITY_PRESETS.get(q)
if preset and preset.height <= source_height:
valid_qualities.append(q)
if not valid_qualities:
# At minimum, include lowest quality
valid_qualities = ["360p"]
# Check if video has audio stream
has_audio = self.has_audio_stream(input_path)
# Build FFmpeg command for multi-quality HLS
cmd = [
"ffmpeg",
"-y",
"-i",
str(input_path),
"-threads",
str(self.settings.ffmpeg_threads),
]
# Add output for each quality
stream_maps = []
for i, quality_name in enumerate(valid_qualities):
preset = QUALITY_PRESETS[quality_name]
quality_dir = hls_dir / quality_name
quality_dir.mkdir(exist_ok=True)
# Video mapping and encoding
cmd.extend(
[
"-map",
"0:v:0",
f"-c:v:{i}",
"libx264",
f"-b:v:{i}",
preset.bitrate,
f"-vf:v:{i}",
f"scale=-2:{preset.height}",
]
)
# Audio mapping and encoding (only if audio stream exists)
if has_audio:
cmd.extend(
[
"-map",
"0:a:0",
f"-c:a:{i}",
"aac",
f"-b:a:{i}",
preset.audio_bitrate,
]
)
stream_maps.append(f"v:{i},a:{i}")
else:
stream_maps.append(f"v:{i}")
# HLS settings
segment_duration = self.settings.hls_segment_duration
# Use var_stream_map for multiple quality outputs
var_stream_map = " ".join(stream_maps)
cmd.extend(
[
"-f",
"hls",
"-hls_time",
str(segment_duration),
"-hls_playlist_type",
"vod",
"-hls_flags",
"independent_segments",
"-hls_segment_filename",
str(hls_dir / "%v" / "segment_%03d.ts"),
"-master_pl_name",
"master.m3u8",
"-var_stream_map",
var_stream_map,
str(hls_dir / "%v" / "playlist.m3u8"),
]
)
# Execute conversion
try:
result = subprocess.run(
cmd,
check=False,
capture_output=True,
text=True,
timeout=3600, # 1 hour timeout
)
if result.returncode != 0:
return {
"success": False,
"error": result.stderr[-500:] if result.stderr else "Unknown error",
"qualities": [],
}
# Rename quality directories to proper names
for i, quality_name in enumerate(valid_qualities):
src_dir = hls_dir / str(i)
dst_dir = hls_dir / quality_name
if src_dir.exists() and src_dir != dst_dir:
if dst_dir.exists():
import shutil
shutil.rmtree(dst_dir)
src_dir.rename(dst_dir)
# Fix master playlist to use correct quality names
master_path = hls_dir / "master.m3u8"
if master_path.exists():
content = master_path.read_text()
for i, quality_name in enumerate(valid_qualities):
content = content.replace(
f"{i}/playlist.m3u8", f"{quality_name}/playlist.m3u8"
)
master_path.write_text(content)
return {
"success": True,
"master_playlist": str(master_path),
"qualities": valid_qualities,
}
except subprocess.TimeoutExpired:
return {
"success": False,
"error": "Conversion timed out after 1 hour",
"qualities": [],
}
except Exception as e:
return {
"success": False,
"error": str(e),
"qualities": [],
}

168
app/services/storage.py Executable file
View File

@@ -0,0 +1,168 @@
"""
Storage Service
Handles file storage for uploaded and converted videos
"""
import json
import shutil
import uuid
from datetime import UTC, datetime
from pathlib import Path
from app.config import Settings, get_settings
from app.models.schemas import VideoMetadata, VideoStatus
class StorageService:
"""Manage video file storage."""
def __init__(self, settings: Settings | None = None):
self.settings = settings or get_settings()
self.base_path = Path(self.settings.storage_path)
self.uploads_path = self.base_path / "uploads"
self.converted_path = self.base_path / "converted"
# Ensure directories exist
self.uploads_path.mkdir(parents=True, exist_ok=True)
self.converted_path.mkdir(parents=True, exist_ok=True)
def generate_video_id(self) -> str:
"""Generate unique video ID."""
return str(uuid.uuid4())[:12]
def get_upload_path(self, video_id: str, filename: str) -> Path:
"""Get path for uploaded video file."""
video_dir = self.uploads_path / video_id
video_dir.mkdir(parents=True, exist_ok=True)
return video_dir / filename
def get_converted_path(self, video_id: str) -> Path:
"""Get path for converted video directory."""
video_dir = self.converted_path / video_id
video_dir.mkdir(parents=True, exist_ok=True)
return video_dir
def get_hls_path(self, video_id: str) -> Path:
"""Get path for HLS output."""
return self.get_converted_path(video_id) / "hls"
def get_thumbnail_path(self, video_id: str) -> Path:
"""Get path for thumbnail."""
return self.get_converted_path(video_id) / "thumbnail.jpg"
def get_metadata_path(self, video_id: str) -> Path:
"""Get path for metadata JSON."""
return self.get_converted_path(video_id) / "metadata.json"
def save_metadata(
self,
video_id: str,
kurs_id: int,
title: str,
status: VideoStatus,
metadata: VideoMetadata | None = None,
error_message: str | None = None,
) -> dict:
"""Save video metadata to JSON file."""
meta_path = self.get_metadata_path(video_id)
data = {
"video_id": video_id,
"kurs_id": kurs_id,
"title": title,
"status": status.value,
"created_at": datetime.now(UTC).isoformat(),
"updated_at": datetime.now(UTC).isoformat(),
"error_message": error_message,
}
if metadata:
data["metadata"] = metadata.model_dump()
# Load existing data to preserve created_at
if meta_path.exists():
with meta_path.open() as f:
existing = json.load(f)
data["created_at"] = existing.get("created_at", data["created_at"])
with meta_path.open("w") as f:
json.dump(data, f, indent=2, default=str)
return data
def load_metadata(self, video_id: str) -> dict | None:
"""Load video metadata from JSON file."""
meta_path = self.get_metadata_path(video_id)
if not meta_path.exists():
return None
with meta_path.open() as f:
return json.load(f)
def update_status(
self,
video_id: str,
status: VideoStatus,
progress: int = 0,
error_message: str | None = None,
) -> dict | None:
"""Update video status in metadata."""
meta = self.load_metadata(video_id)
if not meta:
return None
meta["status"] = status.value
meta["progress"] = progress
meta["updated_at"] = datetime.now(UTC).isoformat()
if error_message:
meta["error_message"] = error_message
meta_path = self.get_metadata_path(video_id)
with meta_path.open("w") as f:
json.dump(meta, f, indent=2, default=str)
return meta
def delete_video(self, video_id: str) -> bool:
"""Delete all files for a video."""
upload_dir = self.uploads_path / video_id
converted_dir = self.converted_path / video_id
deleted = False
if upload_dir.exists():
shutil.rmtree(upload_dir)
deleted = True
if converted_dir.exists():
shutil.rmtree(converted_dir)
deleted = True
return deleted
def get_video_ids(self) -> list[str]:
"""Get all video IDs."""
ids = set()
if self.uploads_path.exists():
ids.update(d.name for d in self.uploads_path.iterdir() if d.is_dir())
if self.converted_path.exists():
ids.update(d.name for d in self.converted_path.iterdir() if d.is_dir())
return sorted(ids)
def is_writable(self) -> bool:
"""Check if storage is writable."""
test_file = self.base_path / ".write_test"
try:
test_file.write_text("test")
test_file.unlink()
return True
except (OSError, PermissionError):
return False
def get_disk_usage(self) -> dict:
"""Get disk usage statistics."""
total, used, free = shutil.disk_usage(self.base_path)
return {
"total_gb": round(total / (1024**3), 2),
"used_gb": round(used / (1024**3), 2),
"free_gb": round(free / (1024**3), 2),
"percent_used": round((used / total) * 100, 1),
}