Compare commits
10 Commits
fbbfa6fdce
...
b09ba3fa9e
| Author | SHA1 | Date | |
|---|---|---|---|
| b09ba3fa9e | |||
| 5b7a55a05d | |||
| 2200da491f | |||
| 3d6469c60c | |||
| 6a4ac8b8ed | |||
| 1f6906c946 | |||
| dfba88a601 | |||
| e94c088df0 | |||
| 9569103edd | |||
| 079afeee7c |
@@ -0,0 +1,8 @@
|
||||
FROM python:3.12-slim
|
||||
RUN apt-get update && apt-get install -y ffmpeg && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY core/ core/
|
||||
COPY server/ server/
|
||||
RUN pip install --no-cache-dir fastapi uvicorn[standard]
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
+3
-3
@@ -121,14 +121,14 @@ class ProcessedDB:
|
||||
"""Return config dict for an output_path, or None."""
|
||||
if not self._enabled:
|
||||
return None
|
||||
self._con.row_factory = sqlite3.Row
|
||||
row = self._con.execute(
|
||||
cur = self._con.cursor()
|
||||
cur.row_factory = sqlite3.Row
|
||||
row = cur.execute(
|
||||
"SELECT label, category, short_side, portrait_ratio, crop_center, format,"
|
||||
" clip_count, spread"
|
||||
" FROM processed WHERE output_path = ?",
|
||||
(output_path,),
|
||||
).fetchone()
|
||||
self._con.row_factory = None
|
||||
return dict(row) if row else None
|
||||
|
||||
def delete_by_output_path(self, output_path: str) -> None:
|
||||
|
||||
@@ -113,6 +113,7 @@ class ExportRunner:
|
||||
except Exception as e:
|
||||
if "cancelled" not in str(e) and self._on_error:
|
||||
self._on_error(str(e))
|
||||
return
|
||||
except Exception as e:
|
||||
if self._on_error:
|
||||
self._on_error(str(e))
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
services:
|
||||
8cut:
|
||||
build: .
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- /path/to/videos:/videos:ro
|
||||
- /path/to/exports:/exports
|
||||
- 8cut-data:/data
|
||||
environment:
|
||||
MEDIA_DIRS: /videos
|
||||
EXPORT_DIR: /exports
|
||||
DB_PATH: /data/8cut.db
|
||||
CACHE_DIR: /data/cache
|
||||
|
||||
volumes:
|
||||
8cut-data:
|
||||
@@ -12,7 +12,7 @@ from pathlib import Path
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||
QLabel, QPushButton, QLineEdit, QFileDialog, QFrame,
|
||||
QLabel, QPushButton, QLineEdit, QFileDialog,
|
||||
QListWidget, QListWidgetItem, QAbstractItemView, QSplitter, QToolTip,
|
||||
QComboBox, QCheckBox, QSpinBox, QDoubleSpinBox,
|
||||
QMessageBox, QInputDialog,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
from fastapi import FastAPI, WebSocket
|
||||
|
||||
from core.db import ProcessedDB
|
||||
from .config import DB_PATH
|
||||
from .routes import files, stream, markers, export, hidden
|
||||
from . import ws
|
||||
|
||||
app = FastAPI(title="8-cut Server")
|
||||
|
||||
db = ProcessedDB(DB_PATH)
|
||||
|
||||
app.include_router(files.router, prefix="/api")
|
||||
app.include_router(stream.router, prefix="/api")
|
||||
app.include_router(markers.router, prefix="/api")
|
||||
app.include_router(export.router, prefix="/api")
|
||||
app.include_router(hidden.router, prefix="/api")
|
||||
|
||||
|
||||
@app.websocket("/ws/export")
|
||||
async def export_ws(websocket: WebSocket):
|
||||
await ws.connect(websocket)
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
import hashlib
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
from enum import Enum
|
||||
|
||||
from core.paths import _bin, _log
|
||||
from .config import CACHE_DIR, QUALITY_PRESETS
|
||||
|
||||
|
||||
class CacheStatus(str, Enum):
|
||||
READY = "ready"
|
||||
TRANSCODING = "transcoding"
|
||||
MISSING = "missing"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
_jobs_lock = threading.Lock()
|
||||
_active_jobs: dict[str, threading.Thread] = {}
|
||||
|
||||
|
||||
def _cache_key(source_path: str) -> str:
|
||||
"""Stable hash from absolute source path."""
|
||||
return hashlib.sha256(source_path.encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def cache_path(source_path: str, quality: str) -> str:
|
||||
key = _cache_key(source_path)
|
||||
return os.path.join(CACHE_DIR, quality, f"{key}.mp4")
|
||||
|
||||
|
||||
def audio_cache_path(source_path: str) -> str:
|
||||
key = _cache_key(source_path)
|
||||
return os.path.join(CACHE_DIR, "audio", f"{key}.wav")
|
||||
|
||||
|
||||
def get_status(source_path: str, quality: str) -> CacheStatus:
|
||||
cp = cache_path(source_path, quality)
|
||||
if os.path.isfile(cp):
|
||||
return CacheStatus.READY
|
||||
job_key = f"{source_path}:{quality}"
|
||||
with _jobs_lock:
|
||||
if job_key in _active_jobs and _active_jobs[job_key].is_alive():
|
||||
return CacheStatus.TRANSCODING
|
||||
return CacheStatus.MISSING
|
||||
|
||||
|
||||
def get_audio_status(source_path: str) -> CacheStatus:
|
||||
ap = audio_cache_path(source_path)
|
||||
if os.path.isfile(ap):
|
||||
return CacheStatus.READY
|
||||
job_key = f"{source_path}:audio"
|
||||
with _jobs_lock:
|
||||
if job_key in _active_jobs and _active_jobs[job_key].is_alive():
|
||||
return CacheStatus.TRANSCODING
|
||||
return CacheStatus.MISSING
|
||||
|
||||
|
||||
def get_all_statuses(source_path: str) -> dict:
|
||||
result = {}
|
||||
for q in QUALITY_PRESETS:
|
||||
result[q] = get_status(source_path, q)
|
||||
result["audio"] = get_audio_status(source_path)
|
||||
return result
|
||||
|
||||
|
||||
def _transcode_worker(source_path: str, quality: str) -> None:
|
||||
preset = QUALITY_PRESETS[quality]
|
||||
out = cache_path(source_path, quality)
|
||||
os.makedirs(os.path.dirname(out), exist_ok=True)
|
||||
tmp = out + ".tmp.mp4"
|
||||
|
||||
cmd = [_bin("ffmpeg"), "-y", "-i", source_path, "-an"]
|
||||
|
||||
if preset["height"] > 0:
|
||||
cmd += [
|
||||
"-vf", f"scale=-2:{preset['height']}:flags=lanczos",
|
||||
]
|
||||
|
||||
cmd += [
|
||||
"-c:v", "libx264",
|
||||
"-preset", "fast",
|
||||
"-b:v", preset["bitrate"],
|
||||
"-movflags", "+faststart",
|
||||
tmp,
|
||||
]
|
||||
|
||||
_log(f"Transcode start: {source_path} @ {quality}")
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=3600)
|
||||
if result.returncode == 0:
|
||||
os.rename(tmp, out)
|
||||
_log(f"Transcode done: {out}")
|
||||
else:
|
||||
_log(f"Transcode failed: {result.stderr[-300:]}")
|
||||
if os.path.exists(tmp):
|
||||
os.unlink(tmp)
|
||||
except Exception as e:
|
||||
_log(f"Transcode error: {e}")
|
||||
if os.path.exists(tmp):
|
||||
os.unlink(tmp)
|
||||
|
||||
|
||||
def _audio_extract_worker(source_path: str) -> None:
|
||||
out = audio_cache_path(source_path)
|
||||
os.makedirs(os.path.dirname(out), exist_ok=True)
|
||||
tmp = out + ".tmp.wav"
|
||||
|
||||
cmd = [
|
||||
_bin("ffmpeg"), "-y",
|
||||
"-i", source_path,
|
||||
"-vn",
|
||||
"-c:a", "pcm_s16le",
|
||||
tmp,
|
||||
]
|
||||
|
||||
_log(f"Audio extract start: {source_path}")
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
|
||||
if result.returncode == 0:
|
||||
os.rename(tmp, out)
|
||||
_log(f"Audio extract done: {out}")
|
||||
else:
|
||||
_log(f"Audio extract failed: {result.stderr[-300:]}")
|
||||
if os.path.exists(tmp):
|
||||
os.unlink(tmp)
|
||||
except Exception as e:
|
||||
_log(f"Audio extract error: {e}")
|
||||
if os.path.exists(tmp):
|
||||
os.unlink(tmp)
|
||||
|
||||
|
||||
def _prune_dead_jobs() -> None:
|
||||
"""Remove finished threads from _active_jobs. Must be called under _jobs_lock."""
|
||||
dead = [k for k, t in _active_jobs.items() if not t.is_alive()]
|
||||
for k in dead:
|
||||
del _active_jobs[k]
|
||||
|
||||
|
||||
def ensure_transcode(source_path: str, quality: str) -> CacheStatus:
|
||||
"""Start transcode if not cached. Returns current status."""
|
||||
status = get_status(source_path, quality)
|
||||
if status != CacheStatus.MISSING:
|
||||
return status
|
||||
|
||||
job_key = f"{source_path}:{quality}"
|
||||
with _jobs_lock:
|
||||
_prune_dead_jobs()
|
||||
if job_key in _active_jobs and _active_jobs[job_key].is_alive():
|
||||
return CacheStatus.TRANSCODING
|
||||
t = threading.Thread(target=_transcode_worker, args=(source_path, quality), daemon=True)
|
||||
_active_jobs[job_key] = t
|
||||
t.start()
|
||||
return CacheStatus.TRANSCODING
|
||||
|
||||
|
||||
def ensure_audio(source_path: str) -> CacheStatus:
|
||||
"""Start audio extraction if not cached. Returns current status."""
|
||||
status = get_audio_status(source_path)
|
||||
if status != CacheStatus.MISSING:
|
||||
return status
|
||||
|
||||
job_key = f"{source_path}:audio"
|
||||
with _jobs_lock:
|
||||
_prune_dead_jobs()
|
||||
if job_key in _active_jobs and _active_jobs[job_key].is_alive():
|
||||
return CacheStatus.TRANSCODING
|
||||
t = threading.Thread(target=_audio_extract_worker, args=(source_path,), daemon=True)
|
||||
_active_jobs[job_key] = t
|
||||
t.start()
|
||||
return CacheStatus.TRANSCODING
|
||||
@@ -0,0 +1,21 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MEDIA_DIRS: list[str] = [
|
||||
d.strip() for d in os.environ.get("MEDIA_DIRS", str(Path.home())).split(",") if d.strip()
|
||||
]
|
||||
EXPORT_DIR: str = os.environ.get("EXPORT_DIR", str(Path.home() / "8cut-exports"))
|
||||
DB_PATH: str = os.environ.get("DB_PATH", str(Path.home() / ".8cut.db"))
|
||||
CACHE_DIR: str = os.environ.get("CACHE_DIR", str(Path.home() / ".8cut-cache"))
|
||||
HOST: str = os.environ.get("HOST", "0.0.0.0")
|
||||
PORT: int = int(os.environ.get("PORT", "8000"))
|
||||
|
||||
VIDEO_EXTENSIONS = {".mp4", ".mkv", ".avi", ".mov", ".webm", ".ts", ".flv", ".wmv"}
|
||||
|
||||
QUALITY_PRESETS = {
|
||||
"potato": {"height": 480, "bitrate": "500k"},
|
||||
"low": {"height": 720, "bitrate": "2M"},
|
||||
"medium": {"height": 1080, "bitrate": "5M"},
|
||||
"high": {"height": 0, "bitrate": "10M"}, # 0 = original resolution
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.export import ExportRunner
|
||||
from core.paths import build_export_path, build_sequence_dir
|
||||
from core.ffmpeg import _RATIOS, apply_keyframes_to_jobs
|
||||
from .. import ws as ws_module
|
||||
from ..config import EXPORT_DIR, MEDIA_DIRS
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_jobs: dict[str, dict] = {}
|
||||
|
||||
_VALID_ENCODERS = {"libx264", "h264_nvenc", "h264_vaapi", "h264_qsv", "h264_amf", "h264_videotoolbox"}
|
||||
|
||||
|
||||
class CropKeyframe(BaseModel):
|
||||
time: float
|
||||
center: float
|
||||
ratio: str | None = None
|
||||
rand_portrait: bool = False
|
||||
rand_square: bool = False
|
||||
|
||||
|
||||
class ExportRequest(BaseModel):
|
||||
input_path: str
|
||||
cursor: float
|
||||
name: str
|
||||
clips: int = 3
|
||||
spread: float = 3.0
|
||||
short_side: int | None = None
|
||||
portrait_ratio: str | None = None
|
||||
crop_center: float = 0.5
|
||||
format: str = "MP4"
|
||||
label: str = ""
|
||||
category: str = ""
|
||||
profile: str = "default"
|
||||
folder_suffix: str = ""
|
||||
crop_keyframes: list[CropKeyframe] | None = None
|
||||
rand_portrait: bool = False
|
||||
rand_square: bool = False
|
||||
encoder: str = "libx264"
|
||||
|
||||
|
||||
def _next_counter(folder: str, basename: str) -> int:
|
||||
"""Scan folder for existing {basename}_NNN dirs and return max + 1."""
|
||||
pattern = re.compile(rf'^{re.escape(basename)}_(\d{{3}})$')
|
||||
highest = 0
|
||||
if os.path.isdir(folder):
|
||||
for entry in os.listdir(folder):
|
||||
m = pattern.match(entry)
|
||||
if m:
|
||||
highest = max(highest, int(m.group(1)))
|
||||
return highest + 1
|
||||
|
||||
|
||||
def _validate_input_path(path: str) -> str:
|
||||
"""Verify input_path falls under a configured MEDIA_DIR."""
|
||||
real = os.path.realpath(path)
|
||||
for root in MEDIA_DIRS:
|
||||
root_real = os.path.realpath(root)
|
||||
if real == root_real or real.startswith(root_real + os.sep):
|
||||
return real
|
||||
raise HTTPException(status_code=403, detail="input_path outside media directories")
|
||||
|
||||
|
||||
@router.post("/export")
|
||||
def start_export(req: ExportRequest):
|
||||
from ..app import db
|
||||
|
||||
# Validate inputs
|
||||
input_path = _validate_input_path(req.input_path)
|
||||
|
||||
if req.encoder not in _VALID_ENCODERS:
|
||||
raise HTTPException(status_code=400, detail=f"invalid encoder: {req.encoder}")
|
||||
|
||||
if req.portrait_ratio is not None and req.portrait_ratio not in _RATIOS:
|
||||
raise HTTPException(status_code=400, detail=f"invalid portrait_ratio: {req.portrait_ratio}")
|
||||
|
||||
if req.folder_suffix and ("/" in req.folder_suffix or "\\" in req.folder_suffix or ".." in req.folder_suffix):
|
||||
raise HTTPException(status_code=400, detail="folder_suffix must not contain path separators")
|
||||
|
||||
if "/" in req.name or "\\" in req.name or ".." in req.name:
|
||||
raise HTTPException(status_code=400, detail="name must not contain path separators")
|
||||
|
||||
job_id = str(uuid.uuid4())[:8]
|
||||
folder = EXPORT_DIR
|
||||
if req.folder_suffix:
|
||||
folder = folder.rstrip(os.sep) + "_" + req.folder_suffix
|
||||
|
||||
image_sequence = req.format in ("WebP", "WebP sequence")
|
||||
counter = _next_counter(folder, req.name)
|
||||
|
||||
# Build job list: (start, output_path, portrait_ratio, crop_center)
|
||||
jobs = []
|
||||
for i in range(req.clips):
|
||||
start = req.cursor + i * req.spread
|
||||
if image_sequence:
|
||||
out = build_sequence_dir(folder, req.name, counter, sub=i if req.clips > 1 else None)
|
||||
else:
|
||||
out = build_export_path(folder, req.name, counter, sub=i if req.clips > 1 else None)
|
||||
os.makedirs(os.path.dirname(out), exist_ok=True)
|
||||
jobs.append((start, out, req.portrait_ratio, req.crop_center))
|
||||
|
||||
# Apply keyframes if provided — returns 6-tuples, strip back to 4
|
||||
if req.crop_keyframes:
|
||||
kf_tuples = [
|
||||
(kf.time, kf.center, kf.ratio, kf.rand_portrait, kf.rand_square)
|
||||
for kf in req.crop_keyframes
|
||||
]
|
||||
widened = apply_keyframes_to_jobs(
|
||||
jobs, kf_tuples,
|
||||
req.crop_center, req.portrait_ratio,
|
||||
req.rand_portrait, req.rand_square,
|
||||
)
|
||||
jobs = [(s, o, r, c) for s, o, r, c, _rp, _rs in widened]
|
||||
|
||||
completed = []
|
||||
|
||||
def on_clip_done(path: str):
|
||||
completed.append(path)
|
||||
# Record in DB so markers show up
|
||||
db.add(
|
||||
filename=os.path.basename(input_path),
|
||||
start_time=req.cursor,
|
||||
output_path=path,
|
||||
label=req.label,
|
||||
category=req.category,
|
||||
short_side=req.short_side,
|
||||
portrait_ratio=req.portrait_ratio or "",
|
||||
crop_center=req.crop_center,
|
||||
fmt=req.format,
|
||||
clip_count=req.clips,
|
||||
spread=req.spread,
|
||||
profile=req.profile,
|
||||
)
|
||||
ws_module.broadcast({"type": "clip_done", "job_id": job_id, "path": path})
|
||||
|
||||
def on_all_done():
|
||||
_jobs[job_id]["status"] = "done"
|
||||
_jobs[job_id].pop("runner", None)
|
||||
ws_module.broadcast({"type": "all_done", "job_id": job_id})
|
||||
|
||||
def on_error(msg: str):
|
||||
_jobs[job_id]["status"] = "error"
|
||||
_jobs[job_id]["error"] = msg
|
||||
_jobs[job_id].pop("runner", None)
|
||||
ws_module.broadcast({"type": "error", "job_id": job_id, "msg": msg})
|
||||
|
||||
runner = ExportRunner(
|
||||
input_path=input_path,
|
||||
jobs=jobs,
|
||||
short_side=req.short_side,
|
||||
image_sequence=image_sequence,
|
||||
encoder=req.encoder,
|
||||
on_clip_done=on_clip_done,
|
||||
on_all_done=on_all_done,
|
||||
on_error=on_error,
|
||||
)
|
||||
|
||||
_jobs[job_id] = {
|
||||
"status": "running",
|
||||
"total": len(jobs),
|
||||
"completed": completed,
|
||||
"runner": runner,
|
||||
}
|
||||
runner.start()
|
||||
|
||||
return {"job_id": job_id}
|
||||
|
||||
|
||||
@router.get("/export/{job_id}")
|
||||
def get_export_status(job_id: str):
|
||||
job = _jobs.get(job_id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="job not found")
|
||||
return {
|
||||
"status": job["status"],
|
||||
"total": job["total"],
|
||||
"completed": len(job["completed"]),
|
||||
"outputs": list(job["completed"]),
|
||||
"error": job.get("error"),
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/export")
|
||||
def delete_export(output_path: str = Query(...)):
|
||||
from ..app import db
|
||||
# Validate path is under EXPORT_DIR
|
||||
real = os.path.realpath(output_path)
|
||||
if not real.startswith(os.path.realpath(EXPORT_DIR) + os.sep):
|
||||
raise HTTPException(status_code=403, detail="path outside export directory")
|
||||
db.delete_by_output_path(real)
|
||||
if os.path.isfile(real):
|
||||
os.unlink(real)
|
||||
elif os.path.isdir(real):
|
||||
shutil.rmtree(real)
|
||||
return {"deleted": real}
|
||||
@@ -0,0 +1,56 @@
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from ..config import MEDIA_DIRS, VIDEO_EXTENSIONS
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _scan_videos(root: str) -> list[dict]:
|
||||
results = []
|
||||
for dirpath, _, filenames in os.walk(root):
|
||||
for f in sorted(filenames):
|
||||
if os.path.splitext(f)[1].lower() in VIDEO_EXTENSIONS:
|
||||
full = os.path.join(dirpath, f)
|
||||
rel = os.path.relpath(full, root)
|
||||
results.append({
|
||||
"name": f,
|
||||
"path": rel,
|
||||
"root": root,
|
||||
"size": os.path.getsize(full),
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
@router.get("/files")
|
||||
def list_files(root: str | None = Query(None)):
|
||||
dirs = [root] if root and root in MEDIA_DIRS else MEDIA_DIRS
|
||||
files = []
|
||||
for d in dirs:
|
||||
files.extend(_scan_videos(d))
|
||||
return files
|
||||
|
||||
|
||||
@router.get("/roots")
|
||||
def list_roots():
|
||||
return MEDIA_DIRS
|
||||
|
||||
|
||||
def _safe_resolve(path: str, root: str) -> str:
|
||||
"""Join path to root and verify it stays within the root directory."""
|
||||
if root not in MEDIA_DIRS:
|
||||
raise HTTPException(status_code=400, detail="invalid root")
|
||||
full = os.path.realpath(os.path.join(root, path))
|
||||
if not full.startswith(os.path.realpath(root) + os.sep):
|
||||
raise HTTPException(status_code=403, detail="path outside media root")
|
||||
return full
|
||||
|
||||
|
||||
@router.get("/video/{path:path}")
|
||||
def serve_video(path: str, root: str = Query(...)):
|
||||
full = _safe_resolve(path, root)
|
||||
if not os.path.isfile(full):
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
return FileResponse(full, media_type="video/mp4")
|
||||
@@ -0,0 +1,25 @@
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _db():
|
||||
from ..app import db
|
||||
return db
|
||||
|
||||
|
||||
@router.post("/hidden/{filename}")
|
||||
def hide_file(filename: str, profile: str = Query("default")):
|
||||
_db().hide_file(filename, profile)
|
||||
return {"hidden": filename}
|
||||
|
||||
|
||||
@router.delete("/hidden/{filename}")
|
||||
def unhide_file(filename: str, profile: str = Query("default")):
|
||||
_db().unhide_file(filename, profile)
|
||||
return {"unhidden": filename}
|
||||
|
||||
|
||||
@router.get("/hidden")
|
||||
def get_hidden(profile: str = Query("default")):
|
||||
return sorted(_db().get_hidden_files(profile))
|
||||
@@ -0,0 +1,27 @@
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _db():
|
||||
from ..app import db
|
||||
return db
|
||||
|
||||
|
||||
@router.get("/markers/{filename}")
|
||||
def get_markers(filename: str, profile: str = Query("default")):
|
||||
markers = _db().get_markers(filename, profile)
|
||||
return [
|
||||
{"start_time": t, "marker_number": n, "output_path": p}
|
||||
for t, n, p in markers
|
||||
]
|
||||
|
||||
|
||||
@router.get("/profiles")
|
||||
def get_profiles():
|
||||
return _db().get_profiles()
|
||||
|
||||
|
||||
@router.get("/labels")
|
||||
def get_labels():
|
||||
return _db().get_labels()
|
||||
@@ -0,0 +1,49 @@
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
|
||||
from ..config import MEDIA_DIRS, QUALITY_PRESETS
|
||||
from .. import cache
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _resolve_source(path: str, root: str) -> str:
|
||||
"""Join path to root, verify it stays within root, and exists."""
|
||||
if root not in MEDIA_DIRS:
|
||||
raise HTTPException(status_code=400, detail="invalid root")
|
||||
full = os.path.realpath(os.path.join(root, path))
|
||||
if not full.startswith(os.path.realpath(root) + os.sep):
|
||||
raise HTTPException(status_code=403, detail="path outside media root")
|
||||
if not os.path.isfile(full):
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
return full
|
||||
|
||||
|
||||
@router.get("/stream/{path:path}")
|
||||
def stream_video(path: str, root: str = Query(...), quality: str = Query("low")):
|
||||
if quality not in QUALITY_PRESETS:
|
||||
raise HTTPException(status_code=400, detail=f"invalid quality: {quality}")
|
||||
source = _resolve_source(path, root)
|
||||
|
||||
status = cache.ensure_transcode(source, quality)
|
||||
if status == cache.CacheStatus.READY:
|
||||
return FileResponse(cache.cache_path(source, quality), media_type="video/mp4")
|
||||
return JSONResponse({"status": status, "quality": quality}, status_code=202)
|
||||
|
||||
|
||||
@router.get("/audio/{path:path}")
|
||||
def stream_audio(path: str, root: str = Query(...)):
|
||||
source = _resolve_source(path, root)
|
||||
|
||||
status = cache.ensure_audio(source)
|
||||
if status == cache.CacheStatus.READY:
|
||||
return FileResponse(cache.audio_cache_path(source), media_type="audio/wav")
|
||||
return JSONResponse({"status": status}, status_code=202)
|
||||
|
||||
|
||||
@router.get("/cache/status/{path:path}")
|
||||
def cache_status(path: str, root: str = Query(...)):
|
||||
source = _resolve_source(path, root)
|
||||
return cache.get_all_statuses(source)
|
||||
@@ -0,0 +1,43 @@
|
||||
import asyncio
|
||||
import json
|
||||
import threading
|
||||
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
|
||||
_lock = threading.Lock()
|
||||
_connections: list[WebSocket] = []
|
||||
_loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
|
||||
async def connect(ws: WebSocket):
|
||||
global _loop
|
||||
_loop = asyncio.get_running_loop()
|
||||
await ws.accept()
|
||||
with _lock:
|
||||
_connections.append(ws)
|
||||
try:
|
||||
while True:
|
||||
await ws.receive_text() # keep alive
|
||||
except (WebSocketDisconnect, Exception):
|
||||
pass
|
||||
finally:
|
||||
with _lock:
|
||||
if ws in _connections:
|
||||
_connections.remove(ws)
|
||||
|
||||
|
||||
def broadcast(msg: dict):
|
||||
"""Send a message to all connected WebSocket clients.
|
||||
|
||||
Called from sync code (export callbacks running in background threads),
|
||||
so we schedule sends on uvicorn's event loop.
|
||||
"""
|
||||
if _loop is None:
|
||||
return
|
||||
data = json.dumps(msg)
|
||||
with _lock:
|
||||
for ws in list(_connections):
|
||||
try:
|
||||
asyncio.run_coroutine_threadsafe(ws.send_text(data), _loop)
|
||||
except Exception:
|
||||
pass
|
||||
Reference in New Issue
Block a user