feat: add scenario management
- Add JSON scenario loading and validation - Add track selection logic with priorities - Add file management (data_partial → data)
This commit is contained in:
253
src/scenarios.py
Normal file
253
src/scenarios.py
Normal file
@@ -0,0 +1,253 @@
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from src.config import DATA_DIR, DATA_PARTIAL_DIR
|
||||
from src.database import (
|
||||
create_replicas,
|
||||
create_scenario,
|
||||
get_connection,
|
||||
get_replicas_for_track,
|
||||
get_scenario,
|
||||
get_track_speaker_ids,
|
||||
)
|
||||
from src.logger import logger
|
||||
|
||||
|
||||
def load_scenario_from_json(scenario_id: str, json_data: list[dict]) -> int:
|
||||
"""Загружает сценарий из JSON. Возвращает количество реплик."""
|
||||
if get_scenario(scenario_id):
|
||||
raise ValueError(f"Сценарий {scenario_id} уже существует")
|
||||
|
||||
replicas_data: list[tuple[int, int, str]] = []
|
||||
for idx, item in enumerate(json_data):
|
||||
if "text" not in item or "speaker_id" not in item:
|
||||
raise ValueError(f"Некорректный формат реплики #{idx}")
|
||||
replicas_data.append((item["speaker_id"], idx, item["text"]))
|
||||
|
||||
create_scenario(scenario_id)
|
||||
create_replicas(scenario_id, replicas_data)
|
||||
|
||||
logger.info(f"Загружен сценарий {scenario_id}: {len(replicas_data)} реплик")
|
||||
return len(replicas_data)
|
||||
|
||||
|
||||
def parse_scenario_file(file_content: bytes) -> tuple[list[dict], str | None]:
|
||||
"""Парсит JSON-файл сценария. Возвращает (данные, ошибка)."""
|
||||
try:
|
||||
data = json.loads(file_content.decode("utf-8"))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
||||
return [], f"Ошибка парсинга JSON: {e}"
|
||||
|
||||
if not isinstance(data, list):
|
||||
return [], "Ожидается массив реплик"
|
||||
|
||||
if not data:
|
||||
return [], "Сценарий пуст"
|
||||
|
||||
for idx, item in enumerate(data):
|
||||
if not isinstance(item, dict):
|
||||
return [], f"Реплика #{idx} должна быть объектом"
|
||||
if "text" not in item:
|
||||
return [], f"Реплика #{idx}: отсутствует поле 'text'"
|
||||
if "speaker_id" not in item:
|
||||
return [], f"Реплика #{idx}: отсутствует поле 'speaker_id'"
|
||||
if not isinstance(item["speaker_id"], int):
|
||||
return [], f"Реплика #{idx}: 'speaker_id' должен быть числом"
|
||||
|
||||
return data, None
|
||||
|
||||
|
||||
def get_scenario_info(json_data: list[dict]) -> dict:
|
||||
"""Возвращает информацию о сценарии для превью."""
|
||||
speaker_ids = set(item["speaker_id"] for item in json_data)
|
||||
return {
|
||||
"total_replicas": len(json_data),
|
||||
"total_tracks": len(speaker_ids),
|
||||
"speaker_ids": sorted(speaker_ids),
|
||||
}
|
||||
|
||||
|
||||
def find_available_track(user_id: int) -> tuple[str, int] | None:
|
||||
"""
|
||||
Находит доступную дорожку для пользователя.
|
||||
Приоритет:
|
||||
1. Дорожки, которые никто не начал озвучивать
|
||||
2. Дорожки, которые кто-то начал, но не закончил
|
||||
3. Дорожки с готовой озвучкой (для дополнительных записей)
|
||||
|
||||
Пользователь не может озвучивать две разные дорожки в одном сценарии.
|
||||
Возвращает (scenario_id, speaker_id) или None.
|
||||
"""
|
||||
with get_connection() as conn:
|
||||
# Сценарии, в которых пользователь уже записывает дорожку
|
||||
user_scenarios = conn.execute(
|
||||
"""
|
||||
SELECT DISTINCT scenario_id FROM recordings WHERE user_id = ?
|
||||
""",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
user_scenario_ids = {row[0] for row in user_scenarios}
|
||||
|
||||
# Все дорожки (scenario_id, speaker_id) с количеством реплик
|
||||
all_tracks = conn.execute(
|
||||
"""
|
||||
SELECT scenario_id, speaker_id, COUNT(*) as replica_count
|
||||
FROM replicas
|
||||
GROUP BY scenario_id, speaker_id
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
# Статистика записей по дорожкам
|
||||
track_stats = conn.execute(
|
||||
"""
|
||||
SELECT r.scenario_id, rep.speaker_id, r.user_id, COUNT(*) as recorded_count
|
||||
FROM recordings r
|
||||
JOIN replicas rep ON r.scenario_id = rep.scenario_id
|
||||
AND r.replica_index = rep.replica_index
|
||||
GROUP BY r.scenario_id, rep.speaker_id, r.user_id
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
# Словарь: (scenario_id, speaker_id) -> {user_id: recorded_count}
|
||||
track_recordings: dict[tuple[str, int], dict[int, int]] = {}
|
||||
for row in track_stats:
|
||||
key = (row[0], row[1])
|
||||
if key not in track_recordings:
|
||||
track_recordings[key] = {}
|
||||
track_recordings[key][row[2]] = row[3]
|
||||
|
||||
# Категоризация дорожек
|
||||
untouched: list[tuple[str, int]] = [] # никто не начал
|
||||
in_progress: list[tuple[str, int]] = [] # начато, не закончено
|
||||
completed: list[tuple[str, int]] = [] # есть готовая запись
|
||||
|
||||
for row in all_tracks:
|
||||
scenario_id, speaker_id, replica_count = row[0], row[1], row[2]
|
||||
key = (scenario_id, speaker_id)
|
||||
|
||||
# Пропускаем сценарии, где пользователь уже записывает другую дорожку
|
||||
if scenario_id in user_scenario_ids:
|
||||
# Проверяем, записывает ли он именно эту дорожку
|
||||
if key in track_recordings and user_id in track_recordings[key]:
|
||||
# Пользователь уже записывает эту дорожку — пропускаем
|
||||
continue
|
||||
# Пользователь записывает другую дорожку в этом сценарии — пропускаем весь сценарий
|
||||
continue
|
||||
|
||||
if key not in track_recordings:
|
||||
untouched.append(key)
|
||||
else:
|
||||
has_complete = any(
|
||||
count == replica_count for count in track_recordings[key].values()
|
||||
)
|
||||
if has_complete:
|
||||
completed.append(key)
|
||||
else:
|
||||
in_progress.append(key)
|
||||
|
||||
# Выбираем по приоритету
|
||||
if untouched:
|
||||
return untouched[0]
|
||||
if in_progress:
|
||||
return in_progress[0]
|
||||
if completed:
|
||||
return completed[0]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_user_current_track(user_id: int) -> tuple[str, int] | None:
|
||||
"""Возвращает текущую дорожку пользователя (scenario_id, speaker_id) или None."""
|
||||
with get_connection() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT r.scenario_id, rep.speaker_id
|
||||
FROM recordings r
|
||||
JOIN replicas rep ON r.scenario_id = rep.scenario_id
|
||||
AND r.replica_index = rep.replica_index
|
||||
WHERE r.user_id = ?
|
||||
GROUP BY r.scenario_id, rep.speaker_id
|
||||
ORDER BY MAX(r.created_at) DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
if row:
|
||||
return (row[0], row[1])
|
||||
return None
|
||||
|
||||
|
||||
def is_track_complete(user_id: int, scenario_id: str, speaker_id: int) -> bool:
|
||||
"""Проверяет, полностью ли озвучена дорожка пользователем."""
|
||||
track_replicas = get_replicas_for_track(scenario_id, speaker_id)
|
||||
with get_connection() as conn:
|
||||
recorded = conn.execute(
|
||||
"""
|
||||
SELECT COUNT(*) FROM recordings r
|
||||
JOIN replicas rep ON r.scenario_id = rep.scenario_id
|
||||
AND r.replica_index = rep.replica_index
|
||||
WHERE r.user_id = ? AND r.scenario_id = ? AND rep.speaker_id = ?
|
||||
""",
|
||||
(user_id, scenario_id, speaker_id),
|
||||
).fetchone()[0]
|
||||
return recorded == len(track_replicas)
|
||||
|
||||
|
||||
def get_partial_dir(scenario_id: str) -> Path:
|
||||
"""Возвращает путь к папке частичных записей сценария."""
|
||||
return DATA_PARTIAL_DIR / scenario_id
|
||||
|
||||
|
||||
def get_data_dir(scenario_id: str) -> Path:
|
||||
"""Возвращает путь к папке готовых записей сценария."""
|
||||
return DATA_DIR / scenario_id
|
||||
|
||||
|
||||
def get_audio_filename(replica_index: int, user_id: int) -> str:
|
||||
"""Формирует имя файла для аудиозаписи."""
|
||||
return f"{replica_index}_{user_id}.wav"
|
||||
|
||||
|
||||
def move_track_to_data(user_id: int, scenario_id: str, speaker_id: int) -> None:
|
||||
"""Переносит завершённую дорожку из data_partial в data."""
|
||||
partial_dir = get_partial_dir(scenario_id)
|
||||
data_dir = get_data_dir(scenario_id)
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
track_replicas = get_replicas_for_track(scenario_id, speaker_id)
|
||||
moved_count = 0
|
||||
|
||||
for replica in track_replicas:
|
||||
filename = get_audio_filename(replica.replica_index, user_id)
|
||||
src = partial_dir / filename
|
||||
dst = data_dir / filename
|
||||
|
||||
if src.exists():
|
||||
shutil.move(str(src), str(dst))
|
||||
moved_count += 1
|
||||
|
||||
logger.info(
|
||||
f"Перенесено {moved_count} файлов для дорожки "
|
||||
f"{scenario_id}/{speaker_id} (user_id={user_id})"
|
||||
)
|
||||
|
||||
|
||||
def delete_partial_track(user_id: int, scenario_id: str, speaker_id: int) -> None:
|
||||
"""Удаляет частичные записи дорожки."""
|
||||
partial_dir = get_partial_dir(scenario_id)
|
||||
track_replicas = get_replicas_for_track(scenario_id, speaker_id)
|
||||
deleted_count = 0
|
||||
|
||||
for replica in track_replicas:
|
||||
filename = get_audio_filename(replica.replica_index, user_id)
|
||||
filepath = partial_dir / filename
|
||||
if filepath.exists():
|
||||
filepath.unlink()
|
||||
deleted_count += 1
|
||||
|
||||
if deleted_count > 0:
|
||||
logger.info(
|
||||
f"Удалено {deleted_count} частичных записей для дорожки "
|
||||
f"{scenario_id}/{speaker_id} (user_id={user_id})"
|
||||
)
|
||||
Reference in New Issue
Block a user