From beb411dfdc2eb6efefd2129f0698b254c6aa8d37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=B8=D1=80=D0=B8=D0=BB=D0=BB=20=D0=91=D0=BB=D0=B8?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2?= Date: Fri, 29 May 2026 12:17:08 +0300 Subject: [PATCH] Add web service: FastAPI backend + minimal frontend with drag-drop, WebSocket progress, file tree and MD viewer --- .gitignore | 4 + backend/__init__.py | 0 backend/main.py | 183 +++++++++++++++ backend/queue.py | 227 ++++++++++++++++++ backend/static/app.js | 362 +++++++++++++++++++++++++++++ backend/static/index.html | 69 ++++++ backend/static/styles.css | 467 ++++++++++++++++++++++++++++++++++++++ start_server.py | 28 +++ 8 files changed, 1340 insertions(+) create mode 100644 backend/__init__.py create mode 100644 backend/main.py create mode 100644 backend/queue.py create mode 100644 backend/static/app.js create mode 100644 backend/static/index.html create mode 100644 backend/static/styles.css create mode 100644 start_server.py 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 "

Transcription Service

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 ` +
+
+ ${this.escapeHtml(task.file || '')} + ${this.getStatusLabel(task.status)} +
+
+
+
+
${this.escapeHtml(task.message || '')}
+
+ `; + } + + getStatusLabel(status) { + const labels = { + 'queued': 'В очереди', + 'processing': 'Обработка', + 'completed': 'Готово', + 'error': 'Ошибка', + }; + return labels[status] || status; + } + + // ===== File Tree ===== + renderFileTree(tree) { + const container = document.getElementById('fileTree'); + + if (!tree || tree.length === 0) { + container.innerHTML = '

Нет обработанных файлов

'; + 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 ` +
+ ${icon} + ${this.escapeHtml(file.name)} + ${this.formatBytes(file.size)} +
+ `; + }).join(''); + + return ` +
+
+ + 📁 + ${this.escapeHtml(folder.name)} + ${this.formatDate(folder.created)} +
+
+ ${files} +
+
+ `; + } + + setupFileTreeEvents() { + const container = document.getElementById('fileTree'); + + container.addEventListener('click', (e) => { + const folderHeader = e.target.closest('.folder-header'); + if (folderHeader) { + const folder = folderHeader.closest('.folder-item'); + const files = folder.querySelector('.folder-files'); + const toggle = folderHeader.querySelector('.folder-toggle'); + + if (files.style.display === 'none') { + files.style.display = 'block'; + toggle.textContent = '▼'; + } else { + files.style.display = 'none'; + toggle.textContent = '▶'; + } + } + + const fileItem = e.target.closest('.file-item.clickable'); + if (fileItem) { + const path = fileItem.dataset.path; + const ext = fileItem.dataset.ext; + this.loadFileContent(path, ext); + } + }); + } + + // ===== Viewer ===== + async loadFileContent(path, ext) { + const viewer = document.getElementById('viewer'); + + if (ext === '.md') { + try { + const response = await fetch(`/api/files/content?path=${encodeURIComponent(path)}`); + const result = await response.json(); + + if (result.error) { + viewer.innerHTML = `
${this.escapeHtml(result.error)}
`; + return; + } + + // Render markdown + const html = marked.parse(result.content); + viewer.innerHTML = ` +
+
+ ${this.escapeHtml(path)} + ⬇️ Скачать +
+
${html}
+
+ `; + this.currentFile = path; + } catch (error) { + viewer.innerHTML = `
Ошибка загрузки: ${this.escapeHtml(error.message)}
`; + } + } else if (ext === '.docx') { + viewer.innerHTML = ` +
+

Для просмотра DOCX скачайте файл:

+ ⬇️ Скачать ${this.escapeHtml(path)} +
+ `; + } + } + + // ===== Utilities ===== + showToast(message, type = 'info') { + const container = document.getElementById('toastContainer'); + const toast = document.createElement('div'); + toast.className = `toast toast-${type}`; + toast.textContent = message; + container.appendChild(toast); + + setTimeout(() => { + toast.classList.add('toast-hide'); + setTimeout(() => toast.remove(), 300); + }, 4000); + } + + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + formatBytes(bytes) { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; + } + + formatDate(isoDate) { + const date = new Date(isoDate); + return date.toLocaleString('ru-RU', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } +} + +// Initialize +document.addEventListener('DOMContentLoaded', () => { + const app = new TranscriptionApp(); + app.setupFileTreeEvents(); +}); diff --git a/backend/static/index.html b/backend/static/index.html new file mode 100644 index 0000000..739a1c9 --- /dev/null +++ b/backend/static/index.html @@ -0,0 +1,69 @@ + + + + + + Транскрибация совещаний + + + + + +
+
+

🎙️ Транскрибация совещаний

+

Загрузите аудио или видео файл для получения протокола

+
+ +
+ +
+
+
+ + + + + +

Перетащите файлы сюда или выберите

+

Поддерживаются: MP4, AVI, MKV, MOV, WAV, MP3, M4A, OGG, FLAC

+
+ +
+ + +
+

Очередь обработки

+
+

Нет активных задач

+
+
+
+ + +
+
+

📁 Файлы

+
+

Нет обработанных файлов

+
+
+ +
+

📝 Просмотр

+
+
+

Выберите файл для просмотра

+
+
+
+
+
+
+ + +
+ + + + diff --git a/backend/static/styles.css b/backend/static/styles.css new file mode 100644 index 0000000..afd7f1b --- /dev/null +++ b/backend/static/styles.css @@ -0,0 +1,467 @@ +/* ===== Base ===== */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --bg-primary: #0f0f1a; + --bg-secondary: #1a1a2e; + --bg-tertiary: #16213e; + --bg-hover: #1e2a4a; + --text-primary: #e0e0e0; + --text-secondary: #a0a0b0; + --accent: #4a9eff; + --accent-hover: #6ab2ff; + --success: #4ade80; + --error: #f87171; + --warning: #fbbf24; + --border: #2a2a4a; + --radius: 8px; + --shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + line-height: 1.6; + min-height: 100vh; +} + +.container { + max-width: 1400px; + margin: 0 auto; + padding: 20px; +} + +/* ===== Header ===== */ +header { + text-align: center; + padding: 30px 0; + border-bottom: 1px solid var(--border); + margin-bottom: 30px; +} + +header h1 { + font-size: 2rem; + font-weight: 600; + margin-bottom: 8px; +} + +.subtitle { + color: var(--text-secondary); + font-size: 1rem; +} + +/* ===== Upload Section ===== */ +.upload-section { + margin-bottom: 30px; +} + +.drop-zone { + background: var(--bg-secondary); + border: 2px dashed var(--border); + border-radius: var(--radius); + padding: 40px; + text-align: center; + cursor: pointer; + transition: all 0.3s ease; +} + +.drop-zone:hover { + border-color: var(--accent); + background: var(--bg-tertiary); +} + +.drop-zone.drag-over { + border-color: var(--accent); + background: var(--bg-hover); + transform: scale(1.02); +} + +.upload-icon { + width: 48px; + height: 48px; + color: var(--accent); + margin-bottom: 12px; +} + +.browse-link { + color: var(--accent); + text-decoration: underline; + cursor: pointer; +} + +.hint { + color: var(--text-secondary); + font-size: 0.85rem; + margin-top: 8px; +} + +/* ===== Queue Status ===== */ +.queue-status { + margin-top: 20px; + background: var(--bg-secondary); + border-radius: var(--radius); + padding: 20px; +} + +.queue-status h3 { + margin-bottom: 15px; + font-size: 1.1rem; + color: var(--text-primary); +} + +.tasks-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.empty-state { + color: var(--text-secondary); + text-align: center; + padding: 20px; + font-style: italic; +} + +.task-item { + background: var(--bg-tertiary); + border-radius: var(--radius); + padding: 12px 16px; + border-left: 3px solid var(--accent); + transition: all 0.3s ease; +} + +.task-item.success { border-left-color: var(--success); } +.task-item.error { border-left-color: var(--error); } +.task-item.processing { border-left-color: var(--warning); } + +.task-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.task-filename { + font-weight: 500; + font-size: 0.9rem; +} + +.task-status-badge { + font-size: 0.75rem; + padding: 2px 8px; + border-radius: 12px; + font-weight: 600; +} + +.task-status-badge.queued { background: var(--bg-hover); color: var(--text-secondary); } +.task-status-badge.processing { background: rgba(251, 191, 36, 0.2); color: var(--warning); } +.task-status-badge.success { background: rgba(74, 222, 128, 0.2); color: var(--success); } +.task-status-badge.error { background: rgba(248, 113, 113, 0.2); color: var(--error); } + +.task-progress-bar { + height: 4px; + background: var(--bg-hover); + border-radius: 2px; + overflow: hidden; + margin-bottom: 6px; +} + +.task-progress-fill { + height: 100%; + background: var(--accent); + border-radius: 2px; + transition: width 0.5s ease; +} + +.task-item.success .task-progress-fill { background: var(--success); } +.task-item.error .task-progress-fill { background: var(--error); } + +.task-message { + font-size: 0.8rem; + color: var(--text-secondary); +} + +/* ===== Results Section ===== */ +.results-section { + display: grid; + grid-template-columns: 350px 1fr; + gap: 20px; + min-height: 500px; +} + +.panel-left, .panel-right { + background: var(--bg-secondary); + border-radius: var(--radius); + padding: 20px; + overflow: hidden; +} + +.panel-left h2, .panel-right h2 { + font-size: 1.2rem; + margin-bottom: 15px; + padding-bottom: 10px; + border-bottom: 1px solid var(--border); +} + +/* ===== File Tree ===== */ +.file-tree { + overflow-y: auto; + max-height: calc(100vh - 250px); +} + +.folder-item { + margin-bottom: 8px; +} + +.folder-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + cursor: pointer; + border-radius: var(--radius); + transition: background 0.2s; +} + +.folder-header:hover { + background: var(--bg-hover); +} + +.folder-toggle { + font-size: 0.7rem; + color: var(--text-secondary); + width: 16px; + text-align: center; +} + +.folder-icon { + font-size: 1.1rem; +} + +.folder-name { + flex: 1; + font-weight: 500; + font-size: 0.9rem; +} + +.folder-date { + font-size: 0.7rem; + color: var(--text-secondary); +} + +.folder-files { + margin-left: 24px; + padding-left: 8px; + border-left: 1px solid var(--border); +} + +.file-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: var(--radius); + transition: background 0.2s; + cursor: default; +} + +.file-item.clickable { + cursor: pointer; +} + +.file-item.clickable:hover { + background: var(--bg-hover); +} + +.file-icon { + font-size: 1rem; +} + +.file-name { + flex: 1; + font-size: 0.85rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.file-size { + font-size: 0.7rem; + color: var(--text-secondary); +} + +/* ===== Viewer ===== */ +.viewer { + overflow-y: auto; + max-height: calc(100vh - 250px); +} + +.empty-viewer { + display: flex; + align-items: center; + justify-content: center; + height: 300px; + color: var(--text-secondary); +} + +.md-content { + background: var(--bg-tertiary); + border-radius: var(--radius); + overflow: hidden; +} + +.md-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + background: var(--bg-hover); + border-bottom: 1px solid var(--border); +} + +.md-title { + font-size: 0.85rem; + color: var(--text-secondary); + font-family: monospace; +} + +.btn-download { + background: var(--accent); + color: white; + text-decoration: none; + padding: 6px 12px; + border-radius: var(--radius); + font-size: 0.8rem; + transition: background 0.2s; +} + +.btn-download:hover { + background: var(--accent-hover); +} + +.md-body { + padding: 20px; + line-height: 1.8; +} + +.md-body h1 { + font-size: 1.5rem; + margin-bottom: 16px; + color: var(--text-primary); +} + +.md-body h2, .md-body h3 { + margin-top: 20px; + margin-bottom: 10px; +} + +.md-body p { + margin-bottom: 12px; +} + +.md-body strong { + color: var(--accent); +} + +.md-body blockquote { + border-left: 3px solid var(--accent); + padding-left: 16px; + margin: 16px 0; + color: var(--text-secondary); +} + +.md-body code { + background: var(--bg-hover); + padding: 2px 6px; + border-radius: 4px; + font-family: monospace; + font-size: 0.9em; +} + +.md-body pre { + background: var(--bg-hover); + padding: 16px; + border-radius: var(--radius); + overflow-x: auto; + margin: 16px 0; +} + +.md-body ul, .md-body ol { + margin-left: 20px; + margin-bottom: 12px; +} + +.file-preview { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 300px; + gap: 20px; +} + +.error { + color: var(--error); + padding: 20px; + text-align: center; +} + +/* ===== Toast ===== */ +.toast-container { + position: fixed; + top: 20px; + right: 20px; + z-index: 1000; + display: flex; + flex-direction: column; + gap: 10px; +} + +.toast { + padding: 12px 20px; + border-radius: var(--radius); + color: white; + font-size: 0.9rem; + animation: slideIn 0.3s ease; + max-width: 300px; + word-wrap: break-word; + box-shadow: var(--shadow); +} + +.toast-info { background: var(--accent); } +.toast-success { background: var(--success); } +.toast-error { background: var(--error); } + +.toast-hide { + animation: slideOut 0.3s ease forwards; +} + +@keyframes slideIn { + from { transform: translateX(100%); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} + +@keyframes slideOut { + from { transform: translateX(0); opacity: 1; } + to { transform: translateX(100%); opacity: 0; } +} + +/* ===== Responsive ===== */ +@media (max-width: 768px) { + .results-section { + grid-template-columns: 1fr; + } + + header h1 { + font-size: 1.5rem; + } + + .drop-zone { + padding: 20px; + } +} diff --git a/start_server.py b/start_server.py new file mode 100644 index 0000000..99bf969 --- /dev/null +++ b/start_server.py @@ -0,0 +1,28 @@ +"""Скрипт для запуска веб-сервиса транскрибации.""" + +import sys +from pathlib import Path + +# Добавляем родительскую директорию в путь +sys.path.insert(0, str(Path(__file__).parent)) + +import uvicorn +from backend.main import app +from backend.queue import start_workers + +if __name__ == "__main__": + print("🚀 Запуск сервиса транскрибации...") + print("📡 Сервер: http://localhost:8000") + print("") + + # Запускаем фоновых рабочих + start_workers(num_workers=1) + + # Запускаем сервер + uvicorn.run( + "backend.main:app", + host="0.0.0.0", + port=8000, + reload=False, + log_level="info", + )