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
+