Fix code review issues: security, race conditions, resource leaks

- Fix race condition: pass return_timestamps as parameter instead of mutating shared state
- Remove /local/transcriptions endpoint (path traversal vulnerability)
- Add timeout=30 and URL scheme validation to prevent SSRF
- Add temp file cleanup via try/finally in all route handlers
- AsyncTaskManager: add threading.Lock and TTL cleanup for completed tasks
- Change audio_rate to 16000 (matches Whisper's expected sample rate)
- Extract filename from URL via Content-Disposition/path
- Detect base64 audio format via python-magic
- Fix response_size_bytes to use json.dumps instead of str()
- Move scipy import to module level
- Clamp temperature to [0.0, 1.0]
- Deduplicate _load_model() code
- Fix docstrings, README structure, typo, log level

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serge Zaigraeff
2026-03-30 00:54:50 +03:00
co-authored by Claude Opus 4.6
parent ff4bd2ba3d
commit 2138651474
12 changed files with 153 additions and 189 deletions
+14 -7
View File
@@ -224,13 +224,20 @@ The project consists of the following components:
- `config.json`: Service configuration file - `config.json`: Service configuration file
- `app/`: Main application module - `app/`: Main application module
- `__init__.py`: Contains the `WhisperServiceAPI` class for service initialization - `__init__.py`: Contains the `WhisperServiceAPI` class for service initialization
- `utils.py`: Logging configuration - `routes.py`: API route definitions
- `transcriber.py`: Contains the `WhisperTranscriber` class for speech recognition - `history.py`: Saving transcription history
- `audio_processor.py`: Contains the `AudioProcessor` class for audio preprocessing - `core/`: Core logic
- `audio_sources.py`: Contains different audio source handlers (upload, URL, base64, local) - `transcriber.py`: `WhisperTranscriber` class for speech recognition
- `transcriber_service.py`: Manages the transcription workflow - `transcription_service.py`: Manages the transcription workflow
- `history_logger.py`: Handles saving transcription history - `audio/`: Audio processing
- `routes.py`: Contains the API route definitions - `processor.py`: `AudioProcessor` class for audio preprocessing
- `sources.py`: Audio source handlers (upload, URL, base64)
- `utils.py`: Audio utilities (loading, duration)
- `infrastructure/`: Supporting modules
- `log.py`: Logging configuration
- `validation.py`: File validation
- `storage.py`: Temp file management
- `async_tasks.py`: Async task manager
- `static/`: Web interface files - `static/`: Web interface files
## Advanced usage ## Advanced usage
+1 -1
View File
@@ -77,7 +77,7 @@ class AudioProcessor:
output_path output_path
] ]
logger.info(f"Конвертация в WAV: {' '.join(cmd)}") logger.debug(f"Конвертация в WAV: {' '.join(cmd)}")
try: try:
subprocess.run(cmd, check=True, capture_output=True) subprocess.run(cmd, check=True, capture_output=True)
+33 -27
View File
@@ -1,12 +1,14 @@
""" """
Модуль sources.py содержит функции для получения аудиофайлов Модуль sources.py содержит функции для получения аудиофайлов
из различных источников (загруженные файлы, URL, base64, локальные файлы). из различных источников (загруженные файлы, URL, base64).
""" """
import os import os
import uuid import uuid
import tempfile import tempfile
import base64 import base64
from urllib.parse import urlparse
import magic
import requests import requests
from typing import Tuple, Optional from typing import Tuple, Optional
import logging import logging
@@ -66,7 +68,11 @@ def get_url_file(url: str, max_file_size_mb: int = 100) -> Tuple[Optional[str],
Кортеж (путь к temp-файлу, имя файла, сообщение об ошибке). Кортеж (путь к temp-файлу, имя файла, сообщение об ошибке).
""" """
try: try:
response = requests.get(url, stream=True) parsed = urlparse(url)
if parsed.scheme not in ('http', 'https'):
return None, None, f"Unsupported URL scheme: {parsed.scheme}. Only http/https allowed"
response = requests.get(url, stream=True, timeout=30)
response.raise_for_status() response.raise_for_status()
# Проверка размера по Content-Length # Проверка размера по Content-Length
@@ -76,12 +82,22 @@ def get_url_file(url: str, max_file_size_mb: int = 100) -> Tuple[Optional[str],
if error: if error:
return None, None, error return None, None, error
# Извлекаем имя файла из Content-Disposition или URL
original_name = None
cd = response.headers.get('Content-Disposition', '')
if 'filename=' in cd:
original_name = cd.split('filename=')[-1].strip('" ')
if not original_name:
url_path = parsed.path.rstrip('/')
if url_path:
original_name = os.path.basename(url_path)
temp_path = _make_temp_path() temp_path = _make_temp_path()
with open(temp_path, 'wb') as f: with open(temp_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192): for chunk in response.iter_content(chunk_size=8192):
f.write(chunk) f.write(chunk)
return temp_path, os.path.basename(temp_path), None return temp_path, original_name or os.path.basename(temp_path), None
except Exception as e: except Exception as e:
logger.error(f"Ошибка при получении файла по URL {url}: {e}") logger.error(f"Ошибка при получении файла по URL {url}: {e}")
@@ -102,7 +118,20 @@ def get_base64_file(base64_data: str, max_file_size_mb: int = 100) -> Tuple[Opti
if error: if error:
return None, None, error return None, None, error
temp_path = _make_temp_path() # Определяем формат по содержимому
mime_to_ext = {
"audio/mpeg": ".mp3",
"audio/ogg": ".ogg",
"audio/flac": ".flac",
"audio/mp4": ".m4a",
"audio/x-m4a": ".m4a",
"audio/aac": ".aac",
"audio/webm": ".webm",
}
detected_mime = magic.from_buffer(audio_data[:1024], mime=True)
suffix = mime_to_ext.get(detected_mime, ".wav")
temp_path = _make_temp_path(suffix)
with open(temp_path, 'wb') as f: with open(temp_path, 'wb') as f:
f.write(audio_data) f.write(audio_data)
@@ -111,26 +140,3 @@ def get_base64_file(base64_data: str, max_file_size_mb: int = 100) -> Tuple[Opti
except Exception as e: except Exception as e:
logger.error(f"Ошибка при декодировании base64 данных: {e}") logger.error(f"Ошибка при декодировании base64 данных: {e}")
return None, None, f"Error decoding base64 data: {str(e)}" return None, None, f"Error decoding base64 data: {str(e)}"
def get_local_file(file_path: str, max_file_size_mb: int = 100) -> Tuple[Optional[str], Optional[str], Optional[str]]:
"""
Получает локальный аудиофайл.
Returns:
Кортеж (путь к файлу, имя файла, сообщение об ошибке).
Примечание: возвращает оригинальный путь, не копирует файл.
"""
if not os.path.exists(file_path):
return None, None, f"File not found: {file_path}"
try:
error = _check_size(os.path.getsize(file_path), max_file_size_mb)
if error:
return None, None, error
return file_path, os.path.basename(file_path), None
except Exception as e:
logger.error(f"Ошибка при открытии локального файла {file_path}: {e}")
return None, None, f"Error opening local file: {str(e)}"
+2 -2
View File
@@ -6,6 +6,7 @@ import os
import subprocess import subprocess
import wave import wave
import numpy as np import numpy as np
from scipy.signal import resample as scipy_resample
import logging import logging
from typing import Tuple from typing import Tuple
@@ -33,9 +34,8 @@ def load_audio(file_path: str, sr: int = 16000) -> Tuple[np.ndarray, int]:
sampling_rate = wav_file.getframerate() sampling_rate = wav_file.getframerate()
if sampling_rate != sr: if sampling_rate != sr:
from scipy.signal import resample
num_samples = int(len(audio_array) * sr / sampling_rate) num_samples = int(len(audio_array) * sr / sampling_rate)
audio_array = resample(audio_array, num_samples) audio_array = scipy_resample(audio_array, num_samples)
sampling_rate = sr sampling_rate = sr
return audio_array, sampling_rate return audio_array, sampling_rate
+23 -25
View File
@@ -117,32 +117,25 @@ class WhisperTranscriber:
""" """
logger.info(f"Загрузка модели из {self.model_path}") logger.info(f"Загрузка модели из {self.model_path}")
model_kwargs = dict(
torch_dtype=self.torch_dtype,
low_cpu_mem_usage=True,
use_safetensors=True,
)
try: try:
# Проверка возможности использования Flash Attention 2
if self.device.type == "cuda": if self.device.type == "cuda":
self.model = WhisperForConditionalGeneration.from_pretrained( model_kwargs["attn_implementation"] = "flash_attention_2"
self.model_path, self.model = WhisperForConditionalGeneration.from_pretrained(
torch_dtype=self.torch_dtype, self.model_path, **model_kwargs
low_cpu_mem_usage=True, ).to(self.device)
use_safetensors=True, if self.device.type == "cuda":
attn_implementation="flash_attention_2"
).to(self.device)
logger.info("Используется Flash Attention 2") logger.info("Используется Flash Attention 2")
else:
self.model = WhisperForConditionalGeneration.from_pretrained(
self.model_path,
torch_dtype=self.torch_dtype,
low_cpu_mem_usage=True,
use_safetensors=True
).to(self.device)
except Exception as e: except Exception as e:
logger.warning(f"Не удалось загрузить модель с Flash Attention: {e}") logger.warning(f"Не удалось загрузить модель с Flash Attention: {e}")
# Fallback к обычной версии model_kwargs.pop("attn_implementation", None)
self.model = WhisperForConditionalGeneration.from_pretrained( self.model = WhisperForConditionalGeneration.from_pretrained(
self.model_path, self.model_path, **model_kwargs
torch_dtype=self.torch_dtype,
low_cpu_mem_usage=True,
use_safetensors=True
).to(self.device) ).to(self.device)
self.processor = WhisperProcessor.from_pretrained(self.model_path) self.processor = WhisperProcessor.from_pretrained(self.model_path)
@@ -161,18 +154,22 @@ class WhisperTranscriber:
logger.info("Модель успешно загружена и готова к использованию") logger.info("Модель успешно загружена и готова к использованию")
def transcribe(self, audio_path: str) -> Union[str, Dict]: def transcribe(self, audio_path: str, return_timestamps: bool = None) -> Union[str, Dict]:
""" """
Транскрибация аудиофайла. Транскрибация аудиофайла.
Args: Args:
audio_path: Путь к обработанному аудиофайлу. audio_path: Путь к обработанному аудиофайлу.
return_timestamps: Флаг возврата временных меток. Если None — берётся из конфига.
Returns: Returns:
В зависимости от параметра return_timestamps: В зависимости от параметра return_timestamps:
- Если return_timestamps=False: строка с распознанным текстом - Если return_timestamps=False: строка с распознанным текстом
- Если return_timestamps=True: словарь с ключами "segments" (список словарей с ключами start_time_ms, end_time_ms, text) и "text" (полный текст) - Если return_timestamps=True: словарь с ключами "segments" (список словарей с ключами start_time_ms, end_time_ms, text) и "text" (полный текст)
""" """
if return_timestamps is None:
return_timestamps = self.return_timestamps
logger.info(f"Начало транскрибации файла: {audio_path}") logger.info(f"Начало транскрибации файла: {audio_path}")
try: try:
@@ -187,11 +184,11 @@ class WhisperTranscriber:
"max_new_tokens": self.max_new_tokens, "max_new_tokens": self.max_new_tokens,
"temperature": self.temperature "temperature": self.temperature
}, },
return_timestamps=self.return_timestamps return_timestamps=return_timestamps
) )
# Если временные метки не запрошены, возвращаем только текст # Если временные метки не запрошены, возвращаем только текст
if not self.return_timestamps: if not return_timestamps:
transcribed_text = result.get("text", "") transcribed_text = result.get("text", "")
logger.info(f"Транскрибация завершена: получено {len(transcribed_text)} символов текста") logger.info(f"Транскрибация завершена: получено {len(transcribed_text)} символов текста")
return transcribed_text return transcribed_text
@@ -241,12 +238,13 @@ class WhisperTranscriber:
logger.error(f"Traceback: {traceback.format_exc()}") logger.error(f"Traceback: {traceback.format_exc()}")
raise raise
def process_file(self, input_path: str) -> Union[str, Dict]: def process_file(self, input_path: str, return_timestamps: bool = None) -> Union[str, Dict]:
""" """
Полный процесс обработки и транскрибации аудиофайла. Полный процесс обработки и транскрибации аудиофайла.
Args: Args:
input_path: Путь к исходному аудиофайлу. input_path: Путь к исходному аудиофайлу.
return_timestamps: Флаг возврата временных меток. Если None — берётся из конфига.
Returns: Returns:
В зависимости от параметра return_timestamps: В зависимости от параметра return_timestamps:
@@ -263,7 +261,7 @@ class WhisperTranscriber:
processed_path, temp_files = self.audio_processor.process_audio(input_path) processed_path, temp_files = self.audio_processor.process_audio(input_path)
# Транскрибация # Транскрибация
result = self.transcribe(processed_path) result = self.transcribe(processed_path, return_timestamps=return_timestamps)
elapsed_time = time.time() - start_time elapsed_time = time.time() - start_time
logger.info(f"Обработка и транскрибация завершены за {elapsed_time:.2f} секунд") logger.info(f"Обработка и транскрибация завершены за {elapsed_time:.2f} секунд")
+5 -11
View File
@@ -3,6 +3,7 @@
который отвечает за транскрибацию аудиофайлов. который отвечает за транскрибацию аудиофайлов.
""" """
import json
import os import os
import time import time
import traceback import traceback
@@ -36,7 +37,7 @@ class TranscriptionService:
""" """
params = params or {} params = params or {}
language = params.get('language', self.config.get('language', 'en')) language = params.get('language', self.config.get('language', 'en'))
temperature = float(params.get('temperature', 0.0)) temperature = max(0.0, min(1.0, float(params.get('temperature', 0.0))))
prompt = params.get('prompt', '') prompt = params.get('prompt', '')
# Проверяем, запрошены ли временные метки # Проверяем, запрошены ли временные метки
@@ -44,10 +45,6 @@ class TranscriptionService:
if isinstance(return_timestamps, str): if isinstance(return_timestamps, str):
return_timestamps = return_timestamps.lower() in ('true', 't', 'yes', 'y', '1') return_timestamps = return_timestamps.lower() in ('true', 't', 'yes', 'y', '1')
# Временно изменяем настройку return_timestamps в транскрайбере
original_return_timestamps = self.transcriber.return_timestamps
self.transcriber.return_timestamps = return_timestamps
try: try:
# Определяем длительность аудиофайла # Определяем длительность аудиофайла
try: try:
@@ -57,7 +54,7 @@ class TranscriptionService:
return {"error": f"Не удалось определить длительность аудиофайла: {e}"}, 500 return {"error": f"Не удалось определить длительность аудиофайла: {e}"}, 500
start_time = time.time() start_time = time.time()
result = self.transcriber.process_file(file_path) result = self.transcriber.process_file(file_path, return_timestamps=return_timestamps)
processing_time = time.time() - start_time processing_time = time.time() - start_time
# Формируем ответ # Формируем ответ
@@ -66,7 +63,7 @@ class TranscriptionService:
"segments": result.get("segments", []), "segments": result.get("segments", []),
"text": result.get("text", ""), "text": result.get("text", ""),
"processing_time": processing_time, "processing_time": processing_time,
"response_size_bytes": len(str(result).encode('utf-8')), "response_size_bytes": len(json.dumps(result, ensure_ascii=False).encode('utf-8')),
"duration_seconds": duration, "duration_seconds": duration,
"model": os.path.basename(self.config["model_path"]) "model": os.path.basename(self.config["model_path"])
} }
@@ -74,7 +71,7 @@ class TranscriptionService:
response = { response = {
"text": result, "text": result,
"processing_time": processing_time, "processing_time": processing_time,
"response_size_bytes": len(str(result).encode('utf-8')), "response_size_bytes": len(json.dumps(result, ensure_ascii=False).encode('utf-8')),
"duration_seconds": duration, "duration_seconds": duration,
"model": os.path.basename(self.config["model_path"]) "model": os.path.basename(self.config["model_path"])
} }
@@ -86,6 +83,3 @@ class TranscriptionService:
logger.error(f"Ошибка при транскрибации файла '{filename}': {str(e)}") logger.error(f"Ошибка при транскрибации файла '{filename}': {str(e)}")
logger.error(f"Traceback: {traceback.format_exc()}") logger.error(f"Traceback: {traceback.format_exc()}")
return {"error": str(e)}, 500 return {"error": str(e)}, 500
finally:
self.transcriber.return_timestamps = original_return_timestamps
+2 -2
View File
@@ -1,5 +1,5 @@
""" """
Модуль history_logger.py — сохранение истории транскрибации в JSON-файлы. Модуль history.py — сохранение истории транскрибации в JSON-файлы.
""" """
import os import os
@@ -13,7 +13,7 @@ import logging
logger = logging.getLogger('app.history') logger = logging.getLogger('app.history')
# Корневая директория истории (относительно корня проекта) # Корневая директория истории (относительно корня проекта)
_history_root = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "history") _history_root = os.path.join(os.path.dirname(os.path.dirname(__file__)), "history")
def save_history(result: Dict[str, Any], original_filename: str, config: Dict) -> Optional[str]: def save_history(result: Dict[str, Any], original_filename: str, config: Dict) -> Optional[str]:
+38 -24
View File
@@ -1,11 +1,11 @@
""" """
Модуль manager.py содержит функции для асинхронной обработки задач. Модуль async_tasks.py содержит функции для асинхронной обработки задач.
""" """
import uuid import uuid
import time import time
from typing import Dict, Any, Callable, Optional from typing import Dict, Any, Callable, Optional
from threading import Thread from threading import Thread, Lock
import logging import logging
logger = logging.getLogger('app.async_tasks') logger = logging.getLogger('app.async_tasks')
@@ -19,10 +19,13 @@ class AsyncTaskManager:
tasks (Dict): Словарь для хранения информации о задачах. tasks (Dict): Словарь для хранения информации о задачах.
""" """
TASK_TTL = 3600 # 1 час
def __init__(self): def __init__(self):
""" """
Инициализация менеджера асинхронных задач. Инициализация менеджера асинхронных задач.
""" """
self._lock = Lock()
self.tasks: Dict[str, Dict[str, Any]] = {} self.tasks: Dict[str, Dict[str, Any]] = {}
def run_task(self, func: Callable, *args, **kwargs) -> str: def run_task(self, func: Callable, *args, **kwargs) -> str:
@@ -39,23 +42,33 @@ class AsyncTaskManager:
""" """
task_id = str(uuid.uuid4()) task_id = str(uuid.uuid4())
# Создание информации о задаче with self._lock:
self.tasks[task_id] = { self._cleanup_old_tasks()
"status": "pending", self.tasks[task_id] = {
"result": None, "status": "pending",
"error": None, "result": None,
"created_at": time.time(), "error": None,
"started_at": None, "created_at": time.time(),
"completed_at": None "started_at": None,
} "completed_at": None
}
# Создание и запуск потока
thread = Thread(target=self._run_task_thread, args=(task_id, func, args, kwargs)) thread = Thread(target=self._run_task_thread, args=(task_id, func, args, kwargs))
thread.daemon = True thread.daemon = True
thread.start() thread.start()
return task_id return task_id
def _cleanup_old_tasks(self) -> None:
"""Удаляет завершённые задачи старше TASK_TTL. Вызывать под self._lock."""
now = time.time()
expired = [
tid for tid, info in self.tasks.items()
if info["completed_at"] and now - info["completed_at"] > self.TASK_TTL
]
for tid in expired:
del self.tasks[tid]
def _run_task_thread(self, task_id: str, func: Callable, args: tuple, kwargs: dict) -> None: def _run_task_thread(self, task_id: str, func: Callable, args: tuple, kwargs: dict) -> None:
""" """
Функция для выполнения задачи в потоке. Функция для выполнения задачи в потоке.
@@ -66,25 +79,24 @@ class AsyncTaskManager:
args: Позиционные аргументы для функции. args: Позиционные аргументы для функции.
kwargs: Именованные аргументы для функции. kwargs: Именованные аргументы для функции.
""" """
try: with self._lock:
# Обновление статуса задачи
self.tasks[task_id]["status"] = "running" self.tasks[task_id]["status"] = "running"
self.tasks[task_id]["started_at"] = time.time() self.tasks[task_id]["started_at"] = time.time()
# Выполнение функции try:
result = func(*args, **kwargs) result = func(*args, **kwargs)
# Сохранение результата with self._lock:
self.tasks[task_id]["status"] = "completed" self.tasks[task_id]["status"] = "completed"
self.tasks[task_id]["result"] = result self.tasks[task_id]["result"] = result
self.tasks[task_id]["completed_at"] = time.time() self.tasks[task_id]["completed_at"] = time.time()
logger.info(f"Задача {task_id} завершена успешно") logger.info(f"Задача {task_id} завершена успешно")
except Exception as e: except Exception as e:
# Обработка ошибки with self._lock:
self.tasks[task_id]["status"] = "failed" self.tasks[task_id]["status"] = "failed"
self.tasks[task_id]["error"] = str(e) self.tasks[task_id]["error"] = str(e)
self.tasks[task_id]["completed_at"] = time.time() self.tasks[task_id]["completed_at"] = time.time()
logger.error(f"Задача {task_id} завершилась с ошибкой: {e}") logger.error(f"Задача {task_id} завершилась с ошибкой: {e}")
@@ -98,7 +110,9 @@ class AsyncTaskManager:
Returns: Returns:
Информация о задаче или None, если задача не найдена. Информация о задаче или None, если задача не найдена.
""" """
return self.tasks.get(task_id) with self._lock:
task = self.tasks.get(task_id)
return dict(task) if task else None
# Глобальный экземпляр менеджера асинхронных задач # Глобальный экземпляр менеджера асинхронных задач
+2 -34
View File
@@ -1,10 +1,10 @@
""" """
Модуль validators.py содержит классы и функции для валидации входных данных. Модуль validation.py содержит классы и функции для валидации входных данных.
""" """
import os import os
import magic import magic
from typing import Dict, List, BinaryIO, Optional from typing import Dict, List, BinaryIO
import logging import logging
# Получаем логгер из централизованной настройки # Получаем логгер из централизованной настройки
@@ -186,35 +186,3 @@ class FileValidator:
return True return True
@staticmethod
def validate_local_file_path(file_path: str, allowed_directories: Optional[List[str]] = None) -> str:
"""
Валидирует путь к локальному файлу для предотвращения атак обхода пути.
Args:
file_path: Путь к файлу.
allowed_directories: Список разрешенных директорий.
Returns:
Нормализованный и проверенный путь к файлу.
Raises:
ValidationError: Если путь к файлу небезопасен.
"""
# Нормализация пути
normalized_path = os.path.normpath(file_path)
# Если указаны разрешенные директории, проверяем, что путь находится в одной из них
if allowed_directories:
for allowed_dir in allowed_directories:
full_allowed_path = os.path.abspath(allowed_dir)
full_file_path = os.path.abspath(os.path.join(full_allowed_path, normalized_path))
if full_file_path.startswith(full_allowed_path):
return full_file_path
logger.warning(f"Попытка доступа к файлу вне разрешенных директорий: {file_path}")
raise ValidationError("Путь к файлу не находится в разрешенных директориях")
# Если разрешенные директории не указаны, просто возвращаем нормализованный путь
return normalized_path
+16 -38
View File
@@ -9,8 +9,9 @@ from typing import Dict
import logging import logging
from .core.transcription_service import TranscriptionService from .core.transcription_service import TranscriptionService
from .audio.sources import get_uploaded_file, get_url_file, get_base64_file, get_local_file from .audio.sources import get_uploaded_file, get_url_file, get_base64_file
from .infrastructure.validation import ValidationError from .infrastructure.validation import ValidationError
from .infrastructure.storage import cleanup_temp_files
from .infrastructure.async_tasks import transcribe_audio_async, task_manager from .infrastructure.async_tasks import transcribe_audio_async, task_manager
logger = logging.getLogger('app.routes') logger = logging.getLogger('app.routes')
@@ -43,33 +44,6 @@ class Routes:
"""Эндпоинт для получения конфигурации сервиса.""" """Эндпоинт для получения конфигурации сервиса."""
return jsonify(self.config), 200 return jsonify(self.config), 200
@self.app.route('/local/transcriptions', methods=['POST'])
def local_transcribe():
"""Эндпоинт для локальной транскрибации файла по пути на сервере."""
data = request.json
if not data or "file_path" not in data:
return jsonify({"error": "No file_path provided"}), 400
file_path = data["file_path"]
try:
validated_path = self.file_validator.validate_local_file_path(
file_path,
allowed_directories=self.config.get("allowed_directories", [])
)
except ValidationError as e:
client_ip = request.environ.get('HTTP_X_FORWARDED_FOR', request.environ.get('REMOTE_ADDR', 'unknown'))
logger.warning(f"Невалидный путь '{file_path}' от {client_ip}: {e}")
return jsonify({"error": str(e)}), 400
temp_path, filename, error = get_local_file(validated_path, self._max_size)
if error:
return jsonify({"error": error}), 400
response, status_code = self.transcription_service.transcribe(temp_path, filename, data)
return jsonify(response), status_code
@self.app.route('/v1/models', methods=['GET']) @self.app.route('/v1/models', methods=['GET'])
def list_models(): def list_models():
"""Эндпоинт для получения списка доступных моделей.""" """Эндпоинт для получения списка доступных моделей."""
@@ -105,15 +79,15 @@ class Routes:
if error: if error:
return jsonify({"error": error}), 400 return jsonify({"error": error}), 400
# Валидация файла
try: try:
self.file_validator.validate_file_by_path(temp_path, filename) self.file_validator.validate_file_by_path(temp_path, filename)
response, status_code = self.transcription_service.transcribe(temp_path, filename, dict(request.form))
return jsonify(response), status_code
except ValidationError as e: except ValidationError as e:
logger.warning(f"Ошибка валидации файла '{filename}': {e}") logger.warning(f"Ошибка валидации файла '{filename}': {e}")
return jsonify({"error": str(e)}), 400 return jsonify({"error": str(e)}), 400
finally:
response, status_code = self.transcription_service.transcribe(temp_path, filename, dict(request.form)) cleanup_temp_files([temp_path])
return jsonify(response), status_code
@self.app.route('/v1/audio/transcriptions/url', methods=['POST']) @self.app.route('/v1/audio/transcriptions/url', methods=['POST'])
def transcribe_from_url(): def transcribe_from_url():
@@ -135,12 +109,13 @@ class Routes:
try: try:
self.file_validator.validate_file_by_path(temp_path, filename) self.file_validator.validate_file_by_path(temp_path, filename)
response, status_code = self.transcription_service.transcribe(temp_path, filename, params)
return jsonify(response), status_code
except ValidationError as e: except ValidationError as e:
logger.warning(f"Ошибка валидации файла '{filename}': {e}") logger.warning(f"Ошибка валидации файла '{filename}': {e}")
return jsonify({"error": str(e)}), 400 return jsonify({"error": str(e)}), 400
finally:
response, status_code = self.transcription_service.transcribe(temp_path, filename, params) cleanup_temp_files([temp_path])
return jsonify(response), status_code
@self.app.route('/v1/audio/transcriptions/base64', methods=['POST']) @self.app.route('/v1/audio/transcriptions/base64', methods=['POST'])
def transcribe_from_base64(): def transcribe_from_base64():
@@ -162,12 +137,13 @@ class Routes:
try: try:
self.file_validator.validate_file_by_path(temp_path, filename) self.file_validator.validate_file_by_path(temp_path, filename)
response, status_code = self.transcription_service.transcribe(temp_path, filename, params)
return jsonify(response), status_code
except ValidationError as e: except ValidationError as e:
logger.warning(f"Ошибка валидации файла '{filename}': {e}") logger.warning(f"Ошибка валидации файла '{filename}': {e}")
return jsonify({"error": str(e)}), 400 return jsonify({"error": str(e)}), 400
finally:
response, status_code = self.transcription_service.transcribe(temp_path, filename, params) cleanup_temp_files([temp_path])
return jsonify(response), status_code
@self.app.route('/v1/audio/transcriptions/async', methods=['POST']) @self.app.route('/v1/audio/transcriptions/async', methods=['POST'])
def transcribe_async(): def transcribe_async():
@@ -179,8 +155,10 @@ class Routes:
try: try:
self.file_validator.validate_file_by_path(temp_path, filename) self.file_validator.validate_file_by_path(temp_path, filename)
except ValidationError as e: except ValidationError as e:
cleanup_temp_files([temp_path])
return jsonify({"error": str(e)}), 400 return jsonify({"error": str(e)}), 400
# Не чистим temp_path здесь — async task отвечает за cleanup
task_id = transcribe_audio_async(temp_path, self.transcription_service.transcriber) task_id = transcribe_audio_async(temp_path, self.transcription_service.transcriber)
return jsonify({"task_id": task_id}), 202 return jsonify({"task_id": task_id}), 202
+1 -2
View File
@@ -8,7 +8,7 @@
"max_new_tokens": 384, "max_new_tokens": 384,
"temperature": 0.01, "temperature": 0.01,
"return_timestamps": false, "return_timestamps": false,
"audio_rate": 8000, "audio_rate": 16000,
"norm_level": "-0.55", "norm_level": "-0.55",
"compand_params": "0.3,1 -90,-90,-70,-50,-40,-15,0,0 -7 0 0.15", "compand_params": "0.3,1 -90,-90,-70,-50,-40,-15,0,0 -7 0 0.15",
"device_id": 0, "device_id": 0,
@@ -17,7 +17,6 @@
"allowed_extensions": [".wav", ".mp3", ".ogg", ".flac", ".m4a", ".oga", ".aac", ".webm"], "allowed_extensions": [".wav", ".mp3", ".ogg", ".flac", ".m4a", ".oga", ".aac", ".webm"],
"allowed_mime_types": ["audio/wav", "audio/mpeg", "audio/ogg", "audio/flac", "audio/mp4", "audio/x-m4a", "audio/aac", "audio/webm"] "allowed_mime_types": ["audio/wav", "audio/mpeg", "audio/ogg", "audio/flac", "audio/mp4", "audio/x-m4a", "audio/aac", "audio/webm"]
}, },
"allowed_directories": [],
"log_level": "INFO", "log_level": "INFO",
"log_file": "logs/whisper_api.log", "log_file": "logs/whisper_api.log",
"request_logging": { "request_logging": {
+1 -1
View File
@@ -1,6 +1,6 @@
# Conda environment dependencies # Conda environment dependencies
# Tourch # Torch
torch @ https://download.pytorch.org/whl/cu128/torch-2.7.0%2Bcu128-cp313-cp313-manylinux_2_28_x86_64.whl ; platform_system == 'Linux' and platform_machine == 'x86_64' and python_version == '3.13' torch @ https://download.pytorch.org/whl/cu128/torch-2.7.0%2Bcu128-cp313-cp313-manylinux_2_28_x86_64.whl ; platform_system == 'Linux' and platform_machine == 'x86_64' and python_version == '3.13'
torch @ https://download.pytorch.org/whl/cu128/torch-2.7.0%2Bcu128-cp312-cp312-manylinux_2_28_x86_64.whl ; platform_system == 'Linux' and platform_machine == 'x86_64' and python_version == '3.12' torch @ https://download.pytorch.org/whl/cu128/torch-2.7.0%2Bcu128-cp312-cp312-manylinux_2_28_x86_64.whl ; platform_system == 'Linux' and platform_machine == 'x86_64' and python_version == '3.12'
torch @ https://download.pytorch.org/whl/cu128/torch-2.7.0%2Bcu128-cp311-cp311-manylinux_2_28_x86_64.whl ; platform_system == 'Linux' and platform_machine == 'x86_64' and python_version == '3.11' torch @ https://download.pytorch.org/whl/cu128/torch-2.7.0%2Bcu128-cp311-cp311-manylinux_2_28_x86_64.whl ; platform_system == 'Linux' and platform_machine == 'x86_64' and python_version == '3.11'