| |
| |
| |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
|
|
| |
| |
|
|
| |
| |
|
|
| |
| |
|
|
| |
| |
|
|
| |
| |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
|
|
| |
| |
| |
|
|
| |
| |
|
|
| |
| |
|
|
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
|
|
| |
|
|
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
|
|
| |
| |
| |
| |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
|
|
| |
| |
|
|
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
|
|
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
|
|
| |
| |
|
|
| |
| |
| |
| |
| |
|
|
| |
| |
|
|
| |
| |
| |
|
|
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
|
|
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
|
|
| |
| |
|
|
| |
| |
|
|
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
|
|
| |
| |
| |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
|
|
| |
| |
| |
| |
|
|
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
|
|
| |
| |
|
|
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
|
|
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
|
|
| |
| |
|
|
| |
| |
|
|
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
|
|
| |
| |
|
|
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| |
|
|
|
|
| |
| """ |
| FastAPI service for the 5-step subtitle pipeline. |
| |
| Project storage contract: |
| |
| src/ |
| ├── input/ |
| │ └── <project_id>/ |
| │ └── file.<original-extension> |
| └── output/ |
| └── <project_id>/ |
| ├── audios/ |
| ├── diarization/ |
| ├── segments/ |
| ├── transcribe/ |
| ├── subtitles/ |
| ├── service/ |
| │ ├── status.json |
| │ └── pipeline.log |
| └── ... |
| |
| API: |
| |
| POST /projects |
| Start a new project from a remote HTTP/HTTPS media URL. |
| |
| GET /projects/{project_id} |
| Return current persistent project status. |
| |
| GET /projects/{project_id}/logs |
| Return recent project log lines. |
| |
| POST /projects/{project_id}/resume |
| Resume the failed/interrupted project from the first incomplete step. |
| |
| GET /health |
| Health check. |
| |
| The processing job is intentionally single-worker inside this process. |
| That is important for your CPU-heavy ML pipeline because Step 2/4 should not |
| be duplicated into multiple independent OS processes. |
| |
| For production across multiple machines/processes, move the job execution to |
| a durable queue such as Celery/RQ/Arq with Redis. FastAPI's BackgroundTasks |
| are suitable for smaller/simple background work but FastAPI itself documents |
| a queue/worker system as preferable for heavy background computation. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import asyncio |
| import copy |
| import json |
| import logging |
| import os |
| import re |
| import shutil |
| import sys |
| import tempfile |
| import threading |
| import time |
| from concurrent.futures import ThreadPoolExecutor |
| from datetime import datetime, timedelta, timezone |
| from email.message import Message |
| from pathlib import Path |
| from typing import Any |
| from urllib.parse import urlparse, unquote |
|
|
| import httpx |
| from fastapi import FastAPI, HTTPException, status |
| from fastapi.responses import FileResponse |
| from fastapi.middleware.cors import CORSMiddleware |
| from pydantic import BaseModel, Field, HttpUrl |
|
|
| from src.pipeline.step_001_separate import run_audio_separation |
| from src.pipeline.step_002_diarize import run_diarization |
| from src.pipeline.step_003_segment import run_segment_preparation |
| from src.pipeline.step_004_transcribe import run_transcription |
| from src.pipeline.step_005_original_subtitle import run_step_5 |
|
|
|
|
| |
| |
| |
|
|
| BASE_DIR = Path(__file__).resolve().parent |
| SRC_DIR = BASE_DIR / "src" |
|
|
| INPUT_ROOT = SRC_DIR / "input" |
| OUTPUT_ROOT = SRC_DIR / "output" |
|
|
| MAX_DOWNLOAD_BYTES = int( |
| os.getenv( |
| "MAX_DOWNLOAD_BYTES", |
| str(4 * 1024 * 1024 * 1024), |
| ) |
| ) |
|
|
| DOWNLOAD_CHUNK_BYTES = 1024 * 1024 |
| HTTP_TIMEOUT_SECONDS = float( |
| os.getenv( |
| "HTTP_TIMEOUT_SECONDS", |
| "120", |
| ) |
| ) |
|
|
| MAX_LOG_LINES = int( |
| os.getenv( |
| "MAX_LOG_LINES", |
| "5000", |
| ) |
| ) |
|
|
| MAX_STATUS_LOG_LINES = int( |
| os.getenv( |
| "MAX_STATUS_LOG_LINES", |
| "100", |
| ) |
| ) |
|
|
| |
| WEBHOOK_TIMEOUT_SECONDS = float( |
| os.getenv( |
| "WEBHOOK_TIMEOUT_SECONDS", |
| "15", |
| ) |
| ) |
|
|
| |
| |
| LOG_TO_CONSOLE = os.getenv( |
| "LOG_TO_CONSOLE", |
| "true", |
| ).strip().lower() in { |
| "1", |
| "true", |
| "yes", |
| } |
|
|
| |
| |
| |
|
|
| IST_TZ = timezone( |
| timedelta( |
| hours=5, |
| minutes=30, |
| ), |
| name="IST", |
| ) |
|
|
|
|
| def ist_now() -> datetime: |
| return datetime.now( |
| IST_TZ |
| ) |
|
|
|
|
| def ist_now_iso() -> str: |
| return ist_now().isoformat() |
|
|
|
|
| def ist_now_display() -> str: |
| return ist_now().strftime( |
| "%Y-%m-%d %H:%M:%S.%f" |
| )[:-3] + " IST" |
|
|
|
|
| |
| |
| |
|
|
| class Ansi: |
| RESET = "\033[0m" |
| BOLD = "\033[1m" |
| DIM = "\033[2m" |
|
|
| BLACK = "\033[30m" |
| RED = "\033[31m" |
| GREEN = "\033[32m" |
| YELLOW = "\033[33m" |
| BLUE = "\033[34m" |
| MAGENTA = "\033[35m" |
| CYAN = "\033[36m" |
| WHITE = "\033[37m" |
|
|
| BRIGHT_RED = "\033[91m" |
| BRIGHT_GREEN = "\033[92m" |
| BRIGHT_YELLOW = "\033[93m" |
| BRIGHT_BLUE = "\033[94m" |
| BRIGHT_MAGENTA = "\033[95m" |
| BRIGHT_CYAN = "\033[96m" |
| BRIGHT_WHITE = "\033[97m" |
|
|
|
|
| |
| |
| STEP_COLORS: dict[int, str] = { |
| 1: Ansi.BRIGHT_CYAN, |
| 2: Ansi.BRIGHT_MAGENTA, |
| 3: Ansi.BRIGHT_YELLOW, |
| 4: Ansi.BRIGHT_BLUE, |
| 5: Ansi.BRIGHT_GREEN, |
| } |
|
|
| |
| |
| EVENT_COLORS: dict[str, str] = { |
| "STARTED": Ansi.BLUE, |
| "RUNNING": Ansi.CYAN, |
| "COMPLETED": Ansi.GREEN, |
| "SUCCESS": Ansi.GREEN, |
| "FAILED": Ansi.RED, |
| "ERROR": Ansi.RED, |
| "INFO": Ansi.WHITE, |
| "WEBHOOK": Ansi.MAGENTA, |
| "RESUME": Ansi.YELLOW, |
| "PROJECT": Ansi.BRIGHT_WHITE, |
| } |
|
|
| LEVEL_COLORS: dict[int, str] = { |
| logging.DEBUG: Ansi.WHITE, |
| logging.INFO: Ansi.CYAN, |
| logging.WARNING: Ansi.YELLOW, |
| logging.ERROR: Ansi.RED, |
| logging.CRITICAL: Ansi.BOLD + Ansi.BRIGHT_RED, |
| } |
|
|
|
|
| def _colorize( |
| text: str, |
| color: str, |
| ) -> str: |
| return f"{color}{text}{Ansi.RESET}" |
|
|
|
|
| def step_log_message( |
| step_number: int | None, |
| event_type: str, |
| message: str, |
| ) -> str: |
| """ |
| Build a colorful, per-step log message such as: |
| |
| [STEP 002] STARTED | speaker_diarization |
| |
| Colors are ANSI escape codes. They render nicely in any terminal that |
| tails pipeline.log (e.g. `tail -f` / `cat`) and in the console handler. |
| They are harmless (ignored/visible as-is) in non-ANSI viewers. |
| """ |
| step_color = ( |
| STEP_COLORS.get( |
| step_number, |
| Ansi.WHITE, |
| ) |
| if step_number is not None |
| else Ansi.BRIGHT_WHITE |
| ) |
|
|
| event_color = EVENT_COLORS.get( |
| event_type, |
| Ansi.WHITE, |
| ) |
|
|
| step_tag = ( |
| _colorize( |
| f"[STEP {step_number:03d}]", |
| Ansi.BOLD + step_color, |
| ) |
| if step_number is not None |
| else _colorize( |
| "[PROJECT]", |
| Ansi.BOLD + Ansi.BRIGHT_WHITE, |
| ) |
| ) |
|
|
| event_tag = _colorize( |
| event_type, |
| Ansi.BOLD + event_color, |
| ) |
|
|
| return ( |
| f"{step_tag} {event_tag} | {message}" |
| ) |
|
|
| |
| |
| |
| |
| MAX_CONCURRENT_PIPELINES = 1 |
|
|
| SUPPORTED_EXTENSIONS = { |
| ".mp3", |
| ".wav", |
| ".flac", |
| ".m4a", |
| ".aac", |
| ".ogg", |
| ".opus", |
| ".wma", |
| ".mp4", |
| ".mkv", |
| ".mov", |
| ".avi", |
| ".webm", |
| ".m4v", |
| ".mpeg", |
| ".mpg", |
| ".ts", |
| ".mts", |
| ".m2ts", |
| ".3gp", |
| } |
|
|
| EXTENSION_BY_CONTENT_TYPE = { |
| "audio/mpeg": ".mp3", |
| "audio/wav": ".wav", |
| "audio/x-wav": ".wav", |
| "audio/flac": ".flac", |
| "audio/x-flac": ".flac", |
| "audio/mp4": ".m4a", |
| "audio/aac": ".aac", |
| "audio/ogg": ".ogg", |
| "audio/opus": ".opus", |
| "video/mp4": ".mp4", |
| "video/quicktime": ".mov", |
| "video/x-matroska": ".mkv", |
| "video/webm": ".webm", |
| "video/mpeg": ".mpg", |
| } |
|
|
| MEDIA_MAGIC_FALLBACK_EXTENSION = ".bin" |
|
|
| STEPS = [ |
| { |
| "number": 1, |
| "name": "audio_separation", |
| }, |
| { |
| "number": 2, |
| "name": "speaker_diarization", |
| }, |
| { |
| "number": 3, |
| "name": "speaker_segment_preparation", |
| }, |
| { |
| "number": 4, |
| "name": "speaker_aware_transcription", |
| }, |
| { |
| "number": 5, |
| "name": "original_subtitle_generation", |
| }, |
| ] |
|
|
|
|
| |
| |
| |
|
|
| app = FastAPI( |
| title="Subtitle Pipeline Service", |
| version="1.0.0", |
| description=( |
| "Project-based FastAPI orchestration for audio separation, " |
| "speaker diarization, segmentation, transcription, and subtitles." |
| ), |
| ) |
|
|
| |
| app.add_middleware( |
| CORSMiddleware, |
| allow_origins=["*"], |
| allow_credentials=True, |
| allow_methods=["*"], |
| allow_headers=["*"], |
| ) |
|
|
| |
| |
| |
|
|
| PIPELINE_EXECUTOR = ThreadPoolExecutor( |
| max_workers=MAX_CONCURRENT_PIPELINES, |
| thread_name_prefix="subtitle-pipeline", |
| ) |
|
|
| ACTIVE_PROJECTS: set[str] = set() |
| ACTIVE_PROJECTS_LOCK = threading.Lock() |
|
|
| |
| |
| |
| PROJECT_EVENTS: dict[str, asyncio.Event] = {} |
|
|
| PROJECT_EVENTS_LOCK = threading.Lock() |
|
|
|
|
| |
| |
| |
|
|
| class CreateProjectRequest(BaseModel): |
| project_id: str = Field( |
| ..., |
| min_length=1, |
| max_length=128, |
| description="Directory-safe project identifier.", |
| ) |
|
|
| file_url: HttpUrl = Field( |
| ..., |
| description="Public/signed HTTP(S) URL of the source media file.", |
| ) |
|
|
| webhook_url: HttpUrl | None = Field( |
| default=None, |
| description=( |
| "Optional HTTP(S) webhook URL. If present, the service will " |
| "POST a JSON payload to it whenever a pipeline step completes " |
| "(with that step's detailed logs plus all previous logs for " |
| "this project), and once more when the whole pipeline " |
| "completes (with all logs and the subtitle file path/" |
| "download link)." |
| ), |
| ) |
|
|
|
|
| class ResumeProjectResponse(BaseModel): |
| project_id: str |
| status: str |
| resumed_from_step: int | None |
| message: str |
|
|
|
|
| |
| |
| |
|
|
| class ISTColorFormatter(logging.Formatter): |
| """ |
| Logging formatter that: |
| 1. Renders timestamps in Indian Standard Time (IST, UTC+05:30). |
| 2. Colorizes the level name using ANSI escape codes so pipeline.log |
| (and the console mirror) is colorful and easy to scan. |
| |
| The per-step / per-event coloring (STARTED / COMPLETED / FAILED, etc.) |
| is applied separately, in the message text itself, via |
| `step_log_message()`, so a single log line ends up carrying both a |
| level color and a step/event color. |
| """ |
|
|
| def __init__( |
| self, |
| fmt: str, |
| colorize: bool = True, |
| ) -> None: |
| super().__init__(fmt) |
| self.colorize = colorize |
|
|
| def formatTime( |
| self, |
| record: logging.LogRecord, |
| datefmt: str | None = None, |
| ) -> str: |
| dt = datetime.fromtimestamp( |
| record.created, |
| tz=IST_TZ, |
| ) |
|
|
| if datefmt: |
| return dt.strftime( |
| datefmt |
| ) |
|
|
| return ( |
| dt.strftime( |
| "%Y-%m-%d %H:%M:%S.%f" |
| )[:-3] |
| + " IST" |
| ) |
|
|
| def format( |
| self, |
| record: logging.LogRecord, |
| ) -> str: |
| if not self.colorize: |
| return super().format( |
| record |
| ) |
|
|
| |
| |
| colored_record = copy.copy( |
| record |
| ) |
|
|
| level_color = LEVEL_COLORS.get( |
| colored_record.levelno, |
| Ansi.WHITE, |
| ) |
|
|
| colored_record.levelname = _colorize( |
| f"{colored_record.levelname:<8}", |
| level_color, |
| ) |
|
|
| return super().format( |
| colored_record |
| ) |
|
|
|
|
| class ProjectLogger: |
| """ |
| File-backed project logger. |
| |
| Every project gets: |
| src/output/<project_id>/service/pipeline.log |
| |
| A project-specific status.json is also updated after every important |
| transition so status survives API process restarts. |
| """ |
|
|
| def __init__( |
| self, |
| project_id: str, |
| ) -> None: |
| self.project_id = project_id |
|
|
| self.project_dir = ( |
| OUTPUT_ROOT / project_id |
| ) |
|
|
| self.service_dir = ( |
| self.project_dir / "service" |
| ) |
|
|
| self.log_path = ( |
| self.service_dir / "pipeline.log" |
| ) |
|
|
| self.status_path = ( |
| self.service_dir / "status.json" |
| ) |
|
|
| self.service_dir.mkdir( |
| parents=True, |
| exist_ok=True, |
| ) |
|
|
| self._lock = threading.Lock() |
|
|
| |
| |
| self.logger = logging.getLogger( |
| f"subtitle-project.{project_id}" |
| ) |
| self.logger.setLevel(logging.INFO) |
| self.logger.propagate = False |
|
|
| if not self.logger.handlers: |
| file_handler = logging.FileHandler( |
| self.log_path, |
| encoding="utf-8", |
| ) |
| file_handler.setFormatter( |
| ISTColorFormatter( |
| "%(asctime)s | %(levelname)s | %(message)s", |
| colorize=True, |
| ) |
| ) |
| self.logger.addHandler(file_handler) |
|
|
| if LOG_TO_CONSOLE: |
| console_handler = logging.StreamHandler( |
| sys.stdout |
| ) |
| console_handler.setFormatter( |
| ISTColorFormatter( |
| "%(asctime)s | %(levelname)s | " |
| f"[{project_id}] " |
| "%(message)s", |
| colorize=True, |
| ) |
| ) |
| self.logger.addHandler(console_handler) |
|
|
| def write( |
| self, |
| message: str, |
| level: int = logging.INFO, |
| ) -> None: |
| with self._lock: |
| self.logger.log( |
| level, |
| message, |
| ) |
|
|
| def write_step( |
| self, |
| step_number: int | None, |
| event_type: str, |
| message: str, |
| level: int = logging.INFO, |
| ) -> str: |
| """ |
| Write a colorful, per-step log line (both to file and, if enabled, |
| console) and return the fully-composed message text so callers can |
| reuse the exact same text (e.g. for state["logs"] / webhooks). |
| """ |
| composed = step_log_message( |
| step_number, |
| event_type, |
| message, |
| ) |
|
|
| self.write( |
| composed, |
| level=level, |
| ) |
|
|
| return composed |
|
|
| def recent_lines( |
| self, |
| limit: int = MAX_STATUS_LOG_LINES, |
| ) -> list[str]: |
| if not self.log_path.exists(): |
| return [] |
|
|
| |
| |
| with self.log_path.open( |
| "r", |
| encoding="utf-8", |
| errors="replace", |
| ) as handle: |
| lines = handle.readlines() |
|
|
| return lines[-limit:] |
|
|
| def all_lines( |
| self, |
| ) -> list[str]: |
| """ |
| Full project log, unbounded. Used for the "all previous logs |
| related to this project" payload sent to the webhook, and for the |
| final completed-pipeline webhook event. |
| """ |
| if not self.log_path.exists(): |
| return [] |
|
|
| with self.log_path.open( |
| "r", |
| encoding="utf-8", |
| errors="replace", |
| ) as handle: |
| return handle.readlines() |
|
|
| def lines_for_step( |
| self, |
| step_number: int, |
| ) -> list[str]: |
| """ |
| Just the lines related to a single step (STARTED/COMPLETED/FAILED |
| and anything else logged with that step's [STEP NNN] tag). |
| Used for the "this step's detailed logs" webhook payload. |
| """ |
| marker = f"STEP {step_number:03d}]" |
|
|
| return [ |
| line |
| for line in self.all_lines() |
| if marker in line |
| ] |
|
|
|
|
| |
| |
| |
|
|
| def utc_now() -> str: |
| return datetime.now( |
| timezone.utc |
| ).isoformat() |
|
|
|
|
| def _project_state_path( |
| project_id: str, |
| ) -> Path: |
| return ( |
| OUTPUT_ROOT |
| / project_id |
| / "service" |
| / "status.json" |
| ) |
|
|
|
|
| def _default_step_state() -> dict[str, Any]: |
| return { |
| "status": "pending", |
| "started_at": None, |
| "completed_at": None, |
| "error": None, |
| "result": None, |
| } |
|
|
|
|
| def _initial_state( |
| project_id: str, |
| source_url: str, |
| webhook_url: str | None = None, |
| ) -> dict[str, Any]: |
| return { |
| "schema_version": "1.0", |
| "project_id": project_id, |
| "source_url": source_url, |
| "source_file": None, |
| "webhook_url": webhook_url, |
| "status": "queued", |
| "current_step": None, |
| "current_step_name": None, |
| "last_completed_step": 0, |
| "created_at": utc_now(), |
| "updated_at": utc_now(), |
| "completed_at": None, |
| "error": None, |
| "steps": { |
| str(step["number"]): { |
| **step, |
| **_default_step_state(), |
| } |
| for step in STEPS |
| }, |
| "logs": [], |
| } |
|
|
|
|
| def _read_state( |
| project_id: str, |
| ) -> dict[str, Any] | None: |
| path = _project_state_path( |
| project_id |
| ) |
|
|
| if not path.exists(): |
| return None |
|
|
| try: |
| with path.open( |
| "r", |
| encoding="utf-8", |
| ) as handle: |
| return json.load(handle) |
| except json.JSONDecodeError: |
| return None |
|
|
|
|
| def _atomic_write_json( |
| path: Path, |
| payload: dict[str, Any], |
| ) -> None: |
| path.parent.mkdir( |
| parents=True, |
| exist_ok=True, |
| ) |
|
|
| temp = path.with_name( |
| f".{path.name}.tmp-{os.getpid()}" |
| ) |
|
|
| try: |
| with temp.open( |
| "w", |
| encoding="utf-8", |
| ) as handle: |
| json.dump( |
| payload, |
| handle, |
| indent=2, |
| ensure_ascii=False, |
| ) |
| handle.write("\n") |
|
|
| os.replace( |
| temp, |
| path, |
| ) |
| finally: |
| temp.unlink( |
| missing_ok=True, |
| ) |
|
|
|
|
| def _write_state( |
| state: dict[str, Any], |
| ) -> None: |
| state["updated_at"] = utc_now() |
|
|
| |
| state["logs"] = state.get( |
| "logs", |
| [], |
| )[-MAX_STATUS_LOG_LINES:] |
|
|
| _atomic_write_json( |
| _project_state_path( |
| state["project_id"] |
| ), |
| state, |
| ) |
|
|
| with PROJECT_EVENTS_LOCK: |
| event = PROJECT_EVENTS.get( |
| state["project_id"] |
| ) |
|
|
| if event: |
| event.set() |
|
|
|
|
| def _record_event( |
| state: dict[str, Any], |
| logger: ProjectLogger, |
| message: str, |
| *, |
| level: int = logging.INFO, |
| ) -> None: |
| logger.write( |
| message, |
| level=level, |
| ) |
|
|
| logs = state.setdefault( |
| "logs", |
| [], |
| ) |
|
|
| logs.append( |
| { |
| "timestamp_ist": ist_now_iso(), |
| "message": message, |
| } |
| ) |
|
|
| state["updated_at"] = utc_now() |
|
|
| _write_state( |
| state |
| ) |
|
|
|
|
| |
| |
| |
|
|
| PROJECT_ID_PATTERN = re.compile( |
| r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" |
| ) |
|
|
|
|
| def validate_project_id( |
| project_id: str, |
| ) -> str: |
| value = str( |
| project_id |
| ).strip() |
|
|
| if not PROJECT_ID_PATTERN.fullmatch( |
| value |
| ): |
| raise HTTPException( |
| status_code=status.HTTP_400_BAD_REQUEST, |
| detail=( |
| "project_id must contain only letters, numbers, " |
| "dot, underscore, and hyphen, and must not exceed 128 characters." |
| ), |
| ) |
|
|
| return value |
|
|
|
|
| def project_paths( |
| project_id: str, |
| ) -> dict[str, Path]: |
| project_id = validate_project_id( |
| project_id |
| ) |
|
|
| root = ( |
| OUTPUT_ROOT |
| / project_id |
| ) |
|
|
| return { |
| "project_dir": root, |
| "input_dir": INPUT_ROOT / project_id, |
| "output_dir": root, |
| "audios": root / "audios", |
| "diarization": root / "diarization", |
| "segments": root / "segments", |
| "transcribe": root / "transcribe", |
| "subtitles": root / "subtitles", |
| "service": root / "service", |
| } |
|
|
|
|
| |
| |
| |
|
|
| def _extension_from_url( |
| url: str, |
| ) -> str | None: |
| parsed = urlparse( |
| url |
| ) |
|
|
| name = unquote( |
| Path( |
| parsed.path |
| ).name |
| ) |
|
|
| suffix = Path( |
| name |
| ).suffix.lower() |
|
|
| if suffix in SUPPORTED_EXTENSIONS: |
| return suffix |
|
|
| return None |
|
|
|
|
| def _extension_from_content_type( |
| content_type: str | None, |
| ) -> str | None: |
| if not content_type: |
| return None |
|
|
| value = content_type.split( |
| ";", |
| 1, |
| )[0].strip().lower() |
|
|
| return EXTENSION_BY_CONTENT_TYPE.get( |
| value |
| ) |
|
|
|
|
| def _extension_from_content_disposition( |
| header: str | None, |
| ) -> str | None: |
| if not header: |
| return None |
|
|
| message = Message() |
|
|
| message["content-disposition"] = header |
|
|
| filename = message.get_filename() |
|
|
| if not filename: |
| return None |
|
|
| suffix = Path( |
| filename |
| ).suffix.lower() |
|
|
| if suffix in SUPPORTED_EXTENSIONS: |
| return suffix |
|
|
| return None |
|
|
|
|
| def _sniff_extension( |
| first_bytes: bytes, |
| ) -> str | None: |
| """ |
| Best-effort media signature detection. |
| |
| FFmpeg remains the final authority for what the file can actually decode. |
| This function only chooses a useful extension when the URL has no media |
| suffix and the content type is generic. |
| """ |
| if first_bytes.startswith( |
| b"RIFF" |
| ) and first_bytes[8:12] == b"WAVE": |
| return ".wav" |
|
|
| if first_bytes.startswith( |
| b"ID3" |
| ): |
| return ".mp3" |
|
|
| if first_bytes.startswith( |
| b"OggS" |
| ): |
| return ".ogg" |
|
|
| if first_bytes.startswith( |
| b"fLaC" |
| ): |
| return ".flac" |
|
|
| if len(first_bytes) >= 12: |
| if first_bytes[4:8] == b"ftyp": |
| return ".mp4" |
|
|
| if first_bytes.startswith( |
| b"\x1a\x45\xdf\xa3" |
| ): |
| return ".mkv" |
|
|
| return None |
|
|
|
|
| def _safe_download_destination( |
| project_id: str, |
| extension: str, |
| ) -> Path: |
| paths = project_paths( |
| project_id |
| ) |
|
|
| input_dir = paths[ |
| "input_dir" |
| ] |
|
|
| input_dir.mkdir( |
| parents=True, |
| exist_ok=True, |
| ) |
|
|
| return ( |
| input_dir |
| / f"file{extension}" |
| ) |
|
|
|
|
| def download_source_file( |
| project_id: str, |
| source_url: str, |
| logger: ProjectLogger, |
| ) -> Path: |
| """ |
| Stream the remote object directly to disk. |
| |
| The complete file is never loaded into RAM. |
| |
| A .part file is used until the download completes. This makes resume/retry |
| safe: an interrupted download is never mistaken for a usable source. |
| """ |
| logger.write_step( |
| None, |
| "PROJECT", |
| f"Downloading source media: {source_url} " |
| f"| started_at={ist_now_display()}", |
| ) |
|
|
| with httpx.Client( |
| follow_redirects=True, |
| timeout=httpx.Timeout( |
| HTTP_TIMEOUT_SECONDS |
| ), |
| headers={ |
| "User-Agent": ( |
| "subtitle-pipeline/1.0 " |
| "(media downloader)" |
| ), |
| }, |
| ) as client: |
|
|
| try: |
| with client.stream( |
| "GET", |
| source_url, |
| ) as response: |
|
|
| response.raise_for_status() |
|
|
| content_type = response.headers.get( |
| "content-type" |
| ) |
|
|
| content_length_header = response.headers.get( |
| "content-length" |
| ) |
|
|
| if content_length_header: |
| try: |
| content_length = int( |
| content_length_header |
| ) |
| except ValueError: |
| content_length = None |
|
|
| if ( |
| content_length is not None |
| and content_length > MAX_DOWNLOAD_BYTES |
| ): |
| raise ValueError( |
| "Remote media exceeds MAX_DOWNLOAD_BYTES." |
| ) |
|
|
| extension = ( |
| _extension_from_content_disposition( |
| response.headers.get( |
| "content-disposition" |
| ) |
| ) |
| or _extension_from_url( |
| source_url |
| ) |
| or _extension_from_content_type( |
| content_type |
| ) |
| ) |
|
|
| if not extension: |
| extension = MEDIA_MAGIC_FALLBACK_EXTENSION |
|
|
| destination = _safe_download_destination( |
| project_id, |
| extension, |
| ) |
|
|
| partial = destination.with_suffix( |
| destination.suffix |
| + ".part" |
| ) |
|
|
| |
| partial.unlink( |
| missing_ok=True |
| ) |
|
|
| total = 0 |
| sniff = bytearray() |
|
|
| with partial.open( |
| "wb" |
| ) as output: |
|
|
| for chunk in response.iter_bytes( |
| chunk_size=DOWNLOAD_CHUNK_BYTES |
| ): |
| if not chunk: |
| continue |
|
|
| if len(sniff) < 64: |
| needed = 64 - len(sniff) |
| sniff.extend( |
| chunk[:needed] |
| ) |
|
|
| total += len( |
| chunk |
| ) |
|
|
| if total > MAX_DOWNLOAD_BYTES: |
| raise ValueError( |
| "Remote media exceeded MAX_DOWNLOAD_BYTES." |
| ) |
|
|
| output.write( |
| chunk |
| ) |
|
|
| if ( |
| destination.suffix == MEDIA_MAGIC_FALLBACK_EXTENSION |
| ): |
| sniffed = _sniff_extension( |
| bytes(sniff) |
| ) |
|
|
| if sniffed: |
| sniffed_destination = ( |
| destination.with_suffix( |
| sniffed |
| ) |
| ) |
|
|
| sniffed_destination.unlink( |
| missing_ok=True |
| ) |
|
|
| os.replace( |
| partial, |
| sniffed_destination, |
| ) |
|
|
| destination = sniffed_destination |
|
|
| else: |
| os.replace( |
| partial, |
| destination, |
| ) |
| else: |
| os.replace( |
| partial, |
| destination, |
| ) |
|
|
| if destination.stat().st_size <= 0: |
| raise ValueError( |
| "Downloaded media file is empty." |
| ) |
|
|
| logger.write_step( |
| None, |
| "PROJECT", |
| "Download complete: " |
| f"{destination} " |
| f"({destination.stat().st_size:,} bytes) " |
| f"| completed_at={ist_now_display()}", |
| ) |
|
|
| return destination |
|
|
| except Exception: |
| |
| |
| try: |
| if "partial" in locals(): |
| partial.unlink( |
| missing_ok=True |
| ) |
| except Exception: |
| pass |
|
|
| raise |
|
|
|
|
| |
| |
| |
|
|
| def _result_to_jsonable( |
| value: Any, |
| ) -> Any: |
| if value is None: |
| return None |
|
|
| if isinstance( |
| value, |
| ( |
| str, |
| int, |
| float, |
| bool, |
| ), |
| ): |
| return value |
|
|
| if isinstance( |
| value, |
| Path, |
| ): |
| return str( |
| value |
| ) |
|
|
| if isinstance( |
| value, |
| dict, |
| ): |
| return { |
| str(key): _result_to_jsonable( |
| item |
| ) |
| for key, item in value.items() |
| } |
|
|
| if isinstance( |
| value, |
| (list, tuple), |
| ): |
| return [ |
| _result_to_jsonable( |
| item |
| ) |
| for item in value |
| ] |
|
|
| return str( |
| value |
| ) |
|
|
|
|
| |
| |
| |
|
|
| def _subtitle_download_info( |
| project_id: str, |
| state: dict[str, Any], |
| ) -> dict[str, Any]: |
| """ |
| Resolve the generated subtitle file path (if any) plus the existing |
| download route for it, for inclusion in webhook payloads. |
| """ |
| step_5 = state.get( |
| "steps", |
| {}, |
| ).get( |
| "5", |
| {}, |
| ) |
|
|
| step_5_result = ( |
| step_5.get("result") |
| or {} |
| ) |
|
|
| srt_path_value = step_5_result.get( |
| "srt_path" |
| ) |
|
|
| subtitle_path: str | None = None |
|
|
| if srt_path_value and Path( |
| srt_path_value |
| ).exists(): |
| subtitle_path = str( |
| Path( |
| srt_path_value |
| ).resolve() |
| ) |
| else: |
| subtitles_dir = ( |
| OUTPUT_ROOT |
| / project_id |
| / "subtitles" |
| ) |
|
|
| if subtitles_dir.exists(): |
| srt_files = sorted( |
| subtitles_dir.glob( |
| "*.srt" |
| ), |
| key=lambda path: path.stat().st_mtime, |
| reverse=True, |
| ) |
|
|
| if srt_files: |
| subtitle_path = str( |
| srt_files[0].resolve() |
| ) |
|
|
| return { |
| "subtitle_file_path": subtitle_path, |
| "subtitle_ready": subtitle_path is not None, |
| |
| "subtitle_download_url": ( |
| f"/projects/{project_id}/subtitle" |
| ), |
| } |
|
|
|
|
| def _send_webhook( |
| state: dict[str, Any], |
| logger: ProjectLogger, |
| event: str, |
| *, |
| step_number: int | None = None, |
| step_state: dict[str, Any] | None = None, |
| ) -> None: |
| """ |
| Best-effort webhook delivery. |
| |
| Fired: |
| - after every step completes (success or failure): includes that |
| step's own detailed logs, plus every log line for the project so |
| far ("previous logs too, related to that project"). |
| - once more when the whole pipeline finishes: includes ALL logs for |
| the project plus the subtitle file path / download link. |
| |
| Failures here are logged but NEVER raised — a webhook outage must not |
| break the pipeline. |
| """ |
| webhook_url = state.get( |
| "webhook_url" |
| ) |
|
|
| if not webhook_url: |
| return |
|
|
| project_id = state[ |
| "project_id" |
| ] |
|
|
| payload: dict[str, Any] = { |
| "event": event, |
| "project_id": project_id, |
| "sent_at_ist": ist_now_iso(), |
| "status": state.get("status"), |
| "current_step": state.get("current_step"), |
| "current_step_name": state.get("current_step_name"), |
| "last_completed_step": state.get( |
| "last_completed_step", |
| 0, |
| ), |
| "error": state.get("error"), |
| "logs_so_far": logger.all_lines(), |
| } |
|
|
| if step_number is not None: |
| payload["step"] = { |
| "number": step_number, |
| "name": STEPS[step_number - 1]["name"], |
| "status": (step_state or {}).get("status"), |
| "started_at": (step_state or {}).get("started_at"), |
| "completed_at": (step_state or {}).get("completed_at"), |
| "error": (step_state or {}).get("error"), |
| "result": (step_state or {}).get("result"), |
| } |
| payload["step_logs"] = logger.lines_for_step( |
| step_number |
| ) |
|
|
| if event == "pipeline_completed": |
| payload.update( |
| _subtitle_download_info( |
| project_id, |
| state, |
| ) |
| ) |
| payload["completed_at"] = state.get( |
| "completed_at" |
| ) |
|
|
| try: |
| with httpx.Client( |
| timeout=httpx.Timeout( |
| WEBHOOK_TIMEOUT_SECONDS |
| ), |
| ) as client: |
| response = client.post( |
| webhook_url, |
| json=payload, |
| ) |
|
|
| logger.write_step( |
| step_number, |
| "WEBHOOK", |
| ( |
| f"Webhook delivered | event={event} | " |
| f"url={webhook_url} | " |
| f"response_status={response.status_code}" |
| ), |
| ) |
|
|
| except Exception as exc: |
| logger.write_step( |
| step_number, |
| "WEBHOOK", |
| ( |
| f"Webhook delivery FAILED | event={event} | " |
| f"url={webhook_url} | error={exc}" |
| ), |
| level=logging.WARNING, |
| ) |
|
|
|
|
| def _run_step_1( |
| project_id: str, |
| source_file: Path, |
| ) -> dict[str, Any]: |
| paths = project_paths( |
| project_id |
| ) |
|
|
| vocal_path, instrument_path = ( |
| run_audio_separation( |
| input_file_path=source_file, |
| output_dir=paths["audios"], |
| ) |
| ) |
|
|
| return { |
| "vocal_path": str( |
| vocal_path |
| ), |
| "instrument_path": str( |
| instrument_path |
| ), |
| } |
|
|
|
|
| def _run_step_2( |
| project_id: str, |
| ) -> dict[str, Any]: |
| return run_diarization( |
| project_id |
| ) |
|
|
|
|
| def _run_step_3( |
| project_id: str, |
| ) -> dict[str, Any]: |
| return run_segment_preparation( |
| project_id |
| ) |
|
|
|
|
| def _run_step_4( |
| project_id: str, |
| ) -> dict[str, Any]: |
| return run_transcription( |
| project_id |
| ) |
|
|
|
|
| def _run_step_5( |
| project_id: str, |
| ) -> dict[str, Any]: |
| return run_step_5( |
| project_id |
| ) |
|
|
|
|
| def _step_function( |
| number: int, |
| ): |
| return { |
| 1: _run_step_1, |
| 2: _run_step_2, |
| 3: _run_step_3, |
| 4: _run_step_4, |
| 5: _run_step_5, |
| }[number] |
|
|
|
|
| def _first_incomplete_step( |
| state: dict[str, Any], |
| ) -> int | None: |
| for step in STEPS: |
| step_state = state[ |
| "steps" |
| ][ |
| str(step["number"]) |
| ] |
|
|
| if step_state.get( |
| "status" |
| ) != "success": |
| return step["number"] |
|
|
| return None |
|
|
|
|
| def _mark_step_started( |
| state: dict[str, Any], |
| step_number: int, |
| ) -> None: |
| step_state = state[ |
| "steps" |
| ][ |
| str(step_number) |
| ] |
|
|
| step_state["status"] = "running" |
| step_state["started_at"] = utc_now() |
| step_state["completed_at"] = None |
| step_state["error"] = None |
|
|
| state["status"] = "running" |
| state["current_step"] = step_number |
| state["current_step_name"] = STEPS[ |
| step_number - 1 |
| ][ |
| "name" |
| ] |
|
|
| _write_state( |
| state |
| ) |
|
|
|
|
| def _mark_step_success( |
| state: dict[str, Any], |
| step_number: int, |
| result: Any, |
| ) -> None: |
| step_state = state[ |
| "steps" |
| ][ |
| str(step_number) |
| ] |
|
|
| step_state["status"] = "success" |
| step_state["completed_at"] = utc_now() |
| step_state["error"] = None |
| step_state["result"] = ( |
| _result_to_jsonable( |
| result |
| ) |
| ) |
|
|
| state["last_completed_step"] = max( |
| state.get( |
| "last_completed_step", |
| 0, |
| ), |
| step_number, |
| ) |
|
|
| state["error"] = None |
|
|
| _write_state( |
| state |
| ) |
|
|
|
|
| def _mark_step_failed( |
| state: dict[str, Any], |
| step_number: int, |
| error: Exception, |
| ) -> None: |
| step_state = state[ |
| "steps" |
| ][ |
| str(step_number) |
| ] |
|
|
| step_state["status"] = "failed" |
| step_state["completed_at"] = utc_now() |
| step_state["error"] = str( |
| error |
| ) |
|
|
| state["status"] = "failed" |
| state["error"] = str( |
| error |
| ) |
| state["current_step"] = step_number |
| state["current_step_name"] = STEPS[ |
| step_number - 1 |
| ][ |
| "name" |
| ] |
|
|
| _write_state( |
| state |
| ) |
|
|
|
|
| def _run_pipeline_job( |
| project_id: str, |
| ) -> None: |
| """ |
| Worker function. |
| |
| It intentionally executes the five heavy stages sequentially. |
| |
| A process restart does not destroy progress information because state.json |
| is checkpointed after each step. The resume route uses this same state. |
| """ |
| logger = ProjectLogger( |
| project_id |
| ) |
|
|
| state = _read_state( |
| project_id |
| ) |
|
|
| if not state: |
| logger.write( |
| "Cannot start pipeline: project state missing.", |
| logging.ERROR, |
| ) |
| return |
|
|
| source_file_value = state.get( |
| "source_file" |
| ) |
|
|
| if not source_file_value: |
| state["status"] = "failed" |
| state["error"] = ( |
| "source_file missing from project state." |
| ) |
|
|
| _write_state( |
| state |
| ) |
|
|
| logger.write( |
| "Pipeline aborted: source_file missing.", |
| logging.ERROR, |
| ) |
|
|
| return |
|
|
| source_file = Path( |
| source_file_value |
| ) |
|
|
| if not source_file.exists(): |
| state["status"] = "failed" |
| state["error"] = ( |
| f"Source file not found: {source_file}" |
| ) |
|
|
| _write_state( |
| state |
| ) |
|
|
| logger.write( |
| state["error"], |
| logging.ERROR, |
| ) |
|
|
| return |
|
|
| start_step = ( |
| _first_incomplete_step( |
| state |
| ) |
| ) |
|
|
| if start_step is None: |
| state["status"] = "completed" |
| state["current_step"] = None |
| state["current_step_name"] = None |
| state["completed_at"] = ( |
| state.get( |
| "completed_at" |
| ) |
| or utc_now() |
| ) |
|
|
| _write_state( |
| state |
| ) |
|
|
| logger.write( |
| "Project already completed; nothing to do." |
| ) |
|
|
| return |
|
|
| _record_event( |
| state, |
| logger, |
| step_log_message( |
| None, |
| "PROJECT", |
| f"Pipeline starting from Step {start_step:03d} " |
| f"at {ist_now_display()}.", |
| ), |
| ) |
|
|
| try: |
| for number in range( |
| start_step, |
| 6, |
| ): |
| step = STEPS[ |
| number - 1 |
| ] |
|
|
| step_name = step[ |
| "name" |
| ] |
|
|
| _mark_step_started( |
| state, |
| number, |
| ) |
|
|
| _record_event( |
| state, |
| logger, |
| step_log_message( |
| number, |
| "STARTED", |
| f"{step_name} | started_at={ist_now_display()}", |
| ), |
| ) |
|
|
| try: |
| if number == 1: |
| result = _step_function( |
| number |
| )( |
| project_id, |
| source_file, |
| ) |
| else: |
| result = _step_function( |
| number |
| )( |
| project_id, |
| ) |
|
|
| except Exception as exc: |
| _mark_step_failed( |
| state, |
| number, |
| exc, |
| ) |
|
|
| _record_event( |
| state, |
| logger, |
| step_log_message( |
| number, |
| "FAILED", |
| f"{step_name} | error={exc} | " |
| f"failed_at={ist_now_display()}", |
| ), |
| level=logging.ERROR, |
| ) |
|
|
| |
| |
| _send_webhook( |
| state, |
| logger, |
| event="step_failed", |
| step_number=number, |
| step_state=state["steps"][str(number)], |
| ) |
|
|
| return |
|
|
| _mark_step_success( |
| state, |
| number, |
| result, |
| ) |
|
|
| _record_event( |
| state, |
| logger, |
| step_log_message( |
| number, |
| "COMPLETED", |
| f"{step_name} | completed_at={ist_now_display()}", |
| ), |
| ) |
|
|
| |
| |
| _send_webhook( |
| state, |
| logger, |
| event="step_completed", |
| step_number=number, |
| step_state=state["steps"][str(number)], |
| ) |
|
|
| state["status"] = "completed" |
| state["current_step"] = None |
| state["current_step_name"] = None |
| state["completed_at"] = utc_now() |
| state["error"] = None |
|
|
| _write_state( |
| state |
| ) |
|
|
| _record_event( |
| state, |
| logger, |
| step_log_message( |
| None, |
| "SUCCESS", |
| "PIPELINE COMPLETED SUCCESSFULLY at " |
| f"{ist_now_display()}.", |
| ), |
| ) |
|
|
| |
| |
| _send_webhook( |
| state, |
| logger, |
| event="pipeline_completed", |
| ) |
|
|
| finally: |
| with ACTIVE_PROJECTS_LOCK: |
| ACTIVE_PROJECTS.discard( |
| project_id |
| ) |
|
|
|
|
| |
| |
| |
|
|
| def _submit_pipeline( |
| project_id: str, |
| ) -> None: |
| with ACTIVE_PROJECTS_LOCK: |
| if ( |
| project_id |
| in ACTIVE_PROJECTS |
| ): |
| raise RuntimeError( |
| "Project is already running." |
| ) |
|
|
| ACTIVE_PROJECTS.add( |
| project_id |
| ) |
|
|
| try: |
| PIPELINE_EXECUTOR.submit( |
| _run_pipeline_job, |
| project_id, |
| ) |
| except Exception: |
| with ACTIVE_PROJECTS_LOCK: |
| ACTIVE_PROJECTS.discard( |
| project_id |
| ) |
| raise |
|
|
|
|
| |
| |
| |
|
|
| @app.get( |
| "/", |
| ) |
| async def root() -> dict[str, Any]: |
| """ |
| Root endpoint — simple service banner + a directory of available |
| routes, so hitting the base URL is never a 404. |
| """ |
| with ACTIVE_PROJECTS_LOCK: |
| active = sorted( |
| ACTIVE_PROJECTS |
| ) |
|
|
| return { |
| "service": "Subtitle Pipeline Service", |
| "version": app.version, |
| "status": "ok", |
| "message": ( |
| "Subtitle Pipeline API is running. " |
| "See /docs for interactive API documentation." |
| ), |
| "server_time_ist": ist_now_display(), |
| "active_projects": active, |
| "endpoints": { |
| "root": "GET /", |
| "health": "GET /health", |
| "docs": "GET /docs", |
| "create_project": "POST /projects", |
| "project_status": "GET /projects/{project_id}", |
| "project_logs": "GET /projects/{project_id}/logs", |
| "resume_project": "POST /projects/{project_id}/resume", |
| "download_subtitle": "GET /projects/{project_id}/subtitle", |
| }, |
| } |
|
|
|
|
| @app.get( |
| "/health", |
| ) |
| async def health() -> dict[str, Any]: |
| with ACTIVE_PROJECTS_LOCK: |
| active = sorted( |
| ACTIVE_PROJECTS |
| ) |
|
|
| return { |
| "status": "ok", |
| "service": "subtitle-pipeline", |
| "active_projects": active, |
| "max_concurrent_pipelines": ( |
| MAX_CONCURRENT_PIPELINES |
| ), |
| "output_root": str( |
| OUTPUT_ROOT.resolve() |
| ), |
| } |
|
|
|
|
| @app.post( |
| "/projects", |
| status_code=status.HTTP_202_ACCEPTED, |
| ) |
| async def create_project( |
| request: CreateProjectRequest, |
| ) -> dict[str, Any]: |
| project_id = validate_project_id( |
| request.project_id |
| ) |
|
|
| source_url = str( |
| request.file_url |
| ) |
|
|
| webhook_url = ( |
| str( |
| request.webhook_url |
| ) |
| if request.webhook_url |
| else None |
| ) |
|
|
| paths = project_paths( |
| project_id |
| ) |
|
|
| service_dir = paths[ |
| "service" |
| ] |
|
|
| service_dir.mkdir( |
| parents=True, |
| exist_ok=True, |
| ) |
|
|
| existing = _read_state( |
| project_id |
| ) |
|
|
| if existing: |
| raise HTTPException( |
| status_code=status.HTTP_409_CONFLICT, |
| detail=( |
| f"Project '{project_id}' already exists. " |
| "Use GET for status or POST /resume to continue it." |
| ), |
| ) |
|
|
| logger = ProjectLogger( |
| project_id |
| ) |
|
|
| state = _initial_state( |
| project_id, |
| source_url, |
| webhook_url, |
| ) |
|
|
| _write_state( |
| state |
| ) |
|
|
| logger.write_step( |
| None, |
| "PROJECT", |
| ( |
| f"Project created: {project_id} | source_url={source_url} | " |
| "webhook_url=" |
| + ( |
| webhook_url |
| if webhook_url |
| else "(none)" |
| ) |
| ), |
| ) |
|
|
| try: |
| source_file = await asyncio.to_thread( |
| download_source_file, |
| project_id, |
| source_url, |
| logger, |
| ) |
|
|
| state = _read_state( |
| project_id |
| ) |
|
|
| if not state: |
| raise RuntimeError( |
| "Project state disappeared after download." |
| ) |
|
|
| state["source_file"] = str( |
| source_file.resolve() |
| ) |
| state["status"] = "queued" |
| state["error"] = None |
|
|
| _write_state( |
| state |
| ) |
|
|
| _record_event( |
| state, |
| logger, |
| step_log_message( |
| None, |
| "PROJECT", |
| "Source media downloaded successfully. " |
| f"Submitting pipeline at {ist_now_display()}.", |
| ), |
| ) |
|
|
| _submit_pipeline( |
| project_id |
| ) |
|
|
| except Exception as exc: |
| state = _read_state( |
| project_id |
| ) or state |
|
|
| state["status"] = "failed" |
| state["error"] = str( |
| exc |
| ) |
|
|
| _write_state( |
| state |
| ) |
|
|
| logger.write_step( |
| None, |
| "FAILED", |
| f"PROJECT CREATION FAILED | {exc} | " |
| f"failed_at={ist_now_display()}", |
| level=logging.ERROR, |
| ) |
|
|
| raise HTTPException( |
| status_code=status.HTTP_400_BAD_REQUEST, |
| detail=str( |
| exc |
| ), |
| ) from exc |
|
|
| return { |
| "project_id": project_id, |
| "status": "queued", |
| "source_file": ( |
| state["source_file"] |
| ), |
| "status_url": ( |
| f"/projects/{project_id}" |
| ), |
| "logs_url": ( |
| f"/projects/{project_id}/logs" |
| ), |
| "resume_url": ( |
| f"/projects/{project_id}/resume" |
| ), |
| } |
|
|
|
|
| @app.get( |
| "/projects/{project_id}", |
| ) |
| async def get_project( |
| project_id: str, |
| ) -> dict[str, Any]: |
| project_id = validate_project_id( |
| project_id |
| ) |
|
|
| state = _read_state( |
| project_id |
| ) |
|
|
| if not state: |
| raise HTTPException( |
| status_code=status.HTTP_404_NOT_FOUND, |
| detail=( |
| f"Project '{project_id}' does not exist." |
| ), |
| ) |
|
|
| with ACTIVE_PROJECTS_LOCK: |
| running = ( |
| project_id |
| in ACTIVE_PROJECTS |
| ) |
|
|
| state["runtime"] = { |
| "running_in_this_process": running, |
| } |
|
|
| return state |
|
|
|
|
| @app.get( |
| "/projects/{project_id}/logs", |
| ) |
| async def get_project_logs( |
| project_id: str, |
| limit: int = 100, |
| ) -> dict[str, Any]: |
| project_id = validate_project_id( |
| project_id |
| ) |
|
|
| state = _read_state( |
| project_id |
| ) |
|
|
| if not state: |
| raise HTTPException( |
| status_code=status.HTTP_404_NOT_FOUND, |
| detail=( |
| f"Project '{project_id}' does not exist." |
| ), |
| ) |
|
|
| limit = max( |
| 1, |
| min( |
| limit, |
| MAX_LOG_LINES, |
| ), |
| ) |
|
|
| logger = ProjectLogger( |
| project_id |
| ) |
|
|
| return { |
| "project_id": project_id, |
| "log_file": str( |
| logger.log_path.resolve() |
| ), |
| "lines": logger.recent_lines( |
| limit |
| ), |
| } |
|
|
|
|
| @app.post( |
| "/projects/{project_id}/resume", |
| response_model=ResumeProjectResponse, |
| ) |
| async def resume_project( |
| project_id: str, |
| ) -> ResumeProjectResponse: |
| project_id = validate_project_id( |
| project_id |
| ) |
|
|
| state = _read_state( |
| project_id |
| ) |
|
|
| if not state: |
| raise HTTPException( |
| status_code=status.HTTP_404_NOT_FOUND, |
| detail=( |
| f"Project '{project_id}' does not exist." |
| ), |
| ) |
|
|
| if not state.get( |
| "source_file" |
| ): |
| raise HTTPException( |
| status_code=status.HTTP_409_CONFLICT, |
| detail=( |
| "Cannot resume because the source media file " |
| "is missing from project state." |
| ), |
| ) |
|
|
| if not Path( |
| state["source_file"] |
| ).exists(): |
| raise HTTPException( |
| status_code=status.HTTP_409_CONFLICT, |
| detail=( |
| "Cannot resume because the downloaded source file " |
| "no longer exists." |
| ), |
| ) |
|
|
| with ACTIVE_PROJECTS_LOCK: |
| if project_id in ACTIVE_PROJECTS: |
| raise HTTPException( |
| status_code=status.HTTP_409_CONFLICT, |
| detail=( |
| "Project is already running." |
| ), |
| ) |
|
|
| start_step = _first_incomplete_step( |
| state |
| ) |
|
|
| if start_step is None: |
| return ResumeProjectResponse( |
| project_id=project_id, |
| status="completed", |
| resumed_from_step=None, |
| message=( |
| "Project is already complete." |
| ), |
| ) |
|
|
| |
| step_state = state[ |
| "steps" |
| ][ |
| str(start_step) |
| ] |
|
|
| step_state["status"] = "pending" |
| step_state["error"] = None |
| step_state["started_at"] = None |
| step_state["completed_at"] = None |
|
|
| state["status"] = "queued" |
| state["error"] = None |
| state["current_step"] = start_step |
| state["current_step_name"] = STEPS[ |
| start_step - 1 |
| ][ |
| "name" |
| ] |
|
|
| _write_state( |
| state |
| ) |
|
|
| logger = ProjectLogger( |
| project_id |
| ) |
|
|
| _record_event( |
| state, |
| logger, |
| step_log_message( |
| start_step, |
| "RESUME", |
| f"Restarting from Step {start_step:03d} | " |
| f"{STEPS[start_step - 1]['name']} | " |
| f"requested_at={ist_now_display()}", |
| ), |
| ) |
|
|
| try: |
| _submit_pipeline( |
| project_id |
| ) |
| except RuntimeError as exc: |
| raise HTTPException( |
| status_code=status.HTTP_409_CONFLICT, |
| detail=str( |
| exc |
| ), |
| ) from exc |
|
|
| return ResumeProjectResponse( |
| project_id=project_id, |
| status="queued", |
| resumed_from_step=start_step, |
| message=( |
| f"Project queued for resume from Step {start_step:03d}." |
| ), |
| ) |
|
|
|
|
|
|
| @app.get("/projects/{project_id}/subtitle") |
| async def get_project_subtitle(project_id: str): |
| project_id = validate_project_id(project_id) |
|
|
| state = _read_state(project_id) |
|
|
| if not state: |
| raise HTTPException( |
| status_code=status.HTTP_404_NOT_FOUND, |
| detail={ |
| "status": "not_found", |
| "message": f"Project '{project_id}' does not exist.", |
| }, |
| ) |
|
|
| |
| |
| |
| step_5 = state.get("steps", {}).get("5", {}) |
| step_5_result = step_5.get("result") or {} |
|
|
| srt_path_value = step_5_result.get("srt_path") |
|
|
| |
| |
| |
| if srt_path_value: |
| srt_path = Path(srt_path_value) |
|
|
| if srt_path.exists() and srt_path.is_file(): |
| return FileResponse( |
| path=str(srt_path), |
| media_type="application/x-subrip", |
| filename=srt_path.name, |
| headers={ |
| "Content-Disposition": ( |
| f'attachment; filename="{srt_path.name}"' |
| ) |
| }, |
| ) |
|
|
| |
| |
| |
| |
| subtitles_dir = ( |
| OUTPUT_ROOT |
| / project_id |
| / "subtitles" |
| ) |
|
|
| if subtitles_dir.exists(): |
| srt_files = sorted( |
| subtitles_dir.glob("*.srt"), |
| key=lambda path: path.stat().st_mtime, |
| reverse=True, |
| ) |
|
|
| if srt_files: |
| srt_path = srt_files[0] |
|
|
| return FileResponse( |
| path=str(srt_path), |
| media_type="application/x-subrip", |
| filename=srt_path.name, |
| headers={ |
| "Content-Disposition": ( |
| f'attachment; filename="{srt_path.name}"' |
| ) |
| }, |
| ) |
|
|
| |
| |
| |
| logger = ProjectLogger(project_id) |
|
|
| return { |
| "status": "not_ready", |
| "message": "Subtitle file has not been created yet.", |
| "project_id": project_id, |
| "project_status": state.get("status"), |
| "current_step": state.get("current_step"), |
| "current_step_name": state.get("current_step_name"), |
| "last_completed_step": state.get("last_completed_step", 0), |
| "subtitle_exists": False, |
| "error": state.get("error"), |
| "logs": logger.recent_lines(20), |
| } |
|
|
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |