Initial commit
This commit is contained in:
32
.gitignore
vendored
Normal file
32
.gitignore
vendored
Normal file
@@ -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
|
||||||
|
|
||||||
|
|
||||||
163
README.md
Normal file
163
README.md
Normal file
@@ -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 музыки
|
||||||
|
|
||||||
588
audio_processor.py
Normal file
588
audio_processor.py
Normal file
@@ -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
|
||||||
|
|
||||||
534
bot.py
Normal file
534
bot.py
Normal file
@@ -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(
|
||||||
|
"🎵 <b>Бот для изменения скорости и pitch аудио</b>\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(
|
||||||
|
"📖 <b>Помощь</b>\n\n"
|
||||||
|
"🔹 Отправьте аудио или видео файл\n"
|
||||||
|
"🔹 Выберите скорость изменения:\n"
|
||||||
|
" • 🐌 Slowed (-20%) - замедление на 20%\n"
|
||||||
|
" • 🚀 Speed Up (+20%) - ускорение на 20%\n"
|
||||||
|
" • ✏️ Ввести вручную - введите коэффициент (например, 0.8 или 1.5)\n\n"
|
||||||
|
"💡 <b>Ручной ввод:</b>\n"
|
||||||
|
"• 0.8 = замедление на 20%\n"
|
||||||
|
"• 1.2 = ускорение на 20%\n"
|
||||||
|
"• 0.5 = замедление в 2 раза\n"
|
||||||
|
"• 2.0 = ускорение в 2 раза\n"
|
||||||
|
"• Диапазон: от 0.1 до 5.0\n\n"
|
||||||
|
"📁 <b>Поддерживаемые форматы:</b>\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"✅ Файл получен: <b>{Path(file_name).name}</b> ({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(
|
||||||
|
"✏️ <b>Введите коэффициент скорости</b>\n\n"
|
||||||
|
"Примеры:\n"
|
||||||
|
"• <code>0.8</code> - замедление на 20%\n"
|
||||||
|
"• <code>1.2</code> - ускорение на 20%\n"
|
||||||
|
"• <code>0.5</code> - замедление в 2 раза\n"
|
||||||
|
"• <code>2.0</code> - ускорение в 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"✅ <b>Готово! MP3 версия</b>\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"✅ <b>Готово! MP3 версия</b>\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"📁 <b>Оригинальный формат</b>\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"✅ <b>Готово!</b>\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"✅ <b>Готово!</b>\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"✅ <b>Готово!</b>\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"✅ <b>Готово!</b>\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("Бот остановлен")
|
||||||
|
|
||||||
|
|
||||||
53
cleanup.py
Normal file
53
cleanup.py
Normal file
@@ -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} МБ"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
37
config.py
Normal file
37
config.py
Normal file
@@ -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',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
9
env.example
Normal file
9
env.example
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Токен бота Telegram
|
||||||
|
# Получить можно у @BotFather в Telegram
|
||||||
|
BOT_TOKEN=your_bot_token_here
|
||||||
|
|
||||||
|
# Время хранения файлов в часах (по умолчанию 24)
|
||||||
|
FILE_CLEANUP_HOURS=24
|
||||||
|
|
||||||
|
# Максимальный размер файла в МБ (по умолчанию 100)
|
||||||
|
MAX_FILE_SIZE_MB=100
|
||||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
aiogram>=3.0.0
|
||||||
|
python-dotenv>=1.0.0
|
||||||
|
|
||||||
Reference in New Issue
Block a user