476 lines
17 KiB
Python
476 lines
17 KiB
Python
import io
|
|
import json
|
|
import logging
|
|
import time
|
|
import shutil
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
from fastapi import Body, FastAPI, File, HTTPException, Query, UploadFile
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import FileResponse, StreamingResponse
|
|
|
|
from . import db, jobs, media, mkv, schemas
|
|
from .settings import APP_NAME, JOBS_DIR, PROJECTS_DIR, UPLOADS_DIR, BLACKDETECT_WINDOW
|
|
|
|
app = FastAPI(title=APP_NAME)
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"],
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
job_manager = jobs.JobManager()
|
|
|
|
|
|
def _ensure_dirs() -> None:
|
|
for directory in (UPLOADS_DIR, JOBS_DIR, PROJECTS_DIR):
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
def _require_project() -> dict:
|
|
project = db.get_current_project()
|
|
if not project:
|
|
raise HTTPException(status_code=400, detail="No current project. Create one first.")
|
|
return project
|
|
|
|
|
|
def _get_video_or_404(video_id: str) -> dict:
|
|
video = db.get_video(video_id)
|
|
if not video:
|
|
raise HTTPException(status_code=404, detail="Video not found")
|
|
return video
|
|
|
|
|
|
def _sanitize_prefix(value: str) -> str:
|
|
cleaned = "".join(ch for ch in value if ch.isalnum() or ch in ("-", "_"))
|
|
return cleaned or "episode"
|
|
|
|
|
|
def _validate_durations(total_duration: float, intro_seconds: float, outro_seconds: float) -> None:
|
|
if intro_seconds + outro_seconds >= total_duration:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Intro/outro durations exceed or match video length",
|
|
)
|
|
|
|
|
|
def _project_dir(project_id: str) -> Path:
|
|
path = PROJECTS_DIR / project_id
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
return path
|
|
|
|
|
|
def _save_project_file(project: dict) -> None:
|
|
project_dir = _project_dir(project["id"])
|
|
payload = {
|
|
"id": project["id"],
|
|
"name": project["name"],
|
|
"segments_count": project["segments_count"],
|
|
"intro_seconds": project["intro_seconds"],
|
|
"outro_seconds": project["outro_seconds"],
|
|
"reencode_enabled": project["reencode_enabled"],
|
|
"ffmpeg_pass1_template": project["ffmpeg_pass1_template"],
|
|
"ffmpeg_pass2_template": project["ffmpeg_pass2_template"],
|
|
}
|
|
(project_dir / "project.json").write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
|
|
|
|
def _format_eta(seconds: float | None) -> str | None:
|
|
if seconds is None or seconds < 0:
|
|
return None
|
|
seconds = int(round(seconds))
|
|
hours = seconds // 3600
|
|
minutes = (seconds % 3600) // 60
|
|
secs = seconds % 60
|
|
if hours:
|
|
return f"{hours:d}:{minutes:02d}:{secs:02d}"
|
|
return f"{minutes:02d}:{secs:02d}"
|
|
|
|
|
|
def _make_progress_updater(job_id: str, job_label: str, offset: float = 0.0, span: float = 1.0):
|
|
started_at = time.monotonic()
|
|
stage_started_at = started_at
|
|
current_stage = None
|
|
|
|
def progress(done: float, total: float, message: str | None = None, details: dict | None = None) -> None:
|
|
nonlocal stage_started_at, current_stage
|
|
now = time.monotonic()
|
|
total_fraction = max(0.0, min(1.0, done / float(total))) if total else 0.0
|
|
stage = (details or {}).get("stage") or message or job_label
|
|
stage_progress = max(0.0, min(1.0, float((details or {}).get("stage_progress", total_fraction))))
|
|
if stage != current_stage:
|
|
current_stage = stage
|
|
stage_started_at = now
|
|
|
|
elapsed = max(0.001, now - started_at)
|
|
stage_elapsed = max(0.001, now - stage_started_at)
|
|
total_eta = elapsed * (1.0 - total_fraction) / total_fraction if total_fraction > 0.001 else None
|
|
stage_eta = stage_elapsed * (1.0 - stage_progress) / stage_progress if stage_progress > 0.001 else None
|
|
payload = {
|
|
**(details or {}),
|
|
"label": job_label,
|
|
"stage": stage,
|
|
"stage_progress": stage_progress,
|
|
"stage_percent": int(round(stage_progress * 100)),
|
|
"total_progress": total_fraction,
|
|
"total_percent": int(round(total_fraction * 100)),
|
|
"stage_eta_seconds": stage_eta,
|
|
"total_eta_seconds": total_eta,
|
|
"stage_eta": _format_eta(stage_eta),
|
|
"total_eta": _format_eta(total_eta),
|
|
"elapsed_seconds": elapsed,
|
|
}
|
|
full_progress = offset + span * total_fraction
|
|
label = message or f"{stage}: {payload['stage_percent']}%"
|
|
eta_text = f"ETA {payload['stage_eta']}" if payload["stage_eta"] else "ETA calculating"
|
|
job_manager.set_progress(
|
|
job_id,
|
|
full_progress,
|
|
message=f"{label} - {eta_text} - total {int(round(full_progress * 100))}%",
|
|
details=payload,
|
|
)
|
|
|
|
return progress
|
|
|
|
|
|
@app.on_event("startup")
|
|
def on_startup() -> None:
|
|
db.init_db()
|
|
_ensure_dirs()
|
|
|
|
|
|
@app.get("/api/health")
|
|
def health() -> dict:
|
|
return {"status": "ok"}
|
|
|
|
|
|
@app.post("/api/projects", response_model=schemas.ProjectOut)
|
|
def create_project(payload: schemas.ProjectCreate) -> dict:
|
|
project = db.create_project(
|
|
payload.name,
|
|
payload.segments_count,
|
|
payload.intro_seconds,
|
|
payload.outro_seconds,
|
|
reencode_enabled=payload.reencode_enabled,
|
|
ffmpeg_pass1_template=payload.ffmpeg_pass1_template,
|
|
ffmpeg_pass2_template=payload.ffmpeg_pass2_template,
|
|
)
|
|
_save_project_file(project)
|
|
return project
|
|
|
|
|
|
@app.get("/api/projects/current", response_model=schemas.ProjectOut)
|
|
def get_current_project() -> dict:
|
|
project = db.get_current_project()
|
|
if not project:
|
|
raise HTTPException(status_code=404, detail="No current project")
|
|
return project
|
|
|
|
|
|
@app.put("/api/projects/current", response_model=schemas.ProjectOut)
|
|
def update_current_project(payload: schemas.ProjectUpdate) -> dict:
|
|
project = db.update_current_project(
|
|
name=payload.name,
|
|
segments_count=payload.segments_count,
|
|
intro_seconds=payload.intro_seconds,
|
|
outro_seconds=payload.outro_seconds,
|
|
reencode_enabled=payload.reencode_enabled,
|
|
ffmpeg_pass1_template=payload.ffmpeg_pass1_template,
|
|
ffmpeg_pass2_template=payload.ffmpeg_pass2_template,
|
|
)
|
|
if not project:
|
|
raise HTTPException(status_code=404, detail="No current project")
|
|
_save_project_file(project)
|
|
return project
|
|
|
|
|
|
@app.post("/api/videos/upload", response_model=schemas.VideoOut)
|
|
def upload_video(file: UploadFile = File(...)) -> dict:
|
|
project = _require_project()
|
|
if not file.filename or not file.filename.lower().endswith(".mkv"):
|
|
raise HTTPException(status_code=400, detail="Only .mkv files are supported")
|
|
|
|
video_id = uuid.uuid4().hex
|
|
safe_name = file.filename.replace(" ", "_")
|
|
dest_path = UPLOADS_DIR / f"{video_id}_{safe_name}"
|
|
|
|
with dest_path.open("wb") as buffer:
|
|
shutil.copyfileobj(file.file, buffer)
|
|
|
|
duration = media.probe_duration(str(dest_path))
|
|
return db.create_video(project["id"], file.filename, str(dest_path), duration)
|
|
|
|
|
|
@app.get("/api/videos/{video_id}", response_model=schemas.VideoOut)
|
|
def get_video(video_id: str) -> dict:
|
|
return _get_video_or_404(video_id)
|
|
|
|
|
|
@app.get("/api/videos", response_model=list[schemas.VideoOut])
|
|
def list_videos() -> list[dict]:
|
|
project = _require_project()
|
|
return db.list_videos(project["id"])
|
|
|
|
|
|
@app.get("/api/videos/{video_id}/metadata")
|
|
def get_video_metadata(video_id: str) -> dict:
|
|
video = _get_video_or_404(video_id)
|
|
fps = media.probe_framerate(video["file_path"])
|
|
return {"fps": fps}
|
|
|
|
|
|
@app.get("/api/videos/{video_id}/markers")
|
|
def list_markers(video_id: str) -> dict:
|
|
_get_video_or_404(video_id)
|
|
return {"markers": db.list_markers(video_id)}
|
|
|
|
|
|
@app.put("/api/videos/{video_id}/markers")
|
|
def replace_markers(video_id: str, payload: schemas.MarkersUpdate) -> dict:
|
|
_get_video_or_404(video_id)
|
|
markers = sorted(payload.markers)
|
|
return {"markers": db.replace_markers(video_id, markers, source="manual")}
|
|
|
|
|
|
@app.get("/api/videos/{video_id}/segment-edits", response_model=dict[str, list[schemas.SegmentEditOut]])
|
|
def list_segment_edits(video_id: str) -> dict:
|
|
_get_video_or_404(video_id)
|
|
return {"segments": db.list_segment_edits(video_id)}
|
|
|
|
@app.put("/api/videos/{video_id}/segment-edits", response_model=dict[str, list[schemas.SegmentEditOut]])
|
|
def replace_segment_edits(video_id: str, payload: schemas.SegmentEditsUpdate) -> dict:
|
|
_get_video_or_404(video_id)
|
|
segments = [
|
|
{
|
|
"segment_key": item.segment_key,
|
|
"start_seconds": item.start_seconds,
|
|
"end_seconds": item.end_seconds,
|
|
}
|
|
for item in payload.segments
|
|
]
|
|
return {"segments": db.replace_segment_edits(video_id, segments)}
|
|
|
|
|
|
@app.post("/api/videos/{video_id}/autocut", response_model=schemas.JobOut)
|
|
def autocut_video(
|
|
video_id: str,
|
|
payload: schemas.AutoCutRequest | None = Body(default=None),
|
|
) -> dict:
|
|
video = _get_video_or_404(video_id)
|
|
project = _require_project()
|
|
_validate_durations(video["duration_seconds"], project["intro_seconds"], project["outro_seconds"])
|
|
|
|
job = job_manager.create_job("autocut")
|
|
job_id = job["id"]
|
|
|
|
def run_job() -> dict:
|
|
window = payload.window_seconds if payload and payload.window_seconds else BLACKDETECT_WINDOW
|
|
|
|
def progress(done: int, total: int) -> None:
|
|
job_manager.set_progress(job_id, done / float(total), message=f"Analisi {done}/{total}")
|
|
|
|
markers = media.autodetect_cuts(
|
|
video["file_path"],
|
|
video["duration_seconds"],
|
|
project["intro_seconds"],
|
|
project["outro_seconds"],
|
|
project["segments_count"],
|
|
window_seconds=window,
|
|
progress_cb=progress,
|
|
)
|
|
db.replace_markers(video_id, markers, source="auto")
|
|
return {"markers": markers}
|
|
|
|
job_manager.run_in_thread(job_id, run_job)
|
|
return job_manager.get_job(job_id)
|
|
|
|
|
|
@app.post("/api/videos/{video_id}/split", response_model=schemas.JobOut)
|
|
def split_video(
|
|
video_id: str,
|
|
payload: schemas.SplitRequest | None = Body(default=None),
|
|
) -> dict:
|
|
video = _get_video_or_404(video_id)
|
|
project = _require_project()
|
|
_save_project_file(project)
|
|
_validate_durations(video["duration_seconds"], project["intro_seconds"], project["outro_seconds"])
|
|
|
|
markers = payload.markers if payload and payload.markers else db.list_markers(video_id)
|
|
markers = sorted(markers)
|
|
if not markers:
|
|
raise HTTPException(status_code=400, detail="No markers provided")
|
|
|
|
output_prefix = _sanitize_prefix(
|
|
payload.output_prefix if payload and payload.output_prefix else Path(video["filename"]).stem
|
|
)
|
|
|
|
job = job_manager.create_job("split")
|
|
job_id = job["id"]
|
|
|
|
segment_edits = db.list_segment_edits(video_id)
|
|
custom_segments = None
|
|
if segment_edits:
|
|
custom_segments = [(se["start_seconds"], se["end_seconds"]) for se in segment_edits if se["segment_key"].startswith("segment_")]
|
|
|
|
def run_job() -> dict:
|
|
project_dir = _project_dir(project["id"])
|
|
output_dir = project_dir / "outputs" / video_id
|
|
temp_dir = JOBS_DIR / job_id
|
|
|
|
progress = _make_progress_updater(job_id, Path(video["filename"]).stem)
|
|
|
|
outputs = mkv.build_episodes(
|
|
video["file_path"],
|
|
video["duration_seconds"],
|
|
project["intro_seconds"],
|
|
project["outro_seconds"],
|
|
markers,
|
|
output_dir,
|
|
temp_dir,
|
|
project_dir,
|
|
output_prefix=output_prefix,
|
|
reencode=project["reencode_enabled"],
|
|
ffmpeg_pass1_template=project["ffmpeg_pass1_template"],
|
|
ffmpeg_pass2_template=project["ffmpeg_pass2_template"],
|
|
progress_cb=progress,
|
|
log_cb=lambda message: job_manager.log(job_id, message),
|
|
custom_segments=custom_segments,
|
|
)
|
|
db.mark_video_exported(video_id)
|
|
return {"outputs": [Path(o).name for o in outputs], "output_dir": str(output_dir)}
|
|
|
|
job_manager.run_in_thread(job_id, run_job)
|
|
return job_manager.get_job(job_id)
|
|
|
|
|
|
@app.post("/api/videos/split-all", response_model=schemas.JobOut)
|
|
def split_all_videos(payload: schemas.SplitAllRequest | None = Body(default=None)) -> dict:
|
|
project = _require_project()
|
|
_save_project_file(project)
|
|
videos = db.list_videos(project["id"])
|
|
if payload and payload.video_ids:
|
|
selected = set(payload.video_ids)
|
|
videos = [item for item in videos if item["id"] in selected]
|
|
if not videos:
|
|
raise HTTPException(status_code=400, detail="No videos available to export")
|
|
|
|
ready: list[tuple[dict, list[float], list[tuple[float, float]] | None]] = []
|
|
skipped: list[dict] = []
|
|
for video in videos:
|
|
try:
|
|
_validate_durations(video["duration_seconds"], project["intro_seconds"], project["outro_seconds"])
|
|
except HTTPException as exc:
|
|
skipped.append({"video_id": video["id"], "filename": video["filename"], "reason": exc.detail})
|
|
continue
|
|
markers = sorted(db.list_markers(video["id"]))
|
|
segment_edits = db.list_segment_edits(video["id"])
|
|
custom_segments = None
|
|
if segment_edits:
|
|
custom_segments = [(se["start_seconds"], se["end_seconds"]) for se in segment_edits if se["segment_key"].startswith("segment_")]
|
|
|
|
if not markers and not custom_segments:
|
|
skipped.append({"video_id": video["id"], "filename": video["filename"], "reason": "No saved markers or segments"})
|
|
continue
|
|
ready.append((video, markers, custom_segments))
|
|
|
|
if not ready:
|
|
raise HTTPException(status_code=400, detail="No videos have saved markers ready to export")
|
|
|
|
job = job_manager.create_job("split_all")
|
|
job_id = job["id"]
|
|
|
|
def run_job() -> dict:
|
|
project_dir = _project_dir(project["id"])
|
|
all_outputs: list[str] = []
|
|
total_videos = len(ready)
|
|
for video_index, (video, markers, custom_segments) in enumerate(ready, start=1):
|
|
label = f"{video_index}/{total_videos} {Path(video['filename']).stem}"
|
|
output_dir = project_dir / "outputs" / video["id"]
|
|
temp_dir = JOBS_DIR / job_id / video["id"]
|
|
output_prefix = _sanitize_prefix(Path(video["filename"]).stem)
|
|
job_manager.log(job_id, f"Starting export for {video['filename']}")
|
|
outputs = mkv.build_episodes(
|
|
video["file_path"],
|
|
video["duration_seconds"],
|
|
project["intro_seconds"],
|
|
project["outro_seconds"],
|
|
markers,
|
|
output_dir,
|
|
temp_dir,
|
|
project_dir,
|
|
output_prefix=output_prefix,
|
|
reencode=project["reencode_enabled"],
|
|
ffmpeg_pass1_template=project["ffmpeg_pass1_template"],
|
|
ffmpeg_pass2_template=project["ffmpeg_pass2_template"],
|
|
progress_cb=_make_progress_updater(
|
|
job_id,
|
|
label,
|
|
offset=(video_index - 1) / float(total_videos),
|
|
span=1.0 / float(total_videos),
|
|
),
|
|
log_cb=lambda message: job_manager.log(job_id, message),
|
|
custom_segments=custom_segments,
|
|
)
|
|
db.mark_video_exported(video["id"])
|
|
all_outputs.extend([Path(o).name for o in outputs])
|
|
|
|
return {"outputs": all_outputs, "skipped": skipped}
|
|
|
|
job_manager.run_in_thread(job_id, run_job)
|
|
return job_manager.get_job(job_id)
|
|
|
|
|
|
@app.delete("/api/videos/{video_id}", status_code=204)
|
|
def delete_video(video_id: str) -> None:
|
|
video = db.delete_video(video_id)
|
|
if not video:
|
|
raise HTTPException(status_code=404, detail="Video not found")
|
|
file_path = Path(video["file_path"])
|
|
if file_path.exists():
|
|
file_path.unlink()
|
|
|
|
|
|
@app.get("/api/jobs/{job_id}", response_model=schemas.JobOut)
|
|
def get_job(job_id: str) -> dict:
|
|
try:
|
|
return job_manager.get_job(job_id)
|
|
except KeyError:
|
|
raise HTTPException(status_code=404, detail="Job not found")
|
|
|
|
|
|
@app.get("/api/videos/{video_id}/frame")
|
|
def get_frame(
|
|
video_id: str,
|
|
ts: float = Query(..., ge=0),
|
|
fast: bool = Query(default=False),
|
|
w: int | None = Query(default=None, ge=160, le=1920),
|
|
):
|
|
video = _get_video_or_404(video_id)
|
|
if ts > video["duration_seconds"]:
|
|
raise HTTPException(status_code=400, detail="Timestamp exceeds duration")
|
|
|
|
frame_bytes = media.extract_frame_bytes(video["file_path"], ts, fast=fast, scale_width=w)
|
|
return StreamingResponse(
|
|
io.BytesIO(frame_bytes),
|
|
media_type="image/jpeg",
|
|
headers={"Cache-Control": "public, max-age=86400"},
|
|
)
|
|
|
|
|
|
@app.get("/api/videos/{video_id}/outputs/{filename}")
|
|
def download_output(video_id: str, filename: str):
|
|
project = _require_project()
|
|
project_dir = _project_dir(project["id"])
|
|
file_path = project_dir / "outputs" / video_id / filename
|
|
if not file_path.exists():
|
|
raise HTTPException(status_code=404, detail="Output file not found")
|
|
return FileResponse(
|
|
path=file_path,
|
|
filename=filename,
|
|
media_type="video/x-matroska",
|
|
)
|