diff --git a/src/audio.py b/src/audio.py index 615d0de..1b1ab11 100644 --- a/src/audio.py +++ b/src/audio.py @@ -12,6 +12,7 @@ async def save_voice_message( user_id: int, scenario_id: str, replica_index: int, + duration: int, ) -> None: """Сохраняет голосовое сообщение в data_partial/.""" # Создаём директорию если нужно @@ -25,7 +26,25 @@ async def save_voice_message( await file.download_to_drive(filepath) - # Записываем в БД - upsert_recording(user_id, scenario_id, replica_index) + # Записываем в БД (duration из метаданных Telegram) + upsert_recording(user_id, scenario_id, replica_index, float(duration)) - logger.debug(f"Saved voice: {filepath}") + logger.debug(f"Saved voice: {filepath} ({duration}s)") + + +def format_duration(seconds: float) -> str: + """Форматирует длительность в читаемый вид.""" + seconds = int(seconds) + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + secs = seconds % 60 + + parts = [] + if hours > 0: + parts.append(f"{hours} ч") + if minutes > 0: + parts.append(f"{minutes} мин") + if secs > 0 or not parts: + parts.append(f"{secs} сек") + + return " ".join(parts) diff --git a/src/database.py b/src/database.py index f6c280c..d9191b8 100644 --- a/src/database.py +++ b/src/database.py @@ -61,6 +61,7 @@ class Recording: user_id: int # dataset_speaker_id scenario_id: str replica_index: int + duration: float # длительность в секундах created_at: datetime @@ -120,6 +121,7 @@ def init_db() -> None: user_id INTEGER NOT NULL, scenario_id TEXT NOT NULL, replica_index INTEGER NOT NULL, + duration REAL DEFAULT 0.0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id), FOREIGN KEY (scenario_id) REFERENCES scenarios(id), @@ -147,6 +149,14 @@ def init_db() -> None: CREATE INDEX IF NOT EXISTS idx_recordings_scenario ON recordings(scenario_id); """) + + # Миграция: добавляем колонку duration если её нет + cursor = conn.execute("PRAGMA table_info(recordings)") + columns = [row[1] for row in cursor.fetchall()] + if "duration" not in columns: + conn.execute("ALTER TABLE recordings ADD COLUMN duration REAL DEFAULT 0.0") + logger.info("Добавлена колонка duration в таблицу recordings") + conn.commit() logger.info("База данных инициализирована") @@ -317,9 +327,9 @@ def create_recording(user_id: int, scenario_id: str, replica_index: int) -> Reco """Создаёт запись об озвучке реплики.""" with get_connection() as conn: cursor = conn.execute( - "INSERT INTO recordings (user_id, scenario_id, replica_index) " - "VALUES (?, ?, ?) " - "RETURNING id, user_id, scenario_id, replica_index, created_at", + "INSERT INTO recordings (user_id, scenario_id, replica_index, duration) " + "VALUES (?, ?, ?, 0.0) " + "RETURNING id, user_id, scenario_id, replica_index, duration, created_at", (user_id, scenario_id, replica_index), ) row = cursor.fetchone() @@ -329,22 +339,25 @@ def create_recording(user_id: int, scenario_id: str, replica_index: int) -> Reco user_id=row["user_id"], scenario_id=row["scenario_id"], replica_index=row["replica_index"], + duration=row["duration"], created_at=row["created_at"], ) -def upsert_recording(user_id: int, scenario_id: str, replica_index: int) -> Recording: +def upsert_recording( + user_id: int, scenario_id: str, replica_index: int, duration: float +) -> Recording: """Создаёт или обновляет запись об озвучке реплики.""" with get_connection() as conn: cursor = conn.execute( """ - INSERT INTO recordings (user_id, scenario_id, replica_index) - VALUES (?, ?, ?) + INSERT INTO recordings (user_id, scenario_id, replica_index, duration) + VALUES (?, ?, ?, ?) ON CONFLICT(user_id, scenario_id, replica_index) - DO UPDATE SET created_at = CURRENT_TIMESTAMP - RETURNING id, user_id, scenario_id, replica_index, created_at + DO UPDATE SET created_at = CURRENT_TIMESTAMP, duration = excluded.duration + RETURNING id, user_id, scenario_id, replica_index, duration, created_at """, - (user_id, scenario_id, replica_index), + (user_id, scenario_id, replica_index, duration), ) row = cursor.fetchone() conn.commit() @@ -353,6 +366,7 @@ def upsert_recording(user_id: int, scenario_id: str, replica_index: int) -> Reco user_id=row["user_id"], scenario_id=row["scenario_id"], replica_index=row["replica_index"], + duration=row["duration"], created_at=row["created_at"], ) @@ -361,7 +375,7 @@ def get_user_recordings_for_scenario(user_id: int, scenario_id: str) -> list[Rec """Получает все записи пользователя для сценария.""" with get_connection() as conn: cursor = conn.execute( - "SELECT id, user_id, scenario_id, replica_index, created_at " + "SELECT id, user_id, scenario_id, replica_index, duration, created_at " "FROM recordings WHERE user_id = ? AND scenario_id = ? " "ORDER BY replica_index", (user_id, scenario_id), @@ -372,6 +386,7 @@ def get_user_recordings_for_scenario(user_id: int, scenario_id: str) -> list[Rec user_id=row["user_id"], scenario_id=row["scenario_id"], replica_index=row["replica_index"], + duration=row["duration"], created_at=row["created_at"], ) for row in cursor.fetchall() @@ -501,6 +516,32 @@ def get_stats() -> dict: ) """).fetchone()[0] + # Количество полностью озвученных сценариев (все дорожки сценария озвучены) + stats["completed_scenarios"] = conn.execute(""" + SELECT COUNT(*) FROM scenarios s + WHERE ( + SELECT COUNT(DISTINCT speaker_id) FROM replicas WHERE scenario_id = s.id + ) = ( + SELECT COUNT(DISTINCT speaker_id) FROM ( + SELECT r.scenario_id, rep.speaker_id, COUNT(*) as cnt + FROM recordings r + JOIN replicas rep ON r.scenario_id = rep.scenario_id + AND r.replica_index = rep.replica_index + WHERE r.scenario_id = s.id + GROUP BY r.scenario_id, rep.speaker_id + HAVING cnt = ( + SELECT COUNT(*) FROM replicas rp + WHERE rp.scenario_id = r.scenario_id + AND rp.speaker_id = rep.speaker_id + ) + ) AS completed + ) + """).fetchone()[0] + + # Общая длительность всех записей в секундах + result = conn.execute("SELECT SUM(duration) FROM recordings").fetchone()[0] + stats["total_duration"] = result if result else 0.0 + return stats @@ -551,3 +592,51 @@ def delete_scenario_data(scenario_id: str) -> None: conn.execute("DELETE FROM scenarios WHERE id = ?", (scenario_id,)) conn.commit() logger.info(f"Deleted scenario {scenario_id} from database") + + +def get_user_stats(user_id: int) -> dict: + """Получает статистику пользователя.""" + with get_connection() as conn: + stats = {} + + # Количество озвученных реплик + stats["total_replicas"] = conn.execute( + "SELECT COUNT(*) FROM recordings WHERE user_id = ?", (user_id,) + ).fetchone()[0] + + # Количество уникальных сценариев + stats["total_scenarios"] = conn.execute( + "SELECT COUNT(DISTINCT scenario_id) FROM recordings WHERE user_id = ?", + (user_id,), + ).fetchone()[0] + + # Количество полностью озвученных дорожек + stats["completed_tracks"] = conn.execute( + """ + SELECT COUNT(*) FROM ( + SELECT r.scenario_id, rep.speaker_id, COUNT(*) as cnt + 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 + HAVING cnt = ( + SELECT COUNT(*) FROM replicas rp + WHERE rp.scenario_id = r.scenario_id + AND rp.speaker_id = rep.speaker_id + ) + ) + """, + (user_id,), + ).fetchone()[0] + + return stats + + +def get_user_audio_duration(user_id: int) -> float: + """Получает общую длительность аудиозаписей пользователя в секундах.""" + with get_connection() as conn: + result = conn.execute( + "SELECT SUM(duration) FROM recordings WHERE user_id = ?", (user_id,) + ).fetchone()[0] + return result if result else 0.0 diff --git a/src/handlers.py b/src/handlers.py index 9b8e1ee..c181e1b 100644 --- a/src/handlers.py +++ b/src/handlers.py @@ -15,7 +15,9 @@ from src.database import ( get_scenario, get_scenario_stats, get_stats, + get_user_audio_duration, get_user_session, + get_user_stats, get_users_in_state, get_users_with_scenario, upsert_user_session, @@ -253,13 +255,17 @@ def format_replica_message(session: UserSession) -> str: def format_admin_stats() -> str: """Форматирует статистику для админки.""" + from src.audio import format_duration + stats = get_stats() + duration_str = format_duration(stats["total_duration"]) + return f"""📊 Статистика датасета: -📁 Сценарии: {stats["total_scenarios"]} -🎵 Дорожки: {stats["total_tracks"]} (завершено: {stats["completed_tracks"]}) -💬 Реплики: {stats["total_replicas"]} -🎙 Записей: {stats["total_recordings"]} +📁 Сценарии: {stats["completed_scenarios"]}/{stats["total_scenarios"]} завершено +🎵 Дорожки: {stats["completed_tracks"]}/{stats["total_tracks"]} озвучено +💬 Реплики: {stats["total_recordings"]}/{stats["total_replicas"]} записано +⏱ Объём: {duration_str} 👥 Пользователей: {stats["total_users"]} Отправьте JSON-файл с новым сценарием для загрузки.""" @@ -434,9 +440,28 @@ async def handle_save_track( ) -> None: """Обработчик сохранения дорожки.""" query = update.callback_query - from src.scenarios import move_track_to_data + from src.audio import format_duration + from src.scenarios import is_scenario_complete, move_scenario_to_data + + # Переносим файлы только когда весь сценарий озвучен + if is_scenario_complete(session.scenario_id): + move_scenario_to_data(session.scenario_id) + + # Получаем статистику пользователя + stats = get_user_stats(user.id) + duration = get_user_audio_duration(user.id) + duration_str = format_duration(duration) + + # Формируем сообщение с благодарностью и статистикой + thanks_msg = f"""✅ Дорожка сохранена! + +🙏 Спасибо за вашу работу! + +📊 Ваша статистика: +• Озвучено реплик: {stats["total_replicas"]} +• Завершено дорожек: {stats["completed_tracks"]} +• Общее время записи: {duration_str}""" - move_track_to_data(user.id, session.scenario_id, session.speaker_id) track = find_available_track(user.id) if not track: @@ -444,9 +469,7 @@ async def handle_save_track( session.scenario_id = None session.speaker_id = None session.replica_index = None - await query.edit_message_text( - f"✅ Дорожка сохранена!\n\n{NO_MORE_SCENARIOS_TEXT}" - ) + await query.edit_message_text(f"{thanks_msg}\n\n{NO_MORE_SCENARIOS_TEXT}") else: scenario_id, speaker_id = track session.state = UserState.FIRST_REPLICA @@ -455,7 +478,7 @@ async def handle_save_track( session.replica_index = 0 replica_text = get_current_replica_text(session) await query.edit_message_text( - f"✅ Дорожка сохранена!\n\n{FIRST_REPLICA_INSTRUCTIONS}\n\n{replica_text}" + f"{thanks_msg}\n\n{FIRST_REPLICA_INSTRUCTIONS}\n\n{replica_text}" ) logger.info( f"User {user.id} saved track, started new: {scenario_id}/{speaker_id}" @@ -806,9 +829,18 @@ async def handle_voice_message( """Обработчик голосовых сообщений.""" from src.audio import save_voice_message + # Получаем реальный replica_index из таблицы replicas (индекс в сценарии) + replicas = get_replicas_for_track(session.scenario_id, session.speaker_id) + real_replica_index = replicas[session.replica_index].replica_index + voice = update.message.voice await save_voice_message( - context.bot, voice.file_id, user.id, session.scenario_id, session.replica_index + context.bot, + voice.file_id, + user.id, + session.scenario_id, + real_replica_index, + voice.duration, ) track_length = get_track_length(session.scenario_id, session.speaker_id) diff --git a/src/scenarios.py b/src/scenarios.py index 876d70a..c879f71 100644 --- a/src/scenarios.py +++ b/src/scenarios.py @@ -208,6 +208,67 @@ def get_audio_filename(replica_index: int, user_id: int) -> str: return f"{replica_index}_{user_id}.wav" +def is_scenario_complete(scenario_id: str) -> bool: + """Проверяет, озвучены ли все дорожки сценария (у каждой есть полная запись).""" + with get_connection() as conn: + # Все дорожки с количеством реплик + all_tracks = conn.execute( + """ + SELECT speaker_id, COUNT(*) as replica_count + FROM replicas + WHERE scenario_id = ? + GROUP BY speaker_id + """, + (scenario_id,), + ).fetchall() + + if not all_tracks: + return False + + for track in all_tracks: + speaker_id, replica_count = track[0], track[1] + # Есть ли хотя бы один пользователь, который полностью озвучил дорожку + complete_count = conn.execute( + """ + SELECT COUNT(*) FROM ( + SELECT r.user_id + FROM recordings r + JOIN replicas rep ON r.scenario_id = rep.scenario_id + AND r.replica_index = rep.replica_index + WHERE r.scenario_id = ? AND rep.speaker_id = ? + GROUP BY r.user_id + HAVING COUNT(*) = ? + ) + """, + (scenario_id, speaker_id, replica_count), + ).fetchone()[0] + + if complete_count == 0: + return False + + return True + + +def move_scenario_to_data(scenario_id: str) -> None: + """Переносит все файлы сценария из data_partial в data.""" + partial_dir = get_partial_dir(scenario_id) + data_dir = get_data_dir(scenario_id) + + if not partial_dir.exists(): + return + + data_dir.mkdir(parents=True, exist_ok=True) + + moved_count = 0 + for file in partial_dir.glob("*.wav"): + dst = data_dir / file.name + shutil.move(str(file), str(dst)) + moved_count += 1 + + if moved_count > 0: + logger.info(f"Сценарий {scenario_id} завершён: перенесено {moved_count} файлов") + + 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)