From 84295974d38559154cdae61fc58782ecc3f23916 Mon Sep 17 00:00:00 2001 From: kilyabin <65072190+kilyabin@users.noreply.github.com> Date: Sat, 1 Nov 2025 00:10:38 +0400 Subject: [PATCH] Initial commit --- .gitignore | 32 +++ README.md | 163 +++++++++++++ audio_processor.py | 588 +++++++++++++++++++++++++++++++++++++++++++++ bot.py | 534 ++++++++++++++++++++++++++++++++++++++++ cleanup.py | 53 ++++ config.py | 37 +++ env.example | 9 + requirements.txt | 3 + 8 files changed, 1419 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 audio_processor.py create mode 100644 bot.py create mode 100644 cleanup.py create mode 100644 config.py create mode 100644 env.example create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..561a269 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +env/ +ENV/ +.venv + +# Environment +.env +.env.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Temporary files +temp/ +*.tmp +*.log + +# OS +.DS_Store +Thumbs.db + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..945ca11 --- /dev/null +++ b/README.md @@ -0,0 +1,163 @@ +# 🎵 Audio Tempo Bot + +Telegram бот для изменения скорости и pitch аудио файлов. Поддерживает замедление/ускорение треков с сохранением метаданных и автоматической конвертацией для Telegram. + +## ✨ Возможности + +- 🐌 **Замедление** и 🚀 **ускорение** аудио файлов +- 📊 **Стандартные пресеты**: -20% (slowed) и +20% (speed up) +- ✏️ **Ручной ввод** коэффициента скорости (0.1 - 5.0) +- 🎬 **Поддержка видео** - автоматическое извлечение аудио +- 🎵 **Множество форматов**: MP3, FLAC, WAV, OGG, M4A, AAC, OPUS и другие +- 📝 **Сохранение метаданных**: ID3 теги, artist, album и другие +- 🏷️ **Автоматические теги**: добавление "(Slowed)" или "(Speed Up)" в title и имя файла +- 📤 **Умная отправка**: автоматическая конвертация FLAC в MP3 для воспроизведения в Telegram +- 🗑️ **Автоочистка**: файлы удаляются через 24 часа + +## 📋 Требования + +- Python 3.8+ +- [ffmpeg](https://ffmpeg.org/download.html) с поддержкой libmp3lame, flac, aac +- Telegram Bot Token (получить у [@BotFather](https://t.me/BotFather)) + +## 🚀 Установка + +1. **Клонируйте репозиторий:** +```bash +git clone https://github.com/yourusername/audio-tempo-bot.git +cd audio-tempo-bot +``` + +2. **Установите зависимости:** +```bash +pip install -r requirements.txt +``` + +3. **Установите ffmpeg:** + + **Ubuntu/Debian:** + ```bash + sudo apt update + sudo apt install ffmpeg + ``` + + **macOS:** + ```bash + brew install ffmpeg + ``` + + **Windows:** + Скачайте с [официального сайта](https://ffmpeg.org/download.html) или используйте [chocolatey](https://chocolatey.org/): + ```bash + choco install ffmpeg + ``` + +4. **Настройте бота:** + ```bash + cp env.example .env + ``` + + Отредактируйте `.env` файл и укажите ваш токен бота: + ``` + BOT_TOKEN=your_bot_token_here + FILE_CLEANUP_HOURS=24 + MAX_FILE_SIZE_MB=100 + ``` + +## 🎯 Использование + +1. **Запустите бота:** +```bash +python bot.py +``` + +2. **Используйте в Telegram:** + - Отправьте боту аудио или видео файл + - Выберите действие: + - 🐌 **Slowed (-20%)** - замедление на 20% + - 🚀 **Speed Up (+20%)** - ускорение на 20% + - ✏️ **Ввести вручную** - введите коэффициент (например, 0.8 или 1.5) + +3. **Получите результат:** + - Для MP3/OGG/M4A - один файл для воспроизведения в Telegram + - Для FLAC/WAV - два файла: MP3 (для прослушивания) и оригинальный формат + +## 📖 Примеры + +**Стандартные варианты:** +- `0.8` = замедление на 20% (slowed) +- `1.2` = ускорение на 20% (speed up) +- `0.5` = замедление в 2 раза +- `2.0` = ускорение в 2 раза + +**Диапазон:** от 0.1 до 5.0 + +## 🔧 Конфигурация + +Настройки можно изменить в файле `.env`: + +- `BOT_TOKEN` - токен бота Telegram (обязательно) +- `FILE_CLEANUP_HOURS` - время хранения файлов в часах (по умолчанию 24) +- `MAX_FILE_SIZE_MB` - максимальный размер файла в МБ (по умолчанию 100) + +## 📁 Структура проекта + +``` +audio-tempo-bot/ +├── bot.py # Основной файл бота +├── audio_processor.py # Обработка аудио (изменение скорости/pitch) +├── cleanup.py # Автоматическая очистка старых файлов +├── config.py # Конфигурация +├── requirements.txt # Зависимости Python +├── env.example # Пример конфигурации +├── .gitignore +├── README.md +└── temp/ # Временные файлы (создается автоматически) +``` + +## 🎨 Особенности + +### Сохранение метаданных +- Все ID3 теги сохраняются из исходного файла +- Поле `title` обновляется с добавлением "(Slowed)" или "(Speed Up)" +- Поле `artist` остается отдельно и не попадает в `title` + +### Умная обработка форматов +- **FLAC/WAV** → создается MP3 версия для Telegram + оригинальный формат +- **MP3/OGG/M4A** → отправляется напрямую для воспроизведения +- **Видео файлы** → автоматически извлекается аудио + +### Безопасность +- Автоматическая очистка временных файлов +- Проверка размера файлов +- Обработка ошибок и таймаутов + +## 🤝 Вклад в проект + +Приветствуются pull requests! Для крупных изменений сначала откройте issue для обсуждения. + +## 📝 Лицензия + +Этот проект распространяется под лицензией MIT. См. файл `LICENSE` для подробностей. + +## ⚠️ Замечания + +- Для обработки больших файлов требуется время +- Telegram ограничивает размер файлов (20 МБ для обычных ботов, 50 МБ для Premium) +- Убедитесь, что ffmpeg установлен и доступен в PATH + +## 🐛 Известные проблемы + +Если возникают проблемы: +- Проверьте, что ffmpeg установлен: `ffmpeg -version` +- Убедитесь, что токен бота указан правильно в `.env` +- Проверьте логи в файле `bot.log` + +## 📞 Поддержка + +Если у вас возникли вопросы или проблемы, откройте [issue](https://github.com/yourusername/audio-tempo-bot/issues) на GitHub. + +--- + +Сделано с ❤️ для любителей slowed/speed-up музыки + diff --git a/audio_processor.py b/audio_processor.py new file mode 100644 index 0000000..b90be60 --- /dev/null +++ b/audio_processor.py @@ -0,0 +1,588 @@ +""" +Модуль для обработки аудио: изменение скорости и pitch +""" +import os +import json +import subprocess +from pathlib import Path +import logging + +logger = logging.getLogger(__name__) + + +class AudioProcessor: + """Класс для обработки аудио файлов""" + + def __init__(self, temp_dir: Path): + self.temp_dir = temp_dir + self.temp_dir.mkdir(exist_ok=True) + self._check_ffmpeg() + + def _check_ffmpeg(self): + """Проверяет наличие ffmpeg в системе""" + try: + subprocess.run( + ['ffmpeg', '-version'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True + ) + except (subprocess.CalledProcessError, FileNotFoundError): + logger.warning("ffmpeg не найден. Убедитесь, что ffmpeg установлен в системе.") + + def _detect_audio_format(self, file_path: Path) -> str: + """Определяет формат аудио файла""" + try: + result = subprocess.run( + ['ffprobe', '-v', 'error', '-select_streams', 'a:0', + '-show_entries', 'stream=codec_name', '-of', 'default=noprint_wrappers=1:nokey=1', + str(file_path)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=10 + ) + if result.returncode == 0: + return result.stdout.strip() + except Exception as e: + logger.debug(f"Не удалось определить формат: {e}") + return None + + def _get_sample_rate(self, file_path: Path) -> int: + """Получает sample rate аудио файла""" + try: + result = subprocess.run( + ['ffprobe', '-v', 'error', '-select_streams', 'a:0', + '-show_entries', 'stream=sample_rate', '-of', 'default=noprint_wrappers=1:nokey=1', + str(file_path)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=10 + ) + if result.returncode == 0: + try: + return int(result.stdout.strip()) + except ValueError: + pass + except Exception as e: + logger.debug(f"Не удалось получить sample rate: {e}") + return None + + def _get_metadata(self, file_path: Path) -> dict: + """Получает все метаданные из аудио файла""" + metadata = {} + try: + # Получаем все метаданные в формате JSON + result = subprocess.run( + ['ffprobe', '-v', 'error', '-show_entries', 'format_tags=all', + '-of', 'json', str(file_path)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=10 + ) + if result.returncode == 0: + try: + data = json.loads(result.stdout) + tags = data.get('format', {}).get('tags', {}) + if tags: + # Копируем все теги + metadata = tags.copy() + except (json.JSONDecodeError, KeyError): + pass + except Exception as e: + logger.debug(f"Не удалось получить метаданные: {e}") + return metadata + + def process_audio(self, input_path: Path, speed_factor: float, output_path: Path, original_filename: str = None) -> bool: + """ + Изменяет скорость и pitch аудио файла + + Args: + input_path: Путь к исходному файлу + speed_factor: Коэффициент скорости (1.0 = оригинал, 0.8 = -20%, 1.2 = +20%) + output_path: Путь для сохранения обработанного файла + original_filename: Оригинальное имя файла (для использования в метаданных, если нет title) + + Returns: + True если успешно, False при ошибке + """ + if not input_path.exists(): + logger.error(f"Входной файл не существует: {input_path}") + return False + + try: + logger.info(f"Обработка аудио: {input_path} -> {output_path} (speed: {speed_factor})") + + # Определяем формат выходного файла и параметры кодека + output_ext = output_path.suffix.lower() + codec_params = self._get_codec_params(output_ext) + + # Получаем текущий sample rate файла + current_sr = self._get_sample_rate(input_path) + if current_sr is None: + # Если не удалось определить, используем стандартный + current_sr = 44100 + logger.warning(f"Не удалось определить sample rate для {input_path}, используем 44100") + + # Изменяем sample rate - это изменит и скорость, и pitch одновременно + # Для одновременного изменения скорости и pitch используем asetrate + aresample + # asetrate изменяет скорость воспроизведения и pitch, aresample возвращает к нормальному sample rate + new_sample_rate = int(current_sr * speed_factor) + + # Используем asetrate для изменения скорости/pitch, затем aresample для нормализации sample rate + filter_complex = f"asetrate={new_sample_rate},aresample={current_sr}" + + # Получаем метаданные из исходного файла + metadata = self._get_metadata(input_path) + + # Определяем, что добавить к названию + if speed_factor < 1.0: + speed_tag = " (Slowed)" + elif speed_factor > 1.0: + speed_tag = " (Speed Up)" + else: + speed_tag = "" + + # Обновляем title в метаданных + original_title = metadata.get('title', '') + if original_title: + # Убираем старые теги из title, если они есть + title_clean = original_title.replace(" (Slowed)", "").replace(" (Speed Up)", "").strip() + + # Извлекаем только название трека, удаляя исполнителя из title + # Обычные форматы: "Artist - Title", "Artist: Title", "Artist | Title" + song_title = title_clean + + # Список всех возможных разделителей (с пробелами и без) + separators = [ + ' - ', ' – ', ' — ', # Тире с пробелами + ' : ', ': ', ' | ', ' / ', # Другие разделители + '- ', '– ', '— ', # Тире только с пробелом справа + ' -', ' –', ' —', # Тире только с пробелом слева + ':', '|', '/', # Без пробелов + ] + + # Если есть отдельное поле artist, ОБЯЗАТЕЛЬНО удаляем его из title + artist_name = metadata.get('artist', '').strip() + if artist_name: + # Нормализуем для сравнения (убираем лишние пробелы, приводим к нижнему регистру) + artist_normalized = ' '.join(artist_name.lower().split()) + title_normalized = title_clean.lower() + + # Пытаемся найти и удалить artist в разных вариациях + found_and_removed = False + + # Вариант 1: "Artist - Title" или "Artist: Title" и т.д. + for sep in separators: + sep_normalized = sep.strip() + # Проверяем начало строки + pattern_variants = [ + artist_name + sep, + artist_name.lower() + sep, + artist_name.upper() + sep, + artist_name.title() + sep, + ] + + for pattern in pattern_variants: + if title_clean.startswith(pattern): + song_title = title_clean[len(pattern):].strip() + found_and_removed = True + break + + if found_and_removed: + break + + # Проверяем конец строки: "Title - Artist" + pattern_variants = [ + sep + artist_name, + sep + artist_name.lower(), + sep + artist_name.upper(), + sep + artist_name.title(), + ] + + for pattern in pattern_variants: + if title_clean.endswith(pattern): + song_title = title_clean[:-len(pattern):].strip() + found_and_removed = True + break + + if found_and_removed: + break + + # Вариант 2: Если не нашли с разделителями, ищем artist в начале без учета регистра + if not found_and_removed: + title_lower = title_clean.lower() + artist_lower = artist_name.lower() + + # Проверяем, начинается ли title с artist (с разделителем или без) + if title_lower.startswith(artist_lower): + # Находим где заканчивается artist в оригинальном title + # Ищем позицию после artist + remaining_pos = len(artist_name) + + # Пропускаем пробелы и разделители после artist + while remaining_pos < len(title_clean) and ( + title_clean[remaining_pos] in ' \t' or + title_clean[remaining_pos:remaining_pos+2] in [' -', ' –', ' —', ' :', ' |', ' /'] + ): + remaining_pos += 1 + + if remaining_pos < len(title_clean): + song_title = title_clean[remaining_pos:].strip() + # Убираем разделители в начале, если остались + while song_title and song_title[0] in '-–—:|/': + song_title = song_title[1:].strip() + found_and_removed = True + + # Если все еще не удалили, пробуем через регулярное выражение или простое удаление + if not found_and_removed or song_title == title_clean: + # Последняя попытка: удаляем все до первого разделителя, если первая часть похожа на artist + for sep in separators: + if sep in title_clean: + parts = title_clean.split(sep, 1) + if len(parts) == 2: + part1, part2 = parts[0].strip(), parts[1].strip() + # Если первая часть совпадает с artist (с учетом регистра) + if part1.lower() == artist_lower: + song_title = part2 + found_and_removed = True + break + # Или если первая часть короткая и вторая длинная (скорее всего artist - title) + elif len(part1) < 30 and len(part2) > len(part1): + song_title = part2 + found_and_removed = True + break + + # Если не удалось извлечь через artist, пробуем общий подход + # Ищем первый разделитель и берем часть после него (или более длинную часть) + if song_title == title_clean: + best_match = None + best_position = -1 + + for sep in separators: + if sep in title_clean: + parts = title_clean.split(sep, 1) + if len(parts) == 2: + part1, part2 = parts[0].strip(), parts[1].strip() + # Предпочитаем более длинную часть как название трека + # Но если первая часть явно короче и похожа на имя, берем вторую + if len(part2) > len(part1) or len(part1) < 20: + if best_position < title_clean.index(sep): + best_match = part2 + best_position = title_clean.index(sep) + + if best_match: + song_title = best_match + + # Если есть artist в метаданных, но мы все еще не удалили его из title, + # применяем принудительное удаление - берем все после первого разделителя + if artist_name and song_title == title_clean: + # Принудительно ищем первый разделитель и берем часть после него + for sep in separators: + if sep in title_clean: + parts = title_clean.split(sep, 1) + if len(parts) == 2: + song_title = parts[1].strip() + logger.debug(f"Принудительно извлечен title после разделителя '{sep}': {song_title}") + break + + # Финальная проверка: если title все еще содержит artist (по подстроке), удаляем его + if artist_name and artist_name.lower() in song_title.lower() and song_title != title_clean: + # Если в извлеченном title все еще есть artist, пробуем удалить + parts = song_title.split(artist_name, 1) + if len(parts) == 2: + # Берем часть без artist + remaining = (parts[0] + parts[1]).strip() + # Убираем разделители в начале + while remaining and remaining[0] in '-–—:|/ ': + remaining = remaining[1:].strip() + if remaining: + song_title = remaining + + # Если все еще не изменилось, оставляем как есть + new_title = song_title + speed_tag + elif original_filename: + # Если нет title, но есть оригинальное имя файла, используем его + stem_clean = Path(original_filename).stem.replace(" (Slowed)", "").replace(" (Speed Up)", "").strip() + new_title = stem_clean + speed_tag + else: + # Если нет ни title, ни оригинального имени файла, не добавляем title в метаданные + new_title = None + + # Собираем список метаданных для добавления + # ВАЖНО: НЕ используем -map_metadata, чтобы старый title не копировался + # Это гарантирует, что в выходном файле будет только наш новый title + metadata_params = [] + + # Явно передаем все метаданные из исходного файла, ИСКЛЮЧАЯ title + # Это сохраняет artist, album и другие метаданные, но НЕ title + for key, value in metadata.items(): + if value: # Пропускаем пустые значения + # НЕ передаем title, так как он будет обновлен + if key.lower() != 'title': + # Экранируем значения метаданных для командной строки + # Заменяем специальные символы, которые могут вызвать проблемы + value_str = str(value).replace('\\', '\\\\').replace(':', '\\:').replace('=', '\\=') + metadata_params.extend(['-metadata', f'{key}={value_str}']) + + # В конце добавляем НОВЫЙ title (это ЕДИНСТВЕННЫЙ title в файле) + if new_title: + # Экранируем title тоже + title_str = new_title.replace('\\', '\\\\').replace(':', '\\:').replace('=', '\\=') + metadata_params.extend(['-metadata', f'title={title_str}']) + + # Собираем команду ffmpeg + # Используем -map_metadata -1 чтобы НЕ копировать метаданные автоматически + # и добавляем только те, которые мы явно указали + cmd = [ + 'ffmpeg', + '-i', str(input_path), + '-map_metadata', '-1', # НЕ копируем метаданные из исходного файла + '-af', filter_complex, + '-ar', str(current_sr), # Устанавливаем sample rate вывода + *metadata_params, # Добавляем только наши метаданные (без старого title) + *codec_params, + '-y', # Перезаписывать выходной файл + str(output_path) + ] + + logger.debug(f"Выполнение команды: {' '.join(cmd)}") + + result = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=300 # Максимум 5 минут на обработку + ) + + if result.returncode != 0: + logger.error(f"Ошибка ffmpeg: {result.stderr}") + return False + + # Проверяем, что выходной файл создан + if not output_path.exists() or output_path.stat().st_size == 0: + logger.error("Выходной файл не создан или пуст") + return False + + logger.info(f"Аудио успешно обработано: {output_path} ({output_path.stat().st_size / 1024:.2f} КБ)") + return True + + except subprocess.TimeoutExpired: + logger.error("Превышено время обработки файла (5 минут)") + return False + except Exception as e: + logger.error(f"Ошибка при обработке аудио: {e}", exc_info=True) + return False + + def _get_codec_params(self, extension: str) -> list: + """Возвращает параметры кодека для формата""" + extension = extension.lower() + + params_map = { + '.mp3': [ + '-acodec', 'libmp3lame', + '-q:a', '2', # Качество ~192 kbps + '-id3v2_version', '3', # Используем ID3v2.3 для лучшей совместимости + '-write_id3v2', '1', # Включаем запись ID3v2 тегов + ], + '.m4a': ['-acodec', 'aac', '-b:a', '192k'], + '.ogg': ['-acodec', 'libvorbis', '-q:a', '5'], # Качество ~160 kbps + '.flac': ['-acodec', 'flac', '-compression_level', '5'], + '.wav': ['-acodec', 'pcm_s16le'], + '.opus': ['-acodec', 'libopus', '-b:a', '128k'], + '.aac': ['-acodec', 'aac', '-b:a', '192k'], + } + + # Для неизвестных форматов используем MP3 как fallback + fallback_params = [ + '-acodec', 'libmp3lame', + '-q:a', '2', + '-id3v2_version', '3', + '-write_id3v2', '1', + ] + return params_map.get(extension, fallback_params) + + def extract_audio_from_video(self, video_path: Path, output_path: Path) -> bool: + """ + Извлекает аудио из видео файла + + Args: + video_path: Путь к видео файлу + output_path: Путь для сохранения извлеченного аудио + + Returns: + True если успешно, False при ошибке + """ + if not video_path.exists(): + logger.error(f"Видео файл не существует: {video_path}") + return False + + try: + logger.info(f"Извлечение аудио из видео: {video_path}") + + cmd = [ + 'ffmpeg', + '-i', str(video_path), + '-vn', # Без видео + '-acodec', 'pcm_s16le', # WAV формат + '-ar', '44100', # Sample rate + '-ac', '2', # Стерео + '-y', + str(output_path) + ] + + result = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=300 + ) + + if result.returncode != 0: + logger.error(f"Ошибка при извлечении аудио: {result.stderr}") + return False + + if not output_path.exists() or output_path.stat().st_size == 0: + logger.error("Извлеченный аудио файл пуст или не создан") + return False + + logger.info(f"Аудио успешно извлечено: {output_path}") + return True + + except subprocess.TimeoutExpired: + logger.error("Превышено время извлечения аудио (5 минут)") + return False + except Exception as e: + logger.error(f"Ошибка при извлечении аудио: {e}", exc_info=True) + return False + + def get_output_filename(self, original_filename: str, speed_factor: float) -> str: + """ + Генерирует имя выходного файла + + Args: + original_filename: Исходное имя файла + speed_factor: Коэффициент скорости + + Returns: + Новое имя файла + """ + # Получаем имя и расширение + path = Path(original_filename) + stem = path.stem + suffix = path.suffix + + # Определяем, что добавить к названию + if speed_factor < 1.0: + speed_tag = " (Slowed)" + elif speed_factor > 1.0: + speed_tag = " (Speed Up)" + else: + speed_tag = "" + + # Убираем старые теги, если они есть + stem_clean = stem.replace(" (Slowed)", "").replace(" (Speed Up)", "").strip() + + # Добавляем новый тег + new_stem = stem_clean + speed_tag + + # Также добавляем процент для совместимости + speed_percent = int((speed_factor - 1.0) * 100) + if speed_percent != 0: + if speed_percent >= 0: + speed_str = f"_{speed_percent:+d}%" + else: + speed_str = f"_{speed_percent}%" + else: + speed_str = "" + + return f"{new_stem}{speed_str}{suffix}" + + def convert_to_mp3_for_telegram(self, input_path: Path, output_path: Path) -> bool: + """ + Конвертирует аудио файл в MP3 для отправки в Telegram + + Args: + input_path: Путь к исходному файлу + output_path: Путь для сохранения MP3 файла + + Returns: + True если успешно, False при ошибке + """ + if not input_path.exists(): + logger.error(f"Входной файл не существует: {input_path}") + return False + + try: + logger.info(f"Конвертация в MP3: {input_path} -> {output_path}") + + # Получаем метаданные из исходного файла для сохранения + metadata = self._get_metadata(input_path) + + # Явно передаем все метаданные, НЕ используя -map_metadata + metadata_params = [] + for key, value in metadata.items(): + if value: # Пропускаем пустые значения + # Экранируем значения метаданных + value_str = str(value).replace('\\', '\\\\').replace(':', '\\:').replace('=', '\\=') + metadata_params.extend(['-metadata', f'{key}={value_str}']) + + # Собираем команду ffmpeg для конвертации в MP3 + cmd = [ + 'ffmpeg', + '-i', str(input_path), + '-map_metadata', '-1', # НЕ копируем метаданные автоматически + *metadata_params, # Добавляем только явно указанные метаданные + '-acodec', 'libmp3lame', + '-q:a', '2', # Качество ~192 kbps + '-id3v2_version', '3', + '-write_id3v2', '1', + '-y', + str(output_path) + ] + + result = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=300 + ) + + if result.returncode != 0: + logger.error(f"Ошибка при конвертации в MP3: {result.stderr}") + return False + + if not output_path.exists() or output_path.stat().st_size == 0: + logger.error("MP3 файл не создан или пуст") + return False + + logger.info(f"Файл успешно сконвертирован в MP3: {output_path}") + return True + + except subprocess.TimeoutExpired: + logger.error("Превышено время конвертации в MP3 (5 минут)") + return False + except Exception as e: + logger.error(f"Ошибка при конвертации в MP3: {e}", exc_info=True) + return False + + def is_telegram_playable_format(self, file_path: Path) -> bool: + """ + Проверяет, может ли Telegram воспроизвести файл напрямую + + Args: + file_path: Путь к файлу + + Returns: + True если формат поддерживается для прямого воспроизведения + """ + extension = file_path.suffix.lower() + # Telegram поддерживает для прямого воспроизведения: MP3, OGG, M4A + playable_formats = {'.mp3', '.ogg', '.m4a', '.aac'} + return extension in playable_formats + diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..8fc30ed --- /dev/null +++ b/bot.py @@ -0,0 +1,534 @@ +""" +Telegram бот для изменения скорости и pitch аудио файлов +""" +import asyncio +import logging +from pathlib import Path +from aiogram import Bot, Dispatcher, F +from aiogram.types import Message, FSInputFile, InlineKeyboardMarkup, InlineKeyboardButton, CallbackQuery +from aiogram.filters import Command +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import State, StatesGroup +from aiogram.fsm.storage.memory import MemoryStorage + +from config import BOT_TOKEN, TEMP_DIR, FILE_CLEANUP_HOURS, MAX_FILE_SIZE_BYTES, SUPPORTED_FORMATS +from audio_processor import AudioProcessor +from cleanup import cleanup_old_files + +# Настройка логирования +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler('bot.log'), + logging.StreamHandler() + ] +) +logger = logging.getLogger(__name__) + +# Инициализация бота и диспетчера +bot = Bot(token=BOT_TOKEN) +dp = Dispatcher(storage=MemoryStorage()) + +# Инициализация процессора аудио +audio_processor = AudioProcessor(TEMP_DIR) + + +class AudioProcessingStates(StatesGroup): + """Состояния для обработки аудио""" + waiting_for_custom_speed = State() + + +def create_speed_keyboard() -> InlineKeyboardMarkup: + """Создает клавиатуру с кнопками выбора скорости""" + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [ + InlineKeyboardButton(text="🐌 Slowed (-20%)", callback_data="speed_0.8"), + InlineKeyboardButton(text="🚀 Speed Up (+20%)", callback_data="speed_1.2") + ], + [ + InlineKeyboardButton(text="✏️ Ввести вручную", callback_data="speed_custom") + ] + ]) + return keyboard + + +@dp.message(Command("start")) +async def cmd_start(message: Message): + """Обработчик команды /start""" + await message.answer( + "🎵 Бот для изменения скорости и pitch аудио\n\n" + "📤 Отправьте аудио или видео файл, и я помогу вам:\n" + "• 🐌 Замедлить на 20% (slowed)\n" + "• 🚀 Ускорить на 20% (speed up)\n" + "• ✏️ Задать свою скорость (в формате 0.x)\n\n" + "Поддерживаются форматы:\n" + "🎵 MP3, WAV, FLAC, OGG, M4A, AAC, OPUS\n" + "🎬 MP4, AVI, MOV, MKV, WEBM (аудио извлекается автоматически)", + parse_mode="HTML" + ) + + +@dp.message(Command("help")) +async def cmd_help(message: Message): + """Обработчик команды /help""" + await message.answer( + "📖 Помощь\n\n" + "🔹 Отправьте аудио или видео файл\n" + "🔹 Выберите скорость изменения:\n" + " • 🐌 Slowed (-20%) - замедление на 20%\n" + " • 🚀 Speed Up (+20%) - ускорение на 20%\n" + " • ✏️ Ввести вручную - введите коэффициент (например, 0.8 или 1.5)\n\n" + "💡 Ручной ввод:\n" + "• 0.8 = замедление на 20%\n" + "• 1.2 = ускорение на 20%\n" + "• 0.5 = замедление в 2 раза\n" + "• 2.0 = ускорение в 2 раза\n" + "• Диапазон: от 0.1 до 5.0\n\n" + "📁 Поддерживаемые форматы:\n" + "🎵 Аудио: MP3, WAV, FLAC, OGG, M4A, AAC, OPUS\n" + "🎬 Видео: MP4, AVI, MOV, MKV, WEBM\n" + "(аудио извлекается автоматически)\n\n" + "🗑️ Файлы автоматически удаляются через 24 часа", + parse_mode="HTML" + ) + + +@dp.message(F.audio | F.voice | F.document | F.video | F.video_note) +async def handle_audio(message: Message, state: FSMContext): + """Обработчик получения аудио или видео файла""" + + # Определяем тип файла и получаем file_id + file_id = None + file_name = None + mime_type = None + is_video = False + file_size = None + + if message.audio: + file_id = message.audio.file_id + file_name = message.audio.file_name or "audio.mp3" + mime_type = message.audio.mime_type + file_size = message.audio.file_size + elif message.voice: + file_id = message.voice.file_id + file_name = "voice.ogg" + mime_type = "audio/ogg" + file_size = message.voice.file_size + elif message.video: + file_id = message.video.file_id + file_name = message.video.file_name or "video.mp4" + mime_type = message.video.mime_type or "video/mp4" + is_video = True + file_size = message.video.file_size + elif message.video_note: + file_id = message.video_note.file_id + file_name = "video_note.mp4" + mime_type = "video/mp4" + is_video = True + file_size = message.video_note.file_size + elif message.document: + file_id = message.document.file_id + file_name = message.document.file_name or "file" + mime_type = message.document.mime_type + file_size = message.document.file_size + + # Проверяем, что это аудио или видео файл + if mime_type: + mime_lower = mime_type.lower() + is_video = 'video' in mime_lower + if not ('audio' in mime_lower or 'video' in mime_lower): + # Проверяем расширение + ext = Path(file_name).suffix.lower() + video_exts = ['.mp4', '.avi', '.mov', '.mkv', '.webm', '.flv', '.wmv', '.m4v', '.3gp'] + audio_exts = ['.mp3', '.wav', '.flac', '.ogg', '.m4a', '.aac', '.wma', '.opus', '.amr'] + + if ext not in audio_exts + video_exts: + await message.answer( + "❌ Пожалуйста, отправьте аудио или видео файл.\n" + "Поддерживаются: MP3, WAV, FLAC, OGG, M4A, AAC, MP4, AVI, MOV, MKV и другие" + ) + return + is_video = ext in video_exts + else: + # Если mime_type не указан, определяем по расширению + ext = Path(file_name).suffix.lower() + video_exts = ['.mp4', '.avi', '.mov', '.mkv', '.webm', '.flv', '.wmv', '.m4v', '.3gp'] + audio_exts = ['.mp3', '.wav', '.flac', '.ogg', '.m4a', '.aac', '.wma', '.opus', '.amr'] + + if ext not in audio_exts + video_exts: + await message.answer( + "❌ Пожалуйста, отправьте аудио или видео файл.\n" + "Поддерживаются: MP3, WAV, FLAC, OGG, M4A, AAC, MP4, AVI, MOV, MKV и другие" + ) + return + is_video = ext in video_exts + + if not file_id: + await message.answer("❌ Не удалось получить файл") + return + + # Проверяем размер файла (сначала из message, потом из API) + if file_size and file_size > MAX_FILE_SIZE_BYTES: + await message.answer( + f"❌ Файл слишком большой ({file_size / 1024 / 1024:.1f} МБ). " + f"Максимальный размер: {MAX_FILE_SIZE_BYTES / 1024 / 1024:.0f} МБ" + ) + return + + # Пытаемся получить информацию о файле из API (для получения file_path) + try: + file_info = await bot.get_file(file_id) + # Дополнительная проверка размера на случай, если его не было в message + if file_info.file_size and file_info.file_size > MAX_FILE_SIZE_BYTES: + await message.answer( + f"❌ Файл слишком большой ({file_info.file_size / 1024 / 1024:.1f} МБ). " + f"Максимальный размер: {MAX_FILE_SIZE_BYTES / 1024 / 1024:.0f} МБ" + ) + return + except Exception as e: + error_str = str(e).lower() + # Проверяем, является ли это ошибкой о большом размере файла + if "too big" in error_str or "file is too big" in error_str: + await message.answer( + f"❌ Файл слишком большой для загрузки ботом.\n" + f"Максимальный размер: {MAX_FILE_SIZE_BYTES / 1024 / 1024:.0f} МБ\n\n" + f"💡 Попробуйте:\n" + f"• Отправить файл меньшего размера\n" + f"• Сжать файл перед отправкой" + ) + logger.warning(f"Файл слишком большой для Telegram API: {file_name}") + return + else: + logger.error(f"Ошибка при получении информации о файле: {e}") + await message.answer( + "❌ Не удалось получить информацию о файле. " + "Попробуйте отправить файл заново." + ) + return + + # Для видео файлов определяем имя выходного аудио файла + if is_video: + # Заменяем расширение на .mp3 или .m4a + original_path = Path(file_name) + file_name = f"{original_path.stem}.mp3" + + # Сохраняем информацию о файле в состоянии + await state.update_data( + file_id=file_id, + file_name=file_name, + mime_type=mime_type, + is_video=is_video + ) + + # Показываем клавиатуру выбора скорости + file_type = "🎬 видео" if is_video else "🎵 аудио" + await message.answer( + f"✅ Файл получен: {Path(file_name).name} ({file_type})\n\n" + "Выберите действие:", + reply_markup=create_speed_keyboard(), + parse_mode="HTML" + ) + + +@dp.callback_query(F.data.startswith("speed_")) +async def handle_speed_callback(callback: CallbackQuery, state: FSMContext): + """Обработчик выбора скорости""" + + data = callback.data + user_data = await state.get_data() + + if not user_data.get("file_id"): + await callback.answer("❌ Файл не найден. Отправьте файл заново.", show_alert=True) + return + + if data == "speed_custom": + # Запрашиваем ручной ввод + await callback.message.edit_text( + "✏️ Введите коэффициент скорости\n\n" + "Примеры:\n" + "• 0.8 - замедление на 20%\n" + "• 1.2 - ускорение на 20%\n" + "• 0.5 - замедление в 2 раза\n" + "• 2.0 - ускорение в 2 раза\n\n" + "Введите число от 0.1 до 5.0:", + parse_mode="HTML" + ) + await state.set_state(AudioProcessingStates.waiting_for_custom_speed) + await callback.answer() + return + + # Извлекаем коэффициент скорости + try: + speed_factor = float(data.replace("speed_", "")) + except ValueError: + await callback.answer("❌ Ошибка в данных", show_alert=True) + return + + await callback.answer("⏳ Обрабатываю файл...") + await process_audio_file(callback.message, state, speed_factor, user_data) + + +@dp.message(AudioProcessingStates.waiting_for_custom_speed) +async def handle_custom_speed_input(message: Message, state: FSMContext): + """Обработчик ручного ввода скорости""" + + try: + speed_factor = float(message.text.strip()) + + # Проверяем диапазон + if speed_factor < 0.1 or speed_factor > 5.0: + await message.answer( + "❌ Коэффициент должен быть от 0.1 до 5.0\n" + "Попробуйте еще раз:" + ) + return + + except ValueError: + await message.answer( + "❌ Пожалуйста, введите число (например, 0.8 или 1.2)\n" + "Попробуйте еще раз:" + ) + return + + user_data = await state.get_data() + await state.clear() + + if not user_data.get("file_id"): + await message.answer("❌ Файл не найден. Отправьте файл заново.") + return + + processing_msg = await message.answer("⏳ Обрабатываю файл...") + await process_audio_file(processing_msg, state, speed_factor, user_data) + + +async def process_audio_file(message: Message, state: FSMContext, speed_factor: float, user_data: dict): + """Обрабатывает аудио или видео файл""" + + file_id = user_data["file_id"] + file_name = user_data["file_name"] + is_video = user_data.get("is_video", False) + + input_path = None + output_path = None + + try: + # Получаем информацию о файле и скачиваем + try: + file_info = await bot.get_file(file_id) + except Exception as e: + error_str = str(e).lower() + if "too big" in error_str or "file is too big" in error_str: + await message.edit_text( + f"❌ Файл слишком большой для загрузки ботом.\n" + f"Максимальный размер: {MAX_FILE_SIZE_BYTES / 1024 / 1024:.0f} МБ\n\n" + f"💡 Попробуйте отправить файл меньшего размера" + ) + return + else: + raise + + # Определяем расширение входного файла + original_ext = Path(file_info.file_path).suffix if hasattr(file_info, 'file_path') and file_info.file_path else Path(file_name).suffix + if not original_ext: + original_ext = ".mp4" if is_video else ".mp3" + + input_path = TEMP_DIR / f"input_{file_id.replace('/', '_').replace('\\', '_')}{original_ext}" + + await message.edit_text("📥 Скачиваю файл...") + + try: + await bot.download_file(file_info.file_path, input_path) + except Exception as e: + error_str = str(e).lower() + if "too big" in error_str or "file is too big" in error_str: + await message.edit_text( + f"❌ Файл слишком большой для загрузки.\n" + f"Максимальный размер: {MAX_FILE_SIZE_BYTES / 1024 / 1024:.0f} МБ" + ) + return + else: + raise + + # Если это видео, сначала извлекаем аудио + if is_video: + await message.edit_text("🎬 Извлекаю аудио из видео...") + audio_extracted_path = TEMP_DIR / f"extracted_{file_id.replace('/', '_')}.wav" + + if not audio_processor.extract_audio_from_video(input_path, audio_extracted_path): + await message.edit_text("❌ Не удалось извлечь аудио из видео. Проверьте формат файла.") + if input_path.exists(): + input_path.unlink() + return + + # Удаляем исходное видео и используем извлеченное аудио + if input_path.exists(): + input_path.unlink() + input_path = audio_extracted_path + + # Определяем имя выходного файла + output_path = TEMP_DIR / audio_processor.get_output_filename(file_name, speed_factor) + + await message.edit_text("🎵 Обрабатываю аудио...") + + # Обрабатываем аудио (передаем оригинальное имя файла для метаданных) + success = audio_processor.process_audio(input_path, speed_factor, output_path, original_filename=file_name) + + if not success: + await message.edit_text("❌ Ошибка при обработке файла. Попробуйте другой файл.") + # Удаляем входной файл + if input_path.exists(): + input_path.unlink() + return + + # Отправляем обработанный файл + await message.edit_text("📤 Отправляю файл...") + + # Проверяем, можно ли воспроизвести файл напрямую в Telegram + if not audio_processor.is_telegram_playable_format(output_path): + # Конвертируем в MP3 для отправки в Telegram + mp3_path = TEMP_DIR / f"{output_path.stem}.mp3" + await message.edit_text("🔄 Конвертирую в MP3 для Telegram...") + + if audio_processor.convert_to_mp3_for_telegram(output_path, mp3_path): + # Сначала отправляем MP3 для воспроизведения + mp3_filename = Path(output_path.name).stem + ".mp3" + mp3_file = FSInputFile(mp3_path, filename=mp3_filename) + + try: + await message.answer_audio( + mp3_file, + caption=f"✅ Готово! MP3 версия\n\n" + f"Файл: {mp3_filename}\n" + f"Коэффициент: {speed_factor:.2f} ({int((speed_factor - 1) * 100):+d}%)", + parse_mode="HTML" + ) + except Exception as e: + # Если не получилось отправить как audio, отправляем как document + logger.warning(f"Не удалось отправить MP3 как audio, отправляем как document: {e}") + await message.answer_document( + mp3_file, + caption=f"✅ Готово! MP3 версия\n\n" + f"Файл: {mp3_filename}\n" + f"Коэффициент: {speed_factor:.2f} ({int((speed_factor - 1) * 100):+d}%)", + parse_mode="HTML" + ) + + # Затем отправляем оригинальный формат (FLAC) + await asyncio.sleep(0.5) # Небольшая задержка между отправками + original_file = FSInputFile(output_path, filename=output_path.name) + await message.answer_document( + original_file, + caption=f"📁 Оригинальный формат\n\n" + f"Файл: {output_path.name}\n" + f"Коэффициент: {speed_factor:.2f} ({int((speed_factor - 1) * 100):+d}%)", + parse_mode="HTML" + ) + + # Удаляем временный MP3 файл после отправки + try: + mp3_path.unlink() + logger.debug(f"Удален временный MP3 файл: {mp3_path}") + except: + pass + else: + # Если не удалось сконвертировать, отправляем только оригинальный файл + logger.warning(f"Не удалось сконвертировать {output_path} в MP3") + original_file = FSInputFile(output_path, filename=output_path.name) + await message.answer_document( + original_file, + caption=f"✅ Готово!\n\n" + f"Файл: {output_path.name}\n" + f"Коэффициент: {speed_factor:.2f} ({int((speed_factor - 1) * 100):+d}%)", + parse_mode="HTML" + ) + else: + # Формат поддерживается для прямого воспроизведения + audio_file = FSInputFile(output_path, filename=output_path.name) + + # Если это MP3, отправляем как audio для прямого воспроизведения + if output_path.suffix.lower() == '.mp3': + try: + await message.answer_audio( + audio_file, + caption=f"✅ Готово!\n\n" + f"Файл: {output_path.name}\n" + f"Коэффициент: {speed_factor:.2f} ({int((speed_factor - 1) * 100):+d}%)", + parse_mode="HTML" + ) + except Exception as e: + # Если не получилось отправить как audio, отправляем как document + logger.warning(f"Не удалось отправить как audio, отправляем как document: {e}") + await message.answer_document( + audio_file, + caption=f"✅ Готово!\n\n" + f"Файл: {output_path.name}\n" + f"Коэффициент: {speed_factor:.2f} ({int((speed_factor - 1) * 100):+d}%)", + parse_mode="HTML" + ) + else: + # Для других поддерживаемых форматов (OGG, M4A) отправляем как document + await message.answer_document( + audio_file, + caption=f"✅ Готово!\n\n" + f"Файл: {output_path.name}\n" + f"Коэффициент: {speed_factor:.2f} ({int((speed_factor - 1) * 100):+d}%)", + parse_mode="HTML" + ) + + await message.delete() + + logger.info(f"Файл обработан: {file_name} -> {output_path.name} (speed: {speed_factor})") + + except Exception as e: + logger.error(f"Ошибка при обработке файла: {e}", exc_info=True) + try: + await message.edit_text("❌ Произошла ошибка при обработке файла. Попробуйте еще раз.") + except: + await message.answer("❌ Произошла ошибка при обработке файла. Попробуйте еще раз.") + finally: + # Удаляем временные файлы + if input_path and input_path.exists(): + try: + input_path.unlink() + except: + pass + await state.clear() + + +async def periodic_cleanup(): + """Периодическая очистка старых файлов""" + while True: + try: + await asyncio.sleep(3600) # Каждый час + cleanup_old_files(TEMP_DIR, FILE_CLEANUP_HOURS) + except Exception as e: + logger.error(f"Ошибка в periodic_cleanup: {e}", exc_info=True) + + +async def main(): + """Основная функция""" + logger.info("Запуск бота...") + + # Проверяем наличие BOT_TOKEN + if not BOT_TOKEN: + logger.error("BOT_TOKEN не установлен! Установите его в .env файле.") + return + + # Запускаем задачу очистки файлов + asyncio.create_task(periodic_cleanup()) + + # Первичная очистка при старте + cleanup_old_files(TEMP_DIR, FILE_CLEANUP_HOURS) + + # Запускаем бота + await dp.start_polling(bot) + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + logger.info("Бот остановлен") + + diff --git a/cleanup.py b/cleanup.py new file mode 100644 index 0000000..e8a73f6 --- /dev/null +++ b/cleanup.py @@ -0,0 +1,53 @@ +""" +Модуль для очистки старых файлов +""" +import os +import time +from pathlib import Path +from datetime import datetime, timedelta +import logging + +logger = logging.getLogger(__name__) + + +def cleanup_old_files(temp_dir: Path, max_age_hours: int = 24): + """ + Удаляет файлы старше указанного времени + + Args: + temp_dir: Директория с временными файлами + max_age_hours: Максимальный возраст файла в часах + """ + if not temp_dir.exists(): + return + + current_time = time.time() + max_age_seconds = max_age_hours * 3600 + + deleted_count = 0 + total_size = 0 + + try: + for file_path in temp_dir.iterdir(): + if file_path.is_file(): + # Получаем время модификации файла + file_age = current_time - file_path.stat().st_mtime + + if file_age > max_age_seconds: + # Файл слишком старый, удаляем + file_size = file_path.stat().st_size + file_path.unlink() + deleted_count += 1 + total_size += file_size + logger.debug(f"Удален старый файл: {file_path.name} ({file_age/3600:.1f} часов)") + + except Exception as e: + logger.error(f"Ошибка при очистке файлов: {e}", exc_info=True) + + if deleted_count > 0: + logger.info( + f"Очистка завершена: удалено {deleted_count} файлов, " + f"освобождено {total_size / 1024 / 1024:.2f} МБ" + ) + + diff --git a/config.py b/config.py new file mode 100644 index 0000000..56b1e46 --- /dev/null +++ b/config.py @@ -0,0 +1,37 @@ +""" +Конфигурация бота +""" +import os +from pathlib import Path +from dotenv import load_dotenv + +# Загрузка переменных окружения +load_dotenv() + +# Токен бота +BOT_TOKEN = os.getenv("BOT_TOKEN", "") + +# Путь к временной папке +TEMP_DIR = Path(__file__).parent / "temp" +TEMP_DIR.mkdir(exist_ok=True) + +# Время хранения файлов (в часах) +FILE_CLEANUP_HOURS = int(os.getenv("FILE_CLEANUP_HOURS", "24")) + +# Максимальный размер файла (в МБ) +MAX_FILE_SIZE_MB = int(os.getenv("MAX_FILE_SIZE_MB", "100")) +MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024 + +# Поддерживаемые форматы аудио +SUPPORTED_FORMATS = { + 'audio/mpeg', 'audio/mp3', 'audio/mpeg3', 'audio/x-mpeg-3', + 'audio/wav', 'audio/x-wav', 'audio/wave', + 'audio/flac', 'audio/x-flac', + 'audio/ogg', 'audio/ogg; codecs=opus', 'audio/opus', + 'audio/m4a', 'audio/x-m4a', 'audio/mp4', + 'audio/aac', + 'audio/x-ms-wma', + 'audio/webm', +} + + diff --git a/env.example b/env.example new file mode 100644 index 0000000..cb3ffba --- /dev/null +++ b/env.example @@ -0,0 +1,9 @@ +# Токен бота Telegram +# Получить можно у @BotFather в Telegram +BOT_TOKEN=your_bot_token_here + +# Время хранения файлов в часах (по умолчанию 24) +FILE_CLEANUP_HOURS=24 + +# Максимальный размер файла в МБ (по умолчанию 100) +MAX_FILE_SIZE_MB=100 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9169d99 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +aiogram>=3.0.0 +python-dotenv>=1.0.0 +