Compare commits
16 Commits
e31440f91e
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 4dca198d3c | |||
| fe0bed7f58 | |||
| 94633288c3 | |||
| 15925c0217 | |||
| e161681bc9 | |||
| 5360ea7f9c | |||
| d460728b2e | |||
| 8e1175c9bf | |||
| 014271e929 | |||
| ae4cfa0119 | |||
| db62f9ef74 | |||
| bf507b5363 | |||
| 9fc60e8d19 | |||
| f3e3da1696 | |||
| b8da32f44a | |||
| 688417990f |
@@ -1,3 +1,226 @@
|
||||
# FileAudioAPI
|
||||
|
||||
Сервис для хранения файлов аудио, индексации файлов, записи и выдачи результатов распознавания
|
||||
## Назначение и роль в экосистеме
|
||||
|
||||
**FileAudioAPI** — сервис **учёта аудио и результатов распознавания** между диском, GPU-распознаванием и веб-клиентом. На **FastAPI**: хранит метаданные и статусы в БД, складывает файлы в общий каталог **`AUDIOFILES_PATH`** (тот же volume `audiofiles`, что у **GigaAM_API** и **Calls_WEB_Client_main**), инициирует ASR в **GigaAM_API**, принимает обратно текст/заключение, при необходимости дергает webhook в **Calls_WEB_Client_main**.
|
||||
|
||||
| Направление | Роль |
|
||||
|-------------|------|
|
||||
| **Внутренние файлы** | Регистрация, очереди `process-all`, статусы `recognition_*`, связь с `AiConclusion`. |
|
||||
| **Внешние файлы** | Загрузка с опциональным `callback_url`, сохранение под UUID-именем в общей папке, **всегда** постановка на распознавание в GigaAM. |
|
||||
| **Интеграция** | `GIGAAM_API_URL`, `CALLS_WEB_CLIENT_URL` / `WEBHOOK_API_KEY` для доставки результатов в основное приложение. |
|
||||
|
||||
Swagger: **`/api/v1/docs`** (см. ниже).
|
||||
|
||||
---
|
||||
|
||||
Сервис для хранения аудиофайлов, индексации файлов, записи и выдачи результатов распознавания, реализованный на **FastAPI**.
|
||||
|
||||
## 🚀 Быстрый старт
|
||||
|
||||
```bash
|
||||
# Перейти в директорию сервиса (от корня репозитория SpeechAnalytics)
|
||||
cd FileAudioAPI
|
||||
|
||||
# Запуск с Docker (рекомендуется)
|
||||
docker-compose up -d
|
||||
|
||||
# Или использовать Makefile
|
||||
make up
|
||||
```
|
||||
|
||||
Приложение будет доступно по адресу: **http://localhost:8000**
|
||||
|
||||
API документация:
|
||||
- 📚 **Swagger UI**: http://localhost:8000/api/v1/docs
|
||||
- 📖 **ReDoc**: http://localhost:8000/api/v1/redoc
|
||||
|
||||
## 📚 Документация
|
||||
|
||||
- 📘 [Quick Start Guide](START.md) - Быстрый старт
|
||||
- 📗 [API Documentation](README_FASTAPI.md) - Полная документация API
|
||||
- 📕 [Docker Deployment](README_DOCKER.md) - Развертывание в Docker
|
||||
|
||||
## 🎯 Возможности
|
||||
|
||||
- ✅ Загрузка аудиофайлов (MP3, WAV, OGG, FLAC, M4A, AAC)
|
||||
- ✅ Хранение файлов с автоматической индексацией
|
||||
- ✅ Асинхронное распознавание аудио в фоне
|
||||
- ✅ Отслеживание статуса распознавания
|
||||
- ✅ Получение результатов распознавания
|
||||
- ✅ RESTful API с автоматической документацией
|
||||
- ✅ Валидация данных с Pydantic
|
||||
- ✅ Docker поддержка
|
||||
|
||||
## 🏗️ Архитектура
|
||||
|
||||
```
|
||||
apiApp/
|
||||
├── schemas/ # Pydantic модели (валидация)
|
||||
├── services/ # Business logic (CRUD операции)
|
||||
├── routers/ # API endpoints (контроллеры)
|
||||
├── database/ # SQLAlchemy модели
|
||||
├── dbApi/ # Legacy compatibility layer
|
||||
├── config.py # Конфигурация
|
||||
└── database.py # SQLAlchemy настройка
|
||||
```
|
||||
|
||||
## 🔧 Технологии
|
||||
|
||||
- **FastAPI** - Современный веб-фреймворк
|
||||
- **SQLAlchemy 2.0** - ORM для работы с БД
|
||||
- **Pydantic** - Валидация данных
|
||||
- **Uvicorn** - ASGI сервер
|
||||
- **Docker** - Контейнеризация
|
||||
|
||||
## 📋 Основные endpoints
|
||||
|
||||
| Метод | Endpoint | Описание |
|
||||
|-------|----------|----------|
|
||||
| POST | `/api/v1/upload` | Загрузка аудиофайла |
|
||||
| GET | `/api/v1/audio/list` | Список всех файлов |
|
||||
| GET | `/api/v1/audio/{id}` | Информация о файле |
|
||||
| GET | `/api/v1/audio/file/{id}` | Скачать файл |
|
||||
| DELETE | `/api/v1/audio/delete/{id}` | Удалить файл |
|
||||
| POST | `/api/v1/recognize/{id}` | Запустить распознавание |
|
||||
| GET | `/api/v1/recognize/{id}` | Статус распознавания |
|
||||
| GET | `/api/v1/recognize/task/{task_id}` | Статус по task_id |
|
||||
| GET | `/api/v1/recognize/{id}/result` | Результат распознавания |
|
||||
|
||||
## 🛠️ Установка и запуск
|
||||
|
||||
### С Docker (рекомендуется)
|
||||
|
||||
```bash
|
||||
# Использование Makefile
|
||||
make up
|
||||
|
||||
# Или напрямую с Docker Compose
|
||||
docker-compose up -d
|
||||
|
||||
# Просмотр логов
|
||||
make logs
|
||||
# или
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
### Локально
|
||||
|
||||
```bash
|
||||
# Установка зависимостей
|
||||
make install
|
||||
# или
|
||||
pip3 install -r requirements.txt
|
||||
|
||||
# Создание базы данных
|
||||
make db-migrate
|
||||
|
||||
# Запуск
|
||||
make dev
|
||||
# или
|
||||
python3 run.py
|
||||
```
|
||||
|
||||
## 🔧 Makefile команды
|
||||
|
||||
```bash
|
||||
make help # Показать все команды
|
||||
make build # Собрать Docker образ
|
||||
make up # Запустить контейнеры
|
||||
make down # Остановить контейнеры
|
||||
make restart # Перезапустить
|
||||
make logs # Логи
|
||||
make shell # Shell в контейнере
|
||||
make test # Запуск тестов
|
||||
make clean # Очистка
|
||||
```
|
||||
|
||||
## 📝 Примеры использования
|
||||
|
||||
### Загрузка файла
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/v1/upload \
|
||||
-F "file=@audio.mp3"
|
||||
```
|
||||
|
||||
### Запуск распознавания
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/v1/recognize/{audio_id}
|
||||
```
|
||||
|
||||
### Получение статуса
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/api/v1/recognize/{audio_id}
|
||||
```
|
||||
|
||||
### Получение результата
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/api/v1/recognize/{audio_id}/result
|
||||
```
|
||||
|
||||
## 🔒 Production
|
||||
|
||||
Для production развертывания:
|
||||
|
||||
1. Используйте PostgreSQL вместо SQLite
|
||||
2. Настройте Redis для хранения статусов задач
|
||||
3. Ограничьте CORS origins
|
||||
4. Добавьте аутентификацию
|
||||
5. Настройте SSL/HTTPS
|
||||
6. Используйте несколько workers
|
||||
|
||||
Подробнее в [Docker Deployment Guide](README_DOCKER.md)
|
||||
|
||||
## 🧪 Тестирование
|
||||
|
||||
```bash
|
||||
# Запуск тестов
|
||||
make test
|
||||
|
||||
# Или напрямую
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
## 📊 Мониторинг
|
||||
|
||||
```bash
|
||||
# Health check
|
||||
curl http://localhost:8000/health
|
||||
|
||||
# Docker stats
|
||||
docker stats file-audio-api
|
||||
|
||||
# Логи
|
||||
docker-compose logs -f app
|
||||
```
|
||||
|
||||
## 🤝 Рефакторинг
|
||||
|
||||
Проект был успешно рефакторин из Flask в FastAPI с улучшением архитектуры:
|
||||
|
||||
**Основные изменения:**
|
||||
- ✅ Многослойная архитектура (schemas → services → routers)
|
||||
- ✅ Разделение по доменам
|
||||
- ✅ Асинхронная обработка задач
|
||||
- ✅ Автодокументация API
|
||||
- ✅ Type hints и валидация
|
||||
- ✅ Docker поддержка
|
||||
|
||||
## 📄 Лицензия
|
||||
|
||||
[MIT License](LICENSE)
|
||||
|
||||
## 👨💻 Автор
|
||||
|
||||
Speech Analytics Team
|
||||
|
||||
---
|
||||
|
||||
📖 Более подробная информация в документации:
|
||||
- [Quick Start](START.md)
|
||||
- [API Docs](README_FASTAPI.md)
|
||||
- [Docker Guide](README_DOCKER.md)
|
||||
|
||||
+5
-2
@@ -32,7 +32,7 @@ AUDIOFILES_PATH = os.path.join(os.getcwd(), os.getenv("FILESAPTH", "audiofiles")
|
||||
# Calls_WEB_Client_main Webhook Configuration
|
||||
CALLS_WEB_CLIENT_URL = os.getenv(
|
||||
"CALLS_WEB_CLIENT_URL",
|
||||
"http://192.168.1.73:5059"
|
||||
"http://192.168.1.73:8642"
|
||||
)
|
||||
WEBHOOK_ENDPOINT = f"{CALLS_WEB_CLIENT_URL}/api/transcription/webhook"
|
||||
WEBHOOK_API_KEY = os.getenv("WEBHOOK_API_KEY", "webhook_secret_key")
|
||||
@@ -40,4 +40,7 @@ WEBHOOK_API_KEY = os.getenv("WEBHOOK_API_KEY", "webhook_secret_key")
|
||||
# Auto-restore recognition on startup
|
||||
ENABLE_AUTO_RESTORE = os.getenv("ENABLE_AUTO_RESTORE", "true").lower() == "true"
|
||||
AUTO_RESTORE_LIMIT = int(os.getenv("AUTO_RESTORE_LIMIT", "100")) # Максимум файлов для восстановления
|
||||
AUTO_RESTORE_DELAY = int(os.getenv("AUTO_RESTORE_DELAY", "5")) # Задержка перед запуском (секунды)
|
||||
AUTO_RESTORE_DELAY = int(os.getenv("AUTO_RESTORE_DELAY", "5")) # Задержка перед запуском (секунды)
|
||||
|
||||
# Recognition retry policy (FileAudioAPI side)
|
||||
MAX_RECOGNITION_ATTEMPTS = int(os.getenv("MAX_RECOGNITION_ATTEMPTS", "3"))
|
||||
@@ -14,6 +14,11 @@ class Audio(Base):
|
||||
file_path = Column(Text)
|
||||
duration = Column(Float)
|
||||
file_size = Column(Integer)
|
||||
sourse = Column(Text, default="internal")
|
||||
recognition_status = Column(Text, default="pending", index=True) # pending, processing, completed, failed
|
||||
recognition_attempts = Column(Integer, default=0)
|
||||
recognition_last_error = Column(Text, nullable=True)
|
||||
recognition_last_attempt_at = Column(DateTime, nullable=True)
|
||||
|
||||
ai_conclusion = relationship("AiConclusion", back_populates="audio", cascade="all, delete-orphan")
|
||||
|
||||
@@ -24,5 +29,10 @@ class Audio(Base):
|
||||
"index_date": self.index_date.isoformat() if self.index_date else None,
|
||||
"file_path": self.file_path,
|
||||
"duration": self.duration,
|
||||
"file_size": self.file_size
|
||||
"file_size": self.file_size,
|
||||
"sourse": self.sourse,
|
||||
"recognition_status": self.recognition_status,
|
||||
"recognition_attempts": self.recognition_attempts,
|
||||
"recognition_last_error": self.recognition_last_error,
|
||||
"recognition_last_attempt_at": self.recognition_last_attempt_at.isoformat() if self.recognition_last_attempt_at else None,
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
from apiApp.routers.audio import router as audio_router
|
||||
from apiApp.routers.recognition import router as recognition_router
|
||||
|
||||
__all__ = ["audio_router", "recognition_router"]
|
||||
from apiApp.routers.external_audio import router as external_audio_router
|
||||
__all__ = ["audio_router", "recognition_router", "external_audio_router"]
|
||||
|
||||
@@ -16,6 +16,31 @@ from apiApp.config import WEBHOOK_ENDPOINT, WEBHOOK_API_KEY
|
||||
logger = logging.getLogger(__name__)
|
||||
ai_conclusion_router = APIRouter()
|
||||
|
||||
def _send_callback(callback_url: str, audio: Audio, conclusion_data: Dict[str, Any]) -> None:
|
||||
"""Отправляем результат клиенту по callback_url (не храним в БД)."""
|
||||
try:
|
||||
callback_url = (callback_url or "").strip()
|
||||
if not callback_url:
|
||||
return
|
||||
if not callback_url.startswith(("http://", "https://")):
|
||||
logger.warning(f"⚠️ Некорректный callback_url для {audio.filename}: {callback_url}")
|
||||
return
|
||||
|
||||
payload = {
|
||||
"audio_id": str(audio.id),
|
||||
"filename": audio.filename,
|
||||
"result": conclusion_data
|
||||
}
|
||||
|
||||
resp = requests.post(callback_url, json=payload, timeout=30)
|
||||
if 200 <= resp.status_code < 300:
|
||||
logger.info(f"✅ Callback успешно отправлен для {audio.filename}")
|
||||
else:
|
||||
logger.warning(f"⚠️ Callback вернул статус {resp.status_code} для {audio.filename}")
|
||||
logger.warning(f"Response: {resp.text}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при отправке callback для {audio.filename}: {e}")
|
||||
|
||||
|
||||
class AiConclusionRequest(BaseModel):
|
||||
"""Модель запроса для сохранения AI заключения"""
|
||||
@@ -25,6 +50,7 @@ class AiConclusionRequest(BaseModel):
|
||||
analysis: Dict[str, Any]
|
||||
segments: Optional[List[Dict[str, Any]]] = []
|
||||
processing_time_seconds: Optional[float] = 0
|
||||
callback_url: Optional[str] = None
|
||||
|
||||
|
||||
class AiConclusionResponse(BaseModel):
|
||||
@@ -36,6 +62,46 @@ class AiConclusionResponse(BaseModel):
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class RecognitionFailedRequest(BaseModel):
|
||||
filename: str
|
||||
error: str
|
||||
|
||||
|
||||
class ConclusionByFilenameResponse(BaseModel):
|
||||
"""Заключение по имени файла"""
|
||||
filename: str
|
||||
audio_id: str
|
||||
conclusion: Dict[str, Any]
|
||||
index_date: Optional[datetime] = None
|
||||
end_date: Optional[datetime] = None
|
||||
|
||||
|
||||
@ai_conclusion_router.get("/conclusion/by-filename/{filename}", response_model=ConclusionByFilenameResponse)
|
||||
async def get_conclusion_by_filename(filename: str, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Возвращает заключение по распознаванию по имени файла.
|
||||
Имя файла задаётся в path (то же, что сохранено в БД при загрузке).
|
||||
"""
|
||||
audio = db.query(Audio).filter(Audio.filename == filename).first()
|
||||
if not audio:
|
||||
raise HTTPException(status_code=404, detail=f"Файл не найден: {filename}")
|
||||
|
||||
conclusion_row = db.query(AiConclusion).filter(AiConclusion.audio_id == audio.id).first()
|
||||
if not conclusion_row:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Заключение по распознаванию для файла не найдено: {filename}"
|
||||
)
|
||||
|
||||
return ConclusionByFilenameResponse(
|
||||
filename=audio.filename,
|
||||
audio_id=str(audio.id),
|
||||
conclusion=conclusion_row.conclusion or {},
|
||||
index_date=conclusion_row.index_date,
|
||||
end_date=conclusion_row.end_date,
|
||||
)
|
||||
|
||||
|
||||
@ai_conclusion_router.post("/conclusion/save", response_model=AiConclusionResponse)
|
||||
async def save_ai_conclusion(request: AiConclusionRequest, db: Session = Depends(get_db)):
|
||||
"""
|
||||
@@ -93,31 +159,41 @@ async def save_ai_conclusion(request: AiConclusionRequest, db: Session = Depends
|
||||
db.commit()
|
||||
logger.info(f"✅ Заключение сохранено для {request.filename}")
|
||||
|
||||
# Обновляем статус распознавания у Audio
|
||||
audio.recognition_status = "completed"
|
||||
audio.recognition_last_error = None
|
||||
db.commit()
|
||||
|
||||
# Для внешних файлов — отправляем результат клиенту из FileAudioAPI
|
||||
if (audio.sourse or "").lower() == "external" and request.callback_url:
|
||||
_send_callback(request.callback_url, audio, conclusion_data)
|
||||
|
||||
# Отправляем webhook в Calls_WEB_Client_main для анализа
|
||||
try:
|
||||
logger.info(f"📤 Отправка webhook в Calls_WEB_Client_main для {request.filename}")
|
||||
if (audio.sourse or "").lower() != "external":
|
||||
logger.info(f"📤 Отправка webhook в Calls_WEB_Client_main для {request.filename}")
|
||||
|
||||
webhook_payload = {
|
||||
"audio_id": str(audio.id),
|
||||
"filename": request.filename,
|
||||
"transcription": request.transcription,
|
||||
"corrected_transcription": request.corrected_transcription,
|
||||
"segments": request.segments,
|
||||
"processing_time_seconds": request.processing_time_seconds
|
||||
}
|
||||
webhook_payload = {
|
||||
"audio_id": str(audio.id),
|
||||
"filename": request.filename,
|
||||
"transcription": request.transcription,
|
||||
"corrected_transcription": request.corrected_transcription,
|
||||
"segments": request.segments,
|
||||
"processing_time_seconds": request.processing_time_seconds
|
||||
}
|
||||
|
||||
webhook_response = requests.post(
|
||||
WEBHOOK_ENDPOINT,
|
||||
json=webhook_payload,
|
||||
headers={"X-Webhook-Key": WEBHOOK_API_KEY},
|
||||
timeout=30
|
||||
)
|
||||
webhook_response = requests.post(
|
||||
WEBHOOK_ENDPOINT,
|
||||
json=webhook_payload,
|
||||
headers={"X-Webhook-Key": WEBHOOK_API_KEY},
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if webhook_response.status_code == 200:
|
||||
logger.info(f"✅ Webhook успешно отправлен для {request.filename}")
|
||||
else:
|
||||
logger.warning(f"⚠️ Webhook вернул статус {webhook_response.status_code}")
|
||||
logger.warning(f"Response: {webhook_response.text}")
|
||||
if webhook_response.status_code == 200:
|
||||
logger.info(f"✅ Webhook успешно отправлен для {request.filename}")
|
||||
else:
|
||||
logger.warning(f"⚠️ Webhook вернул статус {webhook_response.status_code}")
|
||||
logger.warning(f"Response: {webhook_response.text}")
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
logger.error(f"❌ Не удалось подключиться к Calls_WEB_Client_main webhook: {WEBHOOK_ENDPOINT}")
|
||||
@@ -142,3 +218,25 @@ async def save_ai_conclusion(request: AiConclusionRequest, db: Session = Depends
|
||||
status_code=500,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
|
||||
@ai_conclusion_router.post("/conclusion/failed", response_model=AiConclusionResponse)
|
||||
async def mark_recognition_failed(request: RecognitionFailedRequest, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Помечает распознавание как failed для файла (чтобы auto-restore не пытался бесконечно).
|
||||
Используется GigaAM_API при невозможности получить результат.
|
||||
"""
|
||||
audio = db.query(Audio).filter(Audio.filename == request.filename).first()
|
||||
if not audio:
|
||||
raise HTTPException(status_code=404, detail=f"Файл не найден: {request.filename}")
|
||||
|
||||
audio.recognition_status = "failed"
|
||||
audio.recognition_last_error = request.error
|
||||
db.commit()
|
||||
|
||||
return AiConclusionResponse(
|
||||
success=True,
|
||||
message="Recognition marked as failed",
|
||||
audio_id=str(audio.id),
|
||||
filename=audio.filename
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ from datetime import datetime
|
||||
from apiApp.database import get_db
|
||||
from apiApp.database.Audio import Audio
|
||||
from apiApp.database.AiConclusion import AiConclusion
|
||||
from apiApp.config import AUDIOFILES_PATH
|
||||
from apiApp.config import AUDIOFILES_PATH, MAX_RECOGNITION_ATTEMPTS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
audio_management_router = APIRouter()
|
||||
@@ -32,8 +32,14 @@ def query_audio_without_conclusion(db, limit=None):
|
||||
AiConclusion.audio_id == Audio.id
|
||||
)
|
||||
|
||||
# Берём только те, которые еще можно/нужно распознавать
|
||||
query = db.query(Audio).filter(
|
||||
~subquery
|
||||
).filter(
|
||||
Audio.sourse == "internal"
|
||||
).filter(
|
||||
(Audio.recognition_status.in_(["pending", "processing"])) |
|
||||
((Audio.recognition_status == "failed") & (Audio.recognition_attempts < MAX_RECOGNITION_ATTEMPTS))
|
||||
).order_by(Audio.index_date.asc())
|
||||
|
||||
if limit:
|
||||
@@ -110,6 +116,10 @@ async def register_audio_file(
|
||||
audio.filename = filename
|
||||
audio.file_size = file_size
|
||||
audio.index_date = datetime.utcnow()
|
||||
audio.recognition_status = "pending"
|
||||
audio.recognition_attempts = 0
|
||||
audio.recognition_last_error = None
|
||||
audio.recognition_last_attempt_at = None
|
||||
|
||||
db.add(audio)
|
||||
db.commit()
|
||||
@@ -148,6 +158,11 @@ def process_audio_file(audio_id: str, db: Session):
|
||||
return
|
||||
|
||||
logger.info(f"🎵 Запуск распознавания для {audio.filename}")
|
||||
audio.recognition_status = "processing"
|
||||
audio.recognition_attempts = (audio.recognition_attempts or 0) + 1
|
||||
audio.recognition_last_attempt_at = datetime.utcnow()
|
||||
audio.recognition_last_error = None
|
||||
db.commit()
|
||||
|
||||
# Проверяем что файл существует на диске
|
||||
from apiApp.config import AUDIOFILES_PATH
|
||||
@@ -156,7 +171,9 @@ def process_audio_file(audio_id: str, db: Session):
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
logger.error(f"❌ Файл не найден на диске в FileAudioAPI: {file_path}")
|
||||
# Помечаем audio как проблемный
|
||||
audio.recognition_status = "failed"
|
||||
audio.recognition_last_error = f"File not found on disk: {file_path}"
|
||||
db.commit()
|
||||
return
|
||||
|
||||
file_size = os.path.getsize(file_path)
|
||||
@@ -185,11 +202,20 @@ def process_audio_file(audio_id: str, db: Session):
|
||||
error_detail = response.text
|
||||
logger.error(f"❌ Ошибка запуска распознавания для {audio.filename}: {response.status_code}")
|
||||
logger.error(f" Detail: {error_detail}")
|
||||
audio.recognition_status = "failed"
|
||||
audio.recognition_last_error = f"GigaAM start failed: {response.status_code} {error_detail}"
|
||||
db.commit()
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
logger.error(f"❌ Таймаут при отправке задачи для {audio.filename}")
|
||||
audio.recognition_status = "failed"
|
||||
audio.recognition_last_error = "Timeout when starting recognition in GigaAM"
|
||||
db.commit()
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
logger.error(f"❌ Ошибка подключения к GigaAM API для {audio.filename}: {e}")
|
||||
audio.recognition_status = "failed"
|
||||
audio.recognition_last_error = f"Connection error when starting recognition in GigaAM: {e}"
|
||||
db.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при обработке {audio_id}: {e}")
|
||||
|
||||
@@ -1,393 +0,0 @@
|
||||
"""
|
||||
API endpoints для управления аудиофайлами (регистрация и пакетная обработка)
|
||||
Используется Calls_WEB_Client_main для оркестрации процесса распознавания
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Depends, BackgroundTasks
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
import os
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from apiApp.database import get_db
|
||||
from apiApp.database.Audio import Audio
|
||||
from apiApp.config import AUDIOFILES_PATH
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
audio_management_router = APIRouter()
|
||||
|
||||
|
||||
class AudioRegisterRequest(BaseModel):
|
||||
"""Запрос на регистрацию аудиофайла"""
|
||||
filename: str
|
||||
file_path: str # Полный путь к файлу в общей папке audiofiles
|
||||
|
||||
|
||||
class AudioProcessAllRequest(BaseModel):
|
||||
"""Запрос на пакетное распознавание"""
|
||||
limit: int = 100
|
||||
|
||||
|
||||
class AudioRegisterResponse(BaseModel):
|
||||
"""Ответ на регистрацию аудиофайла"""
|
||||
id: str
|
||||
filename: str
|
||||
file_size: int
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@audio_management_router.post("/audio/register", response_model=AudioRegisterResponse, status_code=201)
|
||||
async def register_audio_file(
|
||||
request: AudioRegisterRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Регистрация аудиофайла в БД (без копирования файла)
|
||||
|
||||
Создаёт запись в таблице Audio для файла, который уже находится
|
||||
в общей папке audiofiles. НЕ копирует файл, только создаёт запись в БД.
|
||||
|
||||
Args:
|
||||
request: {filename: "in-xxx.wav", file_path: "/app/audiofiles/in-xxx.wav"}
|
||||
|
||||
Returns:
|
||||
201 Created + информация о созданной записи
|
||||
400 Bad Request если файл уже зарегистрирован
|
||||
404 Not Found если файл не существует на диске
|
||||
"""
|
||||
try:
|
||||
filename = request.filename
|
||||
file_path = request.file_path
|
||||
|
||||
logger.info(f"📝 Регистрация файла: {filename}")
|
||||
|
||||
# Проверяем, что файл уже существует в общей папке
|
||||
if not os.path.exists(file_path):
|
||||
logger.error(f"❌ Файл не найден: {file_path}")
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f'Файл не найден на диске: {file_path}'
|
||||
)
|
||||
|
||||
# Проверяем, что файл не был уже зарегистрирован
|
||||
existing_audio = db.query(Audio).filter(Audio.filename == filename).first()
|
||||
if existing_audio:
|
||||
logger.warning(f"⚠️ Файл уже зарегистрирован: {filename}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f'Файл {filename} уже зарегистрирован в БД'
|
||||
)
|
||||
|
||||
# Получаем размер файла
|
||||
file_size = os.path.getsize(file_path)
|
||||
|
||||
# Создаём запись в БД
|
||||
audio = Audio()
|
||||
audio.filename = filename
|
||||
audio.file_size = file_size
|
||||
audio.index_date = datetime.utcnow()
|
||||
|
||||
db.add(audio)
|
||||
db.commit()
|
||||
db.refresh(audio)
|
||||
|
||||
logger.info(f"✅ Файл зарегистрирован: {filename} (audio_id={audio.id})")
|
||||
|
||||
return AudioRegisterResponse(
|
||||
id=str(audio.id),
|
||||
filename=audio.filename,
|
||||
file_size=audio.file_size,
|
||||
created_at=audio.index_date
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"❌ Ошибка при регистрации файла: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f'Ошибка при регистрации: {str(e)}'
|
||||
)
|
||||
|
||||
|
||||
def process_audio_file(audio_id: str, db: Session):
|
||||
"""
|
||||
Фоновая обработка одного аудиофайла
|
||||
|
||||
Отправляет запрос в GigaAM API для распознавания
|
||||
"""
|
||||
try:
|
||||
audio = db.query(Audio).filter(Audio.id == audio_id).first()
|
||||
if not audio:
|
||||
logger.error(f"❌ Audio {audio_id} не найден")
|
||||
return
|
||||
|
||||
logger.info(f"🎵 Запуск распознавания для {audio.filename}")
|
||||
|
||||
# Формируем запрос в GigaAM API
|
||||
from apiApp.config import GIGAAM_API_URL
|
||||
api_url = f"{GIGAAM_API_URL}/api/call/process"
|
||||
|
||||
payload = {
|
||||
"filename": audio.filename
|
||||
}
|
||||
|
||||
# Отправляем запрос в GigaAM API
|
||||
import requests
|
||||
response = requests.post(api_url, json=payload, timeout=10)
|
||||
|
||||
if response.status_code == 200 or response.status_code == 202:
|
||||
logger.info(f"✅ Запущено распознавание для {audio.filename}")
|
||||
else:
|
||||
logger.error(f"❌ Ошибка запуска распознавания для {audio.filename}: {response.status_code} - {response.text}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при обработке {audio_id}: {e}")
|
||||
|
||||
|
||||
@audio_management_router.post("/audio/process-all")
|
||||
async def process_all_pending_audio(
|
||||
request: AudioProcessAllRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Запуск распознавания для всех Audio без AiConclusion
|
||||
|
||||
Находит все записи Audio, у которых нет AiConclusion, и запускает
|
||||
распознавание для них (до указанного лимита).
|
||||
|
||||
Args:
|
||||
request: {limit: 100} - максимум файлов для обработки
|
||||
|
||||
Returns:
|
||||
200 OK + {
|
||||
"started_count": 15,
|
||||
"pending_files": ["file1.wav", "file2.wav", ...],
|
||||
"total_pending": 50
|
||||
}
|
||||
"""
|
||||
try:
|
||||
limit = request.limit
|
||||
|
||||
logger.info(f"🚀 Поиск Audio без AiConclusion (limit={limit})")
|
||||
|
||||
# Находим все Audio без AiConclusion
|
||||
pending_audio = db.query(Audio).filter(
|
||||
Audio.AiConclusion == None
|
||||
).order_by(Audio.index_date.asc()).limit(limit).all()
|
||||
|
||||
total_pending = db.query(Audio).filter(Audio.AiConclusion == None).count()
|
||||
|
||||
if not pending_audio:
|
||||
logger.info("ℹ️ Нет файлов для распознавания")
|
||||
return {
|
||||
"started_count": 0,
|
||||
"pending_files": [],
|
||||
"total_pending": 0,
|
||||
"message": "Нет файлов без AiConclusion"
|
||||
}
|
||||
|
||||
logger.info(f"📋 Найдено файлов для обработки: {len(pending_audio)} из {total_pending}")
|
||||
|
||||
# Добавляем задачи в фон
|
||||
started_count = 0
|
||||
pending_files = []
|
||||
|
||||
for audio in pending_audio:
|
||||
# Проверяем, что файл существует
|
||||
file_path = os.path.join(AUDIOFILES_PATH, audio.filename)
|
||||
if not os.path.exists(file_path):
|
||||
logger.warning(f"⚠️ Файл не найден на диске: {audio.filename}")
|
||||
continue
|
||||
|
||||
# Добавляем в фон (асинхронно)
|
||||
# В FastAPI используем BackgroundTasks
|
||||
# Но нужно создавать новую сессию для каждого таска
|
||||
pending_files.append(audio.filename)
|
||||
started_count += 1
|
||||
|
||||
# Запускаем обработку в фоне
|
||||
# Используем lambda для захвата audio_id
|
||||
background_tasks.add_task(
|
||||
process_single_audio,
|
||||
str(audio.id)
|
||||
)
|
||||
|
||||
logger.info(f"✅ Запущено распознавание для {started_count} файлов")
|
||||
|
||||
return {
|
||||
"started_count": started_count,
|
||||
"pending_files": pending_files,
|
||||
"total_pending": total_pending
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при запуске пакетного распознавания: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f'Ошибка: {str(e)}'
|
||||
)
|
||||
|
||||
|
||||
def process_single_audio(audio_id: str):
|
||||
"""
|
||||
Обработка одного аудиофайла в фоне
|
||||
|
||||
Создаёт новую DB сессию для обработки
|
||||
"""
|
||||
from apiApp.database import SessionLocal
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
process_audio_file(audio_id, db)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@audio_management_router.get("/audio/pending")
|
||||
async def get_pending_audio(
|
||||
db: Session = Depends(get_db),
|
||||
limit: int = 100
|
||||
):
|
||||
"""
|
||||
Получить список Audio без AiConclusion
|
||||
|
||||
Query Parameters:
|
||||
limit: Максимум файлов (default: 100)
|
||||
|
||||
Returns:
|
||||
Список файлов, ожидающих распознавания
|
||||
"""
|
||||
try:
|
||||
pending_audio = db.query(Audio).filter(
|
||||
Audio.AiConclusion == None
|
||||
).order_by(Audio.index_date.asc()).limit(limit).all()
|
||||
|
||||
files_info = []
|
||||
for audio in pending_audio:
|
||||
file_path = os.path.join(AUDIOFILES_PATH, audio.filename)
|
||||
exists = os.path.exists(file_path)
|
||||
|
||||
files_info.append({
|
||||
"audio_id": str(audio.id),
|
||||
"filename": audio.filename,
|
||||
"file_size": audio.file_size,
|
||||
"created_at": audio.index_date.isoformat() if audio.index_date else None,
|
||||
"exists_on_disk": exists
|
||||
})
|
||||
|
||||
total_pending = db.query(Audio).filter(Audio.AiConclusion == None).count()
|
||||
|
||||
return {
|
||||
"total_pending": total_pending,
|
||||
"count": len(files_info),
|
||||
"files": files_info
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при получении списка: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
|
||||
@audio_management_router.get("/audio/stats")
|
||||
async def get_audio_stats(db: Session = Depends(get_db)):
|
||||
"""
|
||||
Получить статистику по аудиофайлам
|
||||
|
||||
Returns:
|
||||
Статистика по Audio записям
|
||||
"""
|
||||
try:
|
||||
total_audio = db.query(Audio).count()
|
||||
with_conclusion = db.query(Audio).filter(Audio.AiConclusion != None).count()
|
||||
without_conclusion = db.query(Audio).filter(Audio.AiConclusion == None).count()
|
||||
|
||||
# Проверяем существование файлов на диске
|
||||
all_audio = db.query(Audio).all()
|
||||
existing_count = 0
|
||||
for audio in all_audio:
|
||||
file_path = os.path.join(AUDIOFILES_PATH, audio.filename)
|
||||
if os.path.exists(file_path):
|
||||
existing_count += 1
|
||||
|
||||
return {
|
||||
"total_audio": total_audio,
|
||||
"with_conclusion": with_conclusion,
|
||||
"without_conclusion": without_conclusion,
|
||||
"existing_on_disk": existing_count,
|
||||
"missing_on_disk": total_audio - existing_count
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при получении статистики: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
|
||||
def auto_restore_on_startup(db: Session, limit: int = 100):
|
||||
"""
|
||||
Автоматическое восстановление распознавания при старте FileAudioAPI
|
||||
|
||||
Проверяет, есть ли файлы без AiConclusion, и запускает их распознавание
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
limit: Максимум файлов для восстановления
|
||||
"""
|
||||
try:
|
||||
from sqlalchemy import or_
|
||||
|
||||
# Проверяем, есть ли файлы без AiConclusion
|
||||
pending_audio = db.query(Audio).filter(
|
||||
or_(
|
||||
Audio.AiConclusion == None,
|
||||
Audio.AiConclusion == ''
|
||||
)
|
||||
).limit(limit).all()
|
||||
|
||||
if not pending_audio:
|
||||
logger.info("ℹ️ Auto-restore: нет файлов для распознавания")
|
||||
return
|
||||
|
||||
logger.info(f"🔄 Auto-restore: найдено {len(pending_audio)} файлов без AiConclusion")
|
||||
|
||||
# Запускаем распознавание
|
||||
started_count = 0
|
||||
for audio in pending_audio:
|
||||
file_path = os.path.join(AUDIOFILES_PATH, audio.filename)
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
logger.warning(f"⚠️ Файл не найден: {audio.filename}")
|
||||
continue
|
||||
|
||||
# Отправляем в GigaAM API
|
||||
from apiApp.config import GIGAAM_API_URL
|
||||
api_url = f"{GIGAAM_API_URL}/api/call/process"
|
||||
|
||||
payload = {"filename": audio.filename}
|
||||
|
||||
try:
|
||||
import requests
|
||||
response = requests.post(api_url, json=payload, timeout=5)
|
||||
|
||||
if response.status_code in [200, 202]:
|
||||
logger.info(f"✅ Запущено распознавание: {audio.filename}")
|
||||
started_count += 1
|
||||
else:
|
||||
logger.warning(f"⚠️ Ошибка запуска {audio.filename}: {response.status_code}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при запуске {audio.filename}: {e}")
|
||||
|
||||
logger.info(f"🎉 Auto-restore завершено: запущено {started_count} файлов")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при auto-restore: {e}")
|
||||
@@ -0,0 +1,130 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File as FastAPIFile, Form, status
|
||||
from apiApp.database import get_db
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
import os, uuid
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import requests
|
||||
|
||||
from apiApp.config import ALLOWED_AUDIO_EXTENSIONS, MAX_UPLOAD_SIZE, AUDIOFILES_PATH, GIGAAM_API_URL
|
||||
import aiofiles
|
||||
|
||||
from apiApp.schemas import AudioCreate
|
||||
from apiApp.services import AudioCRUD
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/external_audio",
|
||||
tags=["Внешние аудиофайлы"]
|
||||
)
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_external_audio(
|
||||
file: UploadFile = FastAPIFile(...),
|
||||
callback_url: str = Form(None, description="URL для отправки результата распознавания (опционально). Если передан — FileAudioAPI вызовет GigaAM и затем отправит результат на этот URL."),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Загрузка внешнего аудиофайла. Файл сохраняется в общей папке.
|
||||
Если передан callback_url — автоматически отправляется на распознавание в GigaAM,
|
||||
а результат придёт на callback_url.
|
||||
"""
|
||||
|
||||
# Проверка расширения файла
|
||||
file_ext = os.path.splitext(file.filename)[1].lower()
|
||||
if file_ext not in ALLOWED_AUDIO_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"File extension not allowed. Allowed: {', '.join(ALLOWED_AUDIO_EXTENSIONS)}"
|
||||
)
|
||||
content = await file.read()
|
||||
# Проверка размера файла
|
||||
if len(content) > MAX_UPLOAD_SIZE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail=f"File too large. Maximum size: {MAX_UPLOAD_SIZE / (1024*1024)}MB"
|
||||
)
|
||||
|
||||
# Сохранение в общей папке под именем uuid+ext; это же имя передаём в GigaAM
|
||||
safe_name = f"{uuid.uuid4()}{file_ext}"
|
||||
upload_dir = Path(AUDIOFILES_PATH) if isinstance(AUDIOFILES_PATH, str) else AUDIOFILES_PATH
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_path = upload_dir / safe_name
|
||||
try:
|
||||
async with aiofiles.open(file_path, 'wb') as f:
|
||||
await f.write(content)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error saving file: {str(e)}"
|
||||
)
|
||||
|
||||
# Создание записи в БД (filename = имя файла на диске, его же передаём в GigaAM)
|
||||
try:
|
||||
audio_data = AudioCreate(filename=safe_name)
|
||||
audio = AudioCRUD.create(
|
||||
db=db,
|
||||
audio_data=audio_data,
|
||||
file_path=str(file_path),
|
||||
file_size=len(content),
|
||||
sourse="external"
|
||||
)
|
||||
except Exception as e:
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error creating database record: {str(e)}"
|
||||
)
|
||||
|
||||
# Всегда запускаем распознавание в GigaAM; callback_url передаём только если передан
|
||||
cb = callback_url.strip() if (callback_url and callback_url.strip()) else None
|
||||
task_id, recognition_error = send_to_recognition(filename=safe_name, callback_url=cb)
|
||||
|
||||
# Ответ: запись аудио + данные по постановке в очередь распознавания
|
||||
result = {
|
||||
"id": str(audio.id),
|
||||
"filename": audio.filename,
|
||||
"index_date": audio.index_date.isoformat() if audio.index_date else None,
|
||||
"file_path": audio.file_path,
|
||||
"file_size": audio.file_size,
|
||||
"sourse": audio.sourse,
|
||||
}
|
||||
if task_id:
|
||||
result["recognition_task_id"] = task_id
|
||||
result["recognition_status_url"] = f"{GIGAAM_API_URL.rstrip('/')}/api/call/task/{task_id}"
|
||||
if recognition_error:
|
||||
result["recognition_error"] = recognition_error
|
||||
return result
|
||||
|
||||
def send_to_recognition(filename: str, callback_url: str | None = None) -> tuple:
|
||||
"""
|
||||
Отправка аудиофайла на распознавание в GigaAM.
|
||||
Файл должен уже лежать в общей папке под именем filename.
|
||||
|
||||
Returns:
|
||||
(task_id или None, recognition_error или None)
|
||||
"""
|
||||
try:
|
||||
gigaam_url = f"{GIGAAM_API_URL.rstrip('/')}/api/call/external/process"
|
||||
payload = {"filename": filename}
|
||||
if callback_url:
|
||||
payload["callback_url"] = callback_url
|
||||
resp = requests.post(
|
||||
gigaam_url,
|
||||
json=payload,
|
||||
timeout=15,
|
||||
)
|
||||
if resp.status_code == 202:
|
||||
data = resp.json()
|
||||
task_id = data.get("task_id")
|
||||
logger.info(f"✅ Внешний файл {filename} поставлен в очередь GigaAM, task_id={task_id}")
|
||||
return (task_id, None)
|
||||
recognition_error = resp.text or f"HTTP {resp.status_code}"
|
||||
logger.warning(f"⚠️ GigaAM не принял задачу {filename}: {recognition_error}")
|
||||
return (None, recognition_error)
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.warning(f"⚠️ Ошибка вызова GigaAM для {filename}: {e}")
|
||||
return (None, str(e))
|
||||
@@ -26,13 +26,20 @@ class AudioCRUD:
|
||||
return db.query(Audio).filter(Audio.filename == filename).first()
|
||||
|
||||
@staticmethod
|
||||
def create(db: Session, audio_data: AudioCreate, file_path: str, file_size: int = None) -> Audio:
|
||||
def create(
|
||||
db: Session,
|
||||
audio_data: AudioCreate,
|
||||
file_path: str,
|
||||
file_size: int = None,
|
||||
sourse: str = "internal"
|
||||
) -> Audio:
|
||||
"""Создать новую запись аудиофайла"""
|
||||
db_audio = Audio(
|
||||
filename=audio_data.filename,
|
||||
file_path=file_path,
|
||||
index_date=datetime.datetime.utcnow(),
|
||||
file_size=file_size
|
||||
file_size=file_size,
|
||||
sourse=sourse
|
||||
)
|
||||
db.add(db_audio)
|
||||
db.commit()
|
||||
|
||||
@@ -11,6 +11,7 @@ from apiApp.routers import audio_router, recognition_router
|
||||
from apiApp.routers.ai_conclusion_router import ai_conclusion_router
|
||||
from apiApp.routers.audio_files_router import audio_files_router
|
||||
from apiApp.routers.audio_management_router import audio_management_router
|
||||
from apiApp.routers.external_audio import router as external_audio_router
|
||||
|
||||
print("✅ audio_management_router imported successfully")
|
||||
|
||||
@@ -63,6 +64,7 @@ app.include_router(audio_router, prefix=API_V1_PREFIX, tags=["audio"])
|
||||
app.include_router(recognition_router, prefix=API_V1_PREFIX, tags=["recognition"])
|
||||
app.include_router(ai_conclusion_router, prefix=API_V1_PREFIX, tags=["ai_conclusion"])
|
||||
app.include_router(audio_files_router, prefix=API_V1_PREFIX, tags=["audio_files"])
|
||||
app.include_router(external_audio_router, prefix=API_V1_PREFIX)
|
||||
# audio_management_router с префиксом /audio для логической структуры
|
||||
print("📝 Registering audio_management_router...")
|
||||
app.include_router(audio_management_router, prefix="/api", tags=["audio_management"])
|
||||
|
||||
Reference in New Issue
Block a user