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 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.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"] 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), ) return {"outputs": 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]]] = [] 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"])) if not markers: skipped.append({"video_id": video["id"], "filename": video["filename"], "reason": "No saved markers"}) continue ready.append((video, markers)) 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) 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), ) all_outputs.extend(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"}, )