File size: 10,823 Bytes
946fedd
 
 
 
 
04ff374
946fedd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
04ff374
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
946fedd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
"""FastAPI server for the RT Caption Generator web UI."""

import json
import sys
from pathlib import Path
from typing import List

# Make caption-tool root importable (for config constants used in form defaults)
_ROOT = Path(__file__).parent.parent
if str(_ROOT) not in sys.path:
    sys.path.insert(0, str(_ROOT))

from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles

from config import DEFAULT_LANGUAGE, MAX_CHARS_PER_LINE
from web import db
from web.job_manager import JobManager, JobStatus

# ─── App setup ────────────────────────────────────────────────────────────────

app = FastAPI(title="RT Caption Generator", docs_url=None, redoc_url=None)
manager = JobManager()


@app.on_event("startup")
async def startup():
    db.init_db()

_STATIC = Path(__file__).parent / "static"
app.mount("/static", StaticFiles(directory=str(_STATIC)), name="static")


# ─── Pages ────────────────────────────────────────────────────────────────────

@app.get("/")
async def root():
    return FileResponse(str(_STATIC / "index.html"))


# ─── Job: create (single file) ────────────────────────────────────────────────

@app.post("/api/jobs/single")
async def create_single_job(
    audio: UploadFile = File(...),
    script: UploadFile = File(...),
    language: str = Form(default=DEFAULT_LANGUAGE),
    offset_ms: int = Form(default=0),
    word_level: bool = Form(default=True),
    max_chars: int = Form(default=MAX_CHARS_PER_LINE),
):
    audio_bytes = await audio.read()
    script_bytes = await script.read()

    if not audio_bytes:
        raise HTTPException(400, "Audio file is empty")
    if not script_bytes:
        raise HTTPException(400, "Script file is empty")

    job = await manager.create_job(
        audio_bytes=audio_bytes,
        audio_filename=audio.filename or "audio.mp3",
        script_bytes=script_bytes,
        script_filename=script.filename or "script.txt",
    )

    # Fire-and-forget in background (non-blocking)
    await manager.run_job(job.id, language=language, offset_ms=offset_ms,
                          word_level=word_level, max_chars=max_chars)

    return {"job_id": job.id, "audio_name": job.audio_name}


# ─── Job: create (batch) ──────────────────────────────────────────────────────

@app.post("/api/jobs/batch")
async def create_batch_job(
    files: List[UploadFile] = File(...),
    language: str = Form(default=DEFAULT_LANGUAGE),
    offset_ms: int = Form(default=0),
    word_level: bool = Form(default=True),
    max_chars: int = Form(default=MAX_CHARS_PER_LINE),
):
    """Create a batch job for processing multiple audio/script pairs."""
    
    # Group files by stem (filename without extension)
    file_groups = {}
    
    for file in files:
        if not file.filename:
            continue
            
        file_path = Path(file.filename)
        stem = file_path.stem
        ext = file_path.suffix.lower()
        
        if stem not in file_groups:
            file_groups[stem] = {}
            
        if ext in ['.mp3', '.wav', '.m4a', '.aac']:
            file_groups[stem]['audio'] = file
        elif ext == '.txt':
            file_groups[stem]['script'] = file
    
    # Find valid pairs (both audio and script)
    valid_pairs = []
    for stem, group in file_groups.items():
        if 'audio' in group and 'script' in group:
            valid_pairs.append((stem, group['audio'], group['script']))
    
    if not valid_pairs:
        raise HTTPException(400, "No valid audio/script pairs found. Files should have matching names (e.g., video1.mp3 + video1.txt)")
    
    # Create batch job ID
    import uuid
    batch_id = str(uuid.uuid4())
    
    # Create individual jobs for each pair
    batch_jobs = []
    for stem, audio_file, script_file in valid_pairs:
        audio_bytes = await audio_file.read()
        script_bytes = await script_file.read()
        
        if not audio_bytes:
            raise HTTPException(400, f"Audio file {audio_file.filename} is empty")
        if not script_bytes:
            raise HTTPException(400, f"Script file {script_file.filename} is empty")
        
        job = await manager.create_job(
            audio_bytes=audio_bytes,
            audio_filename=audio_file.filename or f"{stem}.mp3",
            script_bytes=script_bytes,
            script_filename=script_file.filename or f"{stem}.txt",
        )
        
        batch_jobs.append({
            "job_id": job.id,
            "stem": stem,
            "audio_name": job.audio_name,
            "status": "pending"
        })
    
    # Process jobs sequentially in background
    import asyncio
    asyncio.create_task(process_batch_jobs(batch_jobs, language, offset_ms, word_level, max_chars))
    
    return {
        "batch_id": batch_id,
        "job_count": len(batch_jobs),
        "jobs": batch_jobs
    }


async def process_batch_jobs(batch_jobs, language, offset_ms, word_level, max_chars):
    """Process batch jobs sequentially."""
    for job_info in batch_jobs:
        try:
            await manager.run_job(
                job_info["job_id"],
                language=language,
                offset_ms=offset_ms,
                word_level=word_level,
                max_chars=max_chars
            )
            job_info["status"] = "completed"
        except Exception as e:
            job_info["status"] = "failed"
            job_info["error"] = str(e)


# ─── Job: SSE progress stream ─────────────────────────────────────────────────

@app.get("/api/jobs/{job_id}/stream")
async def stream_job(job_id: str):
    try:
        job = manager.get_job(job_id)
    except KeyError:
        raise HTTPException(404, f"Job {job_id!r} not found")

    async def event_gen():
        # Keep-alive comment first so the browser recognises the SSE stream
        yield ": connected\n\n"
        while True:
            event = await job.events.get()
            yield f"data: {json.dumps(event)}\n\n"
            if event.get("stage") in ("done", "error"):
                break

    return StreamingResponse(
        event_gen(),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "X-Accel-Buffering": "no",  # disable nginx buffering if any
        },
    )


# ─── Job: status snapshot (polling fallback) ──────────────────────────────────

@app.get("/api/jobs/{job_id}")
async def get_job_status(job_id: str):
    try:
        job = manager.get_job(job_id)
    except KeyError:
        raise HTTPException(404, f"Job {job_id!r} not found")

    return {
        "job_id": job.id,
        "status": job.status,
        "caption_count": job.caption_count,
        "error": job.error,
        "output_filename": job.output_path.name if job.output_path else None,
    }


# ─── Job: download SRT ────────────────────────────────────────────────────────

@app.get("/api/jobs/{job_id}/download")
async def download_srt(job_id: str):
    try:
        job = manager.get_job(job_id)
    except KeyError:
        raise HTTPException(404, "Job not found")

    if job.status != JobStatus.DONE or not job.output_path:
        raise HTTPException(409, "SRT file not ready yet")

    if not job.output_path.exists():
        raise HTTPException(410, "SRT file has been removed from disk")

    return FileResponse(
        str(job.output_path),
        media_type="application/x-subrip",
        filename=job.output_path.name,
        headers={
            "Content-Disposition": f'attachment; filename="{job.output_path.name}"'
        },
    )


# ─── Job: quality metrics ─────────────────────────────────────────────────────

@app.get("/api/jobs/{job_id}/quality")
async def get_quality(job_id: str):
    try:
        job = manager.get_job(job_id)
    except KeyError:
        raise HTTPException(404, "Job not found")

    if job.quality_metrics is None:
        raise HTTPException(409, "Quality analysis not complete yet")

    return {
        "metrics": job.quality_metrics,
        "suggestions": job.suggestions or [],
    }


# ─── Job: cleanup ─────────────────────────────────────────────────────────────

@app.delete("/api/jobs/{job_id}")
async def delete_job(job_id: str):
    try:
        manager.cleanup_job(job_id)
    except KeyError:
        raise HTTPException(404, "Job not found")
    return {"deleted": job_id}


# ─── History: list all completed jobs ─────────────────────────────────────────

@app.get("/history")
async def get_history():
    return db.list_jobs()


# ─── History: download SRT by DB id ───────────────────────────────────────────

@app.get("/download/{job_id}")
async def download_from_history(job_id: int):
    from fastapi.responses import Response
    srt = db.get_srt(job_id)
    if srt is None:
        raise HTTPException(404, "Job not found in history")
    jobs = db.list_jobs()
    row = next((j for j in jobs if j["id"] == job_id), None)
    filename = f"{row['job_name']}.srt" if row else f"job_{job_id}.srt"
    return Response(
        content=srt,
        media_type="application/x-subrip",
        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
    )


# ─── History: delete a job ─────────────────────────────────────────────────────

@app.delete("/job/{job_id}")
async def delete_history_job(job_id: int):
    if not db.delete_job(job_id):
        raise HTTPException(404, "Job not found in history")
    return {"deleted": job_id}