Перетащите файлы сюда или выберите
+Поддерживаются: MP4, AVI, MKV, MOV, WAV, MP3, M4A, OGG, FLAC
+Очередь обработки
+Нет активных задач
+📁 Файлы
+Нет обработанных файлов
+📝 Просмотр
+Выберите файл для просмотра
+diff --git a/.gitignore b/.gitignore index 2452309..44ee6a3 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,7 @@ Thumbs.db # User data video/ *.mp4 + +# Server logs +server.log +*.log diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..62e5baa --- /dev/null +++ b/backend/main.py @@ -0,0 +1,183 @@ +"""FastAPI backend для сервиса транскрибации.""" + +import json +from pathlib import Path +from typing import List, Optional + +from fastapi import FastAPI, File, UploadFile, WebSocket, WebSocketDisconnect +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse, PlainTextResponse, HTMLResponse +from fastapi.staticfiles import StaticFiles + +from backend.queue import ( + UPLOAD_DIR, + PROCESSED_DIR, + enqueue, + get_all_tasks, + get_task_status, + get_processed_tree, + read_file_content, + set_progress_callback, +) + +app = FastAPI(title="Transcription Service", version="1.0.0") + +# CORS +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# WebSocket менеджер +class ConnectionManager: + def __init__(self): + self.active_connections: List[WebSocket] = [] + + async def connect(self, websocket: WebSocket): + await websocket.accept() + self.active_connections.append(websocket) + + def disconnect(self, websocket: WebSocket): + if websocket in self.active_connections: + self.active_connections.remove(websocket) + + async def broadcast(self, message: dict): + for conn in self.active_connections: + try: + await conn.send_json(message) + except Exception: + pass + +manager = ConnectionManager() + +# Устанавливаем callback для отправки прогресса через WebSocket +set_progress_callback(manager.broadcast) + + +# === API Endpoints === + +@app.get("/", response_class=HTMLResponse) +async def root(): + """Главная страница.""" + index_path = Path(__file__).parent / "static" / "index.html" + if index_path.exists(): + return index_path.read_text(encoding="utf-8") + return "
Frontend not built
" + + +@app.post("/upload") +async def upload_file(file: UploadFile = File(...)): + """Загружает файл и добавляет в очередь обработки.""" + # Сохраняем файл + file_path = UPLOAD_DIR / file.filename + with open(file_path, "wb") as f: + content = await file.read() + f.write(content) + + # Добавляем в очередь + task_id = await enqueue(file_path) + + return { + "task_id": task_id, + "filename": file.filename, + "status": "queued", + "message": "Файл добавлен в очередь обработки", + } + + +@app.post("/upload-batch") +async def upload_batch(files: List[UploadFile] = File(...)): + """Загружает несколько файлов пакетно.""" + results = [] + for file in files: + file_path = UPLOAD_DIR / file.filename + with open(file_path, "wb") as f: + content = await file.read() + f.write(content) + + task_id = await enqueue(file_path) + results.append({ + "task_id": task_id, + "filename": file.filename, + "status": "queued", + }) + + return { + "uploaded": len(results), + "tasks": results, + } + + +@app.websocket("/ws") +async def websocket_endpoint(websocket: WebSocket): + """WebSocket для получения прогресса обработки.""" + await manager.connect(websocket) + try: + while True: + # Ждём сообщения от клиента (ping/keepalive) + data = await websocket.receive_text() + msg = json.loads(data) + + if msg.get("action") == "get_tasks": + tasks = get_all_tasks() + await websocket.send_json({ + "type": "tasks_list", + "tasks": tasks, + }) + elif msg.get("action") == "get_tree": + tree = get_processed_tree() + await websocket.send_json({ + "type": "file_tree", + "tree": tree, + }) + except WebSocketDisconnect: + manager.disconnect(websocket) + except Exception: + manager.disconnect(websocket) + + +@app.get("/api/tasks") +async def api_tasks(): + """Возвращает список всех задач.""" + return {"tasks": get_all_tasks()} + + +@app.get("/api/tasks/{task_id}") +async def api_task(task_id: str): + """Возвращает статус конкретной задачи.""" + status = get_task_status(task_id) + if not status: + return {"error": "Task not found"} + return status + + +@app.get("/api/files") +async def api_files(): + """Возвращает дерево обработанных файлов.""" + return {"tree": get_processed_tree()} + + +@app.get("/api/files/content") +async def api_file_content(path: str): + """Возвращает содержимое файла.""" + try: + content = read_file_content(path) + return {"content": content, "path": path} + except Exception as e: + return {"error": str(e)} + + +@app.get("/api/files/download") +async def api_download(path: str): + """Скачивает файл.""" + file_path = PROCESSED_DIR / path + if not file_path.exists(): + return {"error": "File not found"} + return FileResponse(file_path, filename=file_path.name) + + +# Статические файлы +app.mount("/static", StaticFiles(directory="backend/static"), name="static") diff --git a/backend/queue.py b/backend/queue.py new file mode 100644 index 0000000..521d36c --- /dev/null +++ b/backend/queue.py @@ -0,0 +1,227 @@ +"""Фоновая очередь обработки аудио/видео.""" + +import asyncio +import json +import os +import shutil +import sys +from datetime import datetime +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from src.audio_utils import prepare_audio_input +from src.config import get_profile, load_config, resolve_hf_token +from src.document import build_document +from src.pipeline import run_pipeline + + +UPLOAD_DIR = Path("uploads") +PROCESSED_DIR = Path("processed") +UPLOAD_DIR.mkdir(exist_ok=True) +PROCESSED_DIR.mkdir(exist_ok=True) + +# Глобальное хранилище состояний задач +tasks: Dict[str, Dict[str, Any]] = {} + +# Callback для отправки прогресса через WebSocket +_progress_callback: Optional[Callable] = None + + +def set_progress_callback(callback: Callable): + """Устанавливает callback для отправки прогресса.""" + global _progress_callback + _progress_callback = callback + + +async def _send_progress(task_id: str, progress: int, message: str, status: str, result=None, error=None): + """Отправляет прогресс через callback.""" + if _progress_callback: + try: + await _progress_callback({ + "task_id": task_id, + "progress": progress, + "message": message, + "status": status, + "result": result, + "error": error, + }) + except Exception: + pass + + +async def process_file(file_path: Path, task_id: str): + """Обрабатывает один файл и отправляет прогресс.""" + tasks[task_id] = { + "task_id": task_id, + "status": "processing", + "progress": 0, + "message": "Начало обработки...", + "file": str(file_path.name), + "result": None, + "error": None, + "started": datetime.now().isoformat(), + } + + await _send_progress(task_id, 5, "Извлечение аудио...", "processing") + + try: + # Загружаем конфиг + config = load_config() + profile = get_profile(config) + + await _send_progress(task_id, 15, "Загрузка моделей ИИ...", "processing") + + # Подготовка аудио + audio_path = prepare_audio_input(str(file_path)) + + await _send_progress(task_id, 25, "Транскрибация (распознавание речи)...", "processing") + + # Запуск пайплайна + result = run_pipeline( + input_path=str(file_path), + profile_name=None, + config_path=None, + ) + + await _send_progress(task_id, 75, "Генерация документов...", "processing") + + # Определяем имена выходных файлов + stem = file_path.stem + output_dir = PROCESSED_DIR / stem + output_dir.mkdir(parents=True, exist_ok=True) + + # Сохраняем docx и md + docx_path = str(output_dir / f"{stem}.docx") + md_path = str(output_dir / f"{stem}.md") + + build_document(result["segments"], docx_path, config) + build_document(result["segments"], md_path, config) + + # Также сохраняем исходник + src_copy = output_dir / file_path.name + if not src_copy.exists(): + shutil.copy2(str(file_path), str(src_copy)) + + result_data = { + "docx": str(docx_path), + "md": str(md_path), + "dir": str(output_dir), + } + + await _send_progress(task_id, 100, "Обработка завершена", "completed", result=result_data) + + tasks[task_id].update({ + "status": "completed", + "progress": 100, + "message": "Обработка завершена", + "result": result_data, + "finished": datetime.now().isoformat(), + }) + + except Exception as e: + error_msg = str(e) + await _send_progress(task_id, 0, f"Ошибка: {error_msg}", "error", error=error_msg) + tasks[task_id].update({ + "status": "error", + "progress": 0, + "message": f"Ошибка: {error_msg}", + "error": error_msg, + }) + + +# Очередь задач +_queue: asyncio.Queue = asyncio.Queue() +_workers: List[asyncio.Task] = [] + + +async def _worker_loop(): + """Рабочий цикл обработки.""" + while True: + try: + task_id, file_path = await _queue.get() + await process_file(file_path, task_id) + _queue.task_done() + except asyncio.CancelledError: + break + except Exception as e: + print(f"[Worker Error] {e}") + + +def start_workers(num_workers: int = 1): + """Запускает рабочих.""" + global _workers + loop = asyncio.get_event_loop() + for i in range(num_workers): + task = loop.create_task(_worker_loop()) + _workers.append(task) + + +def stop_workers(): + """Останавливает рабочих.""" + for w in _workers: + w.cancel() + + +async def enqueue(file_path: Path) -> str: + """Добавляет файл в очередь.""" + task_id = f"task_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{file_path.stem}" + tasks[task_id] = { + "task_id": task_id, + "status": "queued", + "progress": 0, + "message": "В очереди...", + "file": str(file_path.name), + "result": None, + "error": None, + "started": datetime.now().isoformat(), + } + await _queue.put((task_id, file_path)) + return task_id + + +def get_task_status(task_id: str) -> Optional[Dict[str, Any]]: + """Возвращает статус задачи.""" + return tasks.get(task_id) + + +def get_all_tasks() -> List[Dict[str, Any]]: + """Возвращает все задачи.""" + return list(tasks.values()) + + +def get_processed_tree() -> List[Dict[str, Any]]: + """Возвращает дерево обработанных файлов.""" + tree = [] + if not PROCESSED_DIR.exists(): + return tree + + for item in sorted(PROCESSED_DIR.iterdir()): + if item.is_dir(): + files = [] + for f in sorted(item.iterdir()): + if f.is_file(): + files.append({ + "name": f.name, + "path": str(f.relative_to(PROCESSED_DIR)), + "size": f.stat().st_size, + "ext": f.suffix.lower(), + }) + tree.append({ + "name": item.name, + "path": str(item.relative_to(PROCESSED_DIR)), + "files": files, + "created": datetime.fromtimestamp(item.stat().st_ctime).isoformat(), + }) + return tree + + +def read_file_content(rel_path: str) -> str: + """Читает содержимое файла.""" + full_path = PROCESSED_DIR / rel_path + if not full_path.exists() or not full_path.is_file(): + raise FileNotFoundError(f"Файл не найден: {rel_path}") + + with open(full_path, "r", encoding="utf-8") as f: + return f.read() diff --git a/backend/static/app.js b/backend/static/app.js new file mode 100644 index 0000000..bd9167b --- /dev/null +++ b/backend/static/app.js @@ -0,0 +1,362 @@ +/** + * Frontend application for Transcription Service + */ + +class TranscriptionApp { + constructor() { + this.ws = null; + this.tasks = new Map(); + this.currentFile = null; + this.init(); + } + + init() { + this.connectWebSocket(); + this.setupUpload(); + this.loadFileTree(); + } + + // ===== WebSocket ===== + connectWebSocket() { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + this.ws = new WebSocket(`${protocol}//${window.location.host}/ws`); + + this.ws.onopen = () => { + console.log('WebSocket connected'); + this.showToast('Подключено к серверу', 'success'); + this.requestTasks(); + this.requestTree(); + }; + + this.ws.onmessage = (event) => { + const data = JSON.parse(event.data); + this.handleWebSocketMessage(data); + }; + + this.ws.onclose = () => { + console.log('WebSocket disconnected, reconnecting in 3s...'); + setTimeout(() => this.connectWebSocket(), 3000); + }; + + this.ws.onerror = (error) => { + console.error('WebSocket error:', error); + }; + } + + sendWS(data) { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(data)); + } + } + + requestTasks() { + this.sendWS({ action: 'get_tasks' }); + } + + requestTree() { + this.sendWS({ action: 'get_tree' }); + } + + handleWebSocketMessage(data) { + if (data.type === 'tasks_list') { + this.updateTasks(data.tasks); + } else if (data.type === 'file_tree') { + this.renderFileTree(data.tree); + } else if (data.task_id) { + // Прогресс обработки + this.updateTaskProgress(data); + } + } + + // ===== Upload ===== + setupUpload() { + const dropZone = document.getElementById('dropZone'); + const fileInput = document.getElementById('fileInput'); + const browseLink = document.querySelector('.browse-link'); + + // Click to browse + browseLink.addEventListener('click', () => fileInput.click()); + dropZone.addEventListener('click', (e) => { + if (e.target === dropZone || e.target.closest('.drop-zone-content')) { + fileInput.click(); + } + }); + + // File input change + fileInput.addEventListener('change', (e) => { + this.handleFiles(e.target.files); + }); + + // Drag & drop + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('drag-over'); + }); + + dropZone.addEventListener('dragleave', () => { + dropZone.classList.remove('drag-over'); + }); + + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('drag-over'); + this.handleFiles(e.dataTransfer.files); + }); + } + + async handleFiles(files) { + if (!files.length) return; + + const formData = new FormData(); + for (const file of files) { + formData.append('files', file); + } + + try { + this.showToast(`Загрузка ${files.length} файл(а)...`, 'info'); + + const response = await fetch('/upload-batch', { + method: 'POST', + body: formData, + }); + + const result = await response.json(); + + if (result.error) { + this.showToast(`Ошибка: ${result.error}`, 'error'); + } else { + this.showToast(`Загружено ${result.uploaded} файл(а). Начинается обработка...`, 'success'); + result.tasks.forEach(task => { + this.tasks.set(task.task_id, task); + }); + this.renderTasks(); + } + } catch (error) { + this.showToast(`Ошибка загрузки: ${error.message}`, 'error'); + } + } + + // ===== Tasks / Progress ===== + updateTasks(tasks) { + tasks.forEach(task => { + this.tasks.set(task.file + '_' + task.started, task); + }); + this.renderTasks(); + } + + updateTaskProgress(data) { + const existing = Array.from(this.tasks.values()).find(t => t.task_id === data.task_id); + if (existing) { + Object.assign(existing, data); + } else { + this.tasks.set(data.task_id, data); + } + this.renderTasks(); + + if (data.status === 'completed') { + this.showToast(`Готово: ${data.message}`, 'success'); + this.requestTree(); + } else if (data.status === 'error') { + this.showToast(`Ошибка: ${data.message}`, 'error'); + } + } + + renderTasks() { + const container = document.getElementById('tasksList'); + const tasks = Array.from(this.tasks.values()); + + if (tasks.length === 0) { + container.innerHTML = 'Нет активных задач
'; + return; + } + + container.innerHTML = tasks.map(task => this.renderTaskItem(task)).join(''); + } + + renderTaskItem(task) { + const progress = task.progress || 0; + const statusClass = task.status === 'completed' ? 'success' : + task.status === 'error' ? 'error' : + task.status === 'processing' ? 'processing' : 'queued'; + + return ` +Нет обработанных файлов
'; + return; + } + + container.innerHTML = tree.map(folder => this.renderFolder(folder)).join(''); + } + + renderFolder(folder) { + const files = folder.files.map(file => { + const isMd = file.ext === '.md'; + const isDocx = file.ext === '.docx'; + const icon = isMd ? '📝' : isDocx ? '📄' : '📎'; + const clickable = isMd ? 'clickable' : ''; + + return ` +Для просмотра DOCX скачайте файл:
+ ⬇️ Скачать ${this.escapeHtml(path)} +Загрузите аудио или видео файл для получения протокола
+Перетащите файлы сюда или выберите
+Поддерживаются: MP4, AVI, MKV, MOV, WAV, MP3, M4A, OGG, FLAC
+Нет активных задач
+Нет обработанных файлов
+Выберите файл для просмотра
+