Files
limoka/archquise/H.Modules/mediatools.py
2026-01-10 01:09:56 +00:00

771 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Proprietary License Agreement
# Copyright (c) 2024-29 CodWiz
# Permission is hereby granted to any person obtaining a copy of this software and associated documentation files (the "Software"), to use the Software for personal and non-commercial purposes, subject to the following conditions:
# 1. The Software may not be modified, altered, or otherwise changed in any way without the explicit written permission of the author.
# 2. Redistribution of the Software, in original or modified form, is strictly prohibited without the explicit written permission of the author.
# 3. The Software is provided "as is", without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose, and non-infringement. In no event shall the author or copyright holder be liable for any claim, damages, or other liability, whether in an action of contract, tort, or otherwise, arising from, out of, or in connection with the Software or the use or other dealings in the Software.
# 4. Any use of the Software must include the above copyright notice and this permission notice in all copies or substantial portions of the Software.
# 5. By using the Software, you agree to be bound by the terms and conditions of this license.
# For any inquiries or requests for permissions, please contact codwiz@yandex.ru.
# ---------------------------------------------------------------------------------
# Name: MediaTools
# Description: Powerful tools for working with media files
# Author: @hikka_mods
# ---------------------------------------------------------------------------------
# meta developer: @hikka_mods
# scope: MediaTools
# scope: MediaTools 0.0.1
# ---------------------------------------------------------------------------------
import asyncio
import logging
import math
import os
import re
import shutil
from typing import Optional
from telethon.types import Message
from .. import loader, utils
logger = logging.getLogger(__name__)
def check_ffmpeg():
return shutil.which("ffmpeg") is not None
@loader.tds
class MediaToolsMod(loader.Module):
"""Powerful tools for working with media files"""
strings = {
"name": "MediaTools",
"no_reply": "🚫 Reply to a media file!",
"no_ffmpeg": "❌ FFmpeg is not installed! Install: apt-get install ffmpeg",
"processing": "⚙️ Processing...",
"converted": "✅ Converted to {}",
"downloaded": "✅ Voice message saved",
"gif_created": "✅ GIF created",
"cut_done": "✅ Trimmed",
"circle_done": "✅ Video circle created",
"audio_extracted": "✅ Audio extracted",
"compressed": "✅ Compressed to {}",
"split_done": "✅ Split into {} parts",
"merged": "✅ Merged",
"metadata_removed": "✅ Metadata removed",
"invalid_args": "❌ Invalid arguments",
"error": "❌ Error: {}",
"available_formats": "Available formats:\n🎵 Audio: mp3, flac, wav, aac, ogg, m4a, opus\n🎬 Video: mp4, avi, mkv, mov, wmv, flv, webm, 3gp, hevc, h264",
"cut_usage": "Usage: .cut 20s6ms:8m16s3ms",
"compress_usage": "Available qualities: 144p, 240p, 360p, 480p, 720p, 1080p, 1440p, 2160p",
"split_time_usage": "Example: .split 10m (10 minutes)",
"split_size_usage": "Usage: .split 10m or .split 5MB",
"merge_usage": "Reply to first video/audio",
"min_files": "Need at least 2 media files in chain",
"downloading": "Downloading {} files...",
"part": "Part {}/{}",
}
strings_ru = {
"no_reply": "🚫 Ответьте на медиафайл!",
"no_ffmpeg": "❌ FFmpeg не установлен! Установите: apt-get install ffmpeg",
"processing": "⚙️ Обрабатываю...",
"converted": "✅ Конвертировано в {}",
"downloaded": "✅ Голосовое сохранено",
"gif_created": "✅ GIF создан",
"cut_done": "✅ Обрезано",
"circle_done": "✅ Видео в кружок",
"audio_extracted": "✅ Аудио извлечено",
"compressed": "✅ Сжато до {}",
"split_done": "✅ Разделено на {} частей",
"merged": "✅ Объединено",
"metadata_removed": "✅ Метаданные удалены",
"invalid_args": "❌ Неверные аргументы",
"error": "❌ Ошибка: {}",
"available_formats": "Доступные форматы:\n🎵 Аудио: mp3, flac, wav, aac, ogg, m4a, opus\n🎬 Видео: mp4, avi, mkv, mov, wmv, flv, webm, 3gp, hevc, h264",
"cut_usage": "Используйте: .cut 20сс:8м16сс",
"compress_usage": "Доступные качества: 144p, 240p, 360p, 480p, 720p, 1080p, 1440p, 2160p",
"split_time_usage": "Пример: .split 10m (10 минут)",
"split_size_usage": "Используйте: .split 10m или .split 5MB",
"merge_usage": "Ответьте на первое видео/аудио",
"min_files": "Нужно как минимум 2 медиафайла в цепочке",
"downloading": "Скачиваю {} файлов...",
"part": "Часть {}/{}",
}
async def client_ready(self, client, db):
self._client = client
self._db = db
if not check_ffmpeg():
self.logger.warning(self.strings["no_ffmpeg"])
@loader.command(
ru_doc="<формат> - конвертировать медиа в указанный формат",
en_doc="<format> - convert media to specified format",
)
async def convert(self, message: Message):
reply = await message.get_reply_message()
if not reply or not reply.media:
return await utils.answer(message, self.strings["no_reply"])
if not check_ffmpeg():
return await utils.answer(message, self.strings["no_ffmpeg"])
args = utils.get_args_raw(message).lower()
formats = {
"mp3": "audio",
"flac": "audio",
"wav": "audio",
"aac": "audio",
"ogg": "audio",
"m4a": "audio",
"opus": "audio",
"mp4": "video",
"avi": "video",
"mkv": "video",
"mov": "video",
"wmv": "video",
"flv": "video",
"webm": "video",
"3gp": "video",
"hevc": "video",
"h264": "video",
}
if not args or args not in formats:
return await utils.answer(message, self.strings["available_formats"])
msg = await utils.answer(message, self.strings["processing"])
try:
file = await reply.download_media(file="temp/")
output = f"{file.rsplit('.', 1)[0]}_converted.{args}"
cmd = ["ffmpeg", "-i", file, "-y"]
if formats[args] == "audio":
if args == "mp3":
cmd.extend(["-codec:a", "libmp3lame", "-q:a", "2"])
elif args == "flac":
cmd.extend(["-codec:a", "flac", "-compression_level", "12"])
elif args == "opus":
cmd.extend(["-codec:a", "libopus", "-b:a", "128k"])
elif args == "aac":
cmd.extend(["-codec:a", "aac", "-b:a", "192k"])
elif formats[args] == "video":
if args in ["hevc", "h264"]:
codec = "libx265" if args == "hevc" else "libx264"
cmd.extend(["-codec:v", codec, "-preset", "medium", "-crf", "23"])
if args == "webm":
cmd.extend(["-codec:v", "libvpx-vp9", "-b:v", "1M"])
cmd.append(output)
process = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
await process.communicate()
await message.client.send_file(
message.peer_id,
output,
caption=self.strings["converted"].format(args),
reply_to=reply.id,
)
os.remove(file)
if os.path.exists(output):
os.remove(output)
await msg.delete()
except Exception as e:
await utils.answer(message, self.strings["error"].format(str(e)))
@loader.command(
ru_doc="Скачать голосовое сообщение как файл",
en_doc="Download voice message as file",
)
async def voicedl(self, message: Message):
reply = await message.get_reply_message()
if not reply or not reply.voice:
return await utils.answer(message, self.strings["no_reply"])
msg = await utils.answer(message, self.strings["processing"])
try:
file = await reply.download_media(file="temp/voice.ogg")
new_file = file.replace(".ogg", ".opus")
os.rename(file, new_file)
await message.client.send_file(
message.peer_id,
new_file,
caption=self.strings["downloaded"],
reply_to=reply.id,
voice_note=False,
)
os.remove(new_file)
await msg.delete()
except Exception as e:
await utils.answer(message, self.strings["error"].format(str(e)))
@loader.command(ru_doc="Преобразовать видео в GIF", en_doc="Convert video to GIF")
async def gif(self, message: Message):
if not check_ffmpeg():
return await utils.answer(message, self.strings["no_ffmpeg"])
reply = await message.get_reply_message()
if not reply or not reply.video:
return await utils.answer(message, self.strings["no_reply"])
msg = await utils.answer(message, self.strings["processing"])
try:
file = await reply.download_media(file="temp/")
output = f"{file.rsplit('.', 1)[0]}.gif"
cmd = [
"ffmpeg",
"-i",
file,
"-vf",
"fps=10,scale=480:-1:flags=lanczos",
"-gifflags",
"+transdiff",
"-y",
output,
]
process = await asyncio.create_subprocess_exec(*cmd)
await process.communicate()
await message.client.send_file(
message.peer_id,
output,
caption=self.strings["gif_created"],
reply_to=reply.id,
)
os.remove(file)
os.remove(output)
await msg.delete()
except Exception as e:
await utils.answer(message, self.strings["error"].format(str(e)))
def parse_time(self, time_str: str) -> Optional[float]:
time_str = time_str.lower()
total = 0
pattern = r"(\d+\.?\d*)([мm]?[сc]|[мm][сc]?)"
matches = re.findall(pattern, time_str)
for value, unit in matches:
value = float(value)
if "м" in unit or "m" in unit:
total += value * 60
elif "с" in unit or "c" in unit:
total += value
return total if total > 0 else None
@loader.command(
ru_doc="<начало:конец> - обрезать медиа по времени",
en_doc="<start:end> - trim media by time",
)
async def cut(self, message: Message):
if not check_ffmpeg():
return await utils.answer(message, self.strings["no_ffmpeg"])
reply = await message.get_reply_message()
if not reply or not reply.media:
return await utils.answer(message, self.strings["no_reply"])
args = utils.get_args_raw(message)
if not args or ":" not in args:
return await utils.answer(message, self.strings["cut_usage"])
start_str, end_str = args.split(":", 1)
start = self.parse_time(start_str)
end = self.parse_time(end_str)
if start is None or end is None or start >= end:
return await utils.answer(message, self.strings["invalid_args"])
msg = await utils.answer(message, self.strings["processing"])
try:
file = await reply.download_media(file="temp/")
output = f"{file.rsplit('.', 1)[0]}_cut.{file.rsplit('.', 1)[1]}"
cmd = [
"ffmpeg",
"-i",
file,
"-ss",
str(start),
"-to",
str(end),
"-c",
"copy",
"-avoid_negative_ts",
"make_zero",
"-y",
output,
]
process = await asyncio.create_subprocess_exec(*cmd)
await process.communicate()
await message.client.send_file(
message.peer_id,
output,
caption=self.strings["cut_done"],
reply_to=reply.id,
)
os.remove(file)
os.remove(output)
await msg.delete()
except Exception as e:
await utils.answer(message, self.strings["error"].format(str(e)))
@loader.command(
ru_doc="[начало:конец] - Видео в кружок",
en_doc="[start:end] - Convert video to circle",
)
async def vircle(self, message: Message):
if not check_ffmpeg():
return await utils.answer(message, self.strings["no_ffmpeg"])
reply = await message.get_reply_message()
if not reply or not (reply.video or reply.gif):
return await utils.answer(message, self.strings["no_reply"])
args = utils.get_args_raw(message)
filter_args = ""
if args and ":" in args:
start_str, end_str = args.split(":", 1)
start = self.parse_time(start_str)
end = self.parse_time(end_str)
if start is not None and end is not None and start < end:
filter_args = f",trim=start={start}:end={end},setpts=PTS-STARTPTS"
msg = await utils.answer(message, self.strings["processing"])
try:
file = await reply.download_media(file="temp/")
output = f"{file.rsplit('.', 1)[0]}_circle.mp4"
cmd = [
"ffmpeg",
"-i",
file,
"-vf",
f"scale=720:720:force_original_aspect_ratio=increase,crop=720:720{filter_args},format=rgba,geq='if(gt(X,360),if(gt(Y,360),if(lt(sqrt((X-360)^2+(Y-360)^2),360),p(X,Y),0),0),0)'",
"-c:v",
"libx264",
"-preset",
"fast",
"-crf",
"23",
"-pix_fmt",
"yuv420p",
"-y",
output,
]
process = await asyncio.create_subprocess_exec(*cmd)
await process.communicate()
await message.client.send_file(
message.peer_id,
output,
caption=self.strings["circle_done"],
reply_to=reply.id,
video_note=True,
)
os.remove(file)
os.remove(output)
await msg.delete()
except Exception as e:
await utils.answer(message, self.strings["error"].format(str(e)))
@loader.command(
ru_doc="[начало:конец] - Извлечь аудио из видео",
en_doc="[start:end] - Extract audio from video",
)
async def vsound(self, message: Message):
if not check_ffmpeg():
return await utils.answer(message, self.strings["no_ffmpeg"])
reply = await message.get_reply_message()
if not reply or not reply.video:
return await utils.answer(message, self.strings["no_reply"])
args = utils.get_args_raw(message)
msg = await utils.answer(message, self.strings["processing"])
try:
file = await reply.download_media(file="temp/")
output = f"{file.rsplit('.', 1)[0]}_audio.mp3"
cmd = ["ffmpeg", "-i", file]
if args and ":" in args:
start_str, end_str = args.split(":", 1)
start = self.parse_time(start_str)
end = self.parse_time(end_str)
if start is not None and end is not None and start < end:
cmd.extend(["-ss", str(start), "-to", str(end)])
cmd.extend(["-q:a", "2", "-map", "a", "-y", output])
process = await asyncio.create_subprocess_exec(*cmd)
await process.communicate()
await message.client.send_file(
message.peer_id,
output,
caption=self.strings["audio_extracted"],
reply_to=reply.id,
)
os.remove(file)
os.remove(output)
await msg.delete()
except Exception as e:
await utils.answer(message, self.strings["error"].format(str(e)))
@loader.command(
ru_doc="<качество> - Сжать видео", en_doc="<quality> - Compress video"
)
async def compress(self, message: Message):
if not check_ffmpeg():
return await utils.answer(message, self.strings["no_ffmpeg"])
reply = await message.get_reply_message()
if not reply or not reply.video:
return await utils.answer(message, self.strings["no_reply"])
args = utils.get_args_raw(message).lower()
resolutions = {
"144p": "256x144",
"240p": "426x240",
"360p": "640x360",
"480p": "854x480",
"720p": "1280x720",
"1080p": "1920x1080",
"1440p": "2560x1440",
"2160p": "3840x2160",
}
if not args or args not in resolutions:
return await utils.answer(message, self.strings["compress_usage"])
msg = await utils.answer(message, self.strings["processing"])
try:
file = await reply.download_media(file="temp/")
output = f"{file.rsplit('.', 1)[0]}_compressed.mp4"
probe_cmd = [
"ffprobe",
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=bit_rate",
"-of",
"default=noprint_wrappers=1:nokey=1",
file,
]
process = await asyncio.create_subprocess_exec(
*probe_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await process.communicate()
original_bitrate = stdout.decode().strip()
scale_factor = {
"144p": 0.1,
"240p": 0.2,
"360p": 0.3,
"480p": 0.4,
"720p": 0.6,
"1080p": 0.8,
"1440p": 0.9,
"2160p": 1.0,
}
target_bitrate = "500k"
if original_bitrate and original_bitrate.isdigit():
original_br = int(original_bitrate)
target_br = int(original_br * scale_factor[args] / 1000)
target_bitrate = f"{max(200, target_br)}k"
cmd = [
"ffmpeg",
"-i",
file,
"-vf",
f"scale={resolutions[args]}:force_original_aspect_ratio=decrease",
"-c:v",
"libx264",
"-preset",
"medium",
"-b:v",
target_bitrate,
"-maxrate",
target_bitrate,
"-bufsize",
f"{int(target_bitrate[:-1]) * 2}k",
"-c:a",
"aac",
"-b:a",
"128k",
"-y",
output,
]
process = await asyncio.create_subprocess_exec(*cmd)
await process.communicate()
await message.client.send_file(
message.peer_id,
output,
caption=self.strings["compressed"].format(args),
reply_to=reply.id,
)
os.remove(file)
os.remove(output)
await msg.delete()
except Exception as e:
await utils.answer(message, self.strings["error"].format(str(e)))
@loader.command(
ru_doc="<время/размер> - Разделить медиа на части",
en_doc="<time/size> - Split media into parts",
)
async def split(self, message: Message):
if not check_ffmpeg():
return await utils.answer(message, self.strings["no_ffmpeg"])
reply = await message.get_reply_message()
if not reply or not reply.media:
return await utils.answer(message, self.strings["no_reply"])
args = utils.get_args_raw(message).lower()
msg = await utils.answer(message, self.strings["processing"])
try:
file = await reply.download_media(file="temp/")
file_ext = file.rsplit(".", 1)[1]
if "m" in args or "м" in args:
duration = self.parse_time(args)
if not duration:
await msg.edit(self.strings["split_time_usage"])
os.remove(file)
return
probe_cmd = [
"ffprobe",
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
file,
]
process = await asyncio.create_subprocess_exec(
*probe_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await process.communicate()
total_duration = float(stdout.decode().strip())
parts = math.ceil(total_duration / duration)
for i in range(parts):
start = i * duration
end = min((i + 1) * duration, total_duration)
output = f"{file.rsplit('.', 1)[0]}_part{i + 1}.{file_ext}"
split_cmd = [
"ffmpeg",
"-i",
file,
"-ss",
str(start),
"-to",
str(end),
"-c",
"copy",
"-avoid_negative_ts",
"make_zero",
"-y",
output,
]
process = await asyncio.create_subprocess_exec(*split_cmd)
await process.communicate()
await message.client.send_file(
message.peer_id,
output,
caption=self.strings["part"].format(i + 1, parts),
reply_to=reply.id if i == 0 else None,
)
os.remove(output)
await msg.edit(self.strings["split_done"].format(parts))
elif "mb" in args or "мб" in args:
await utils.answer(
message, "Size splitting requires additional implementation"
)
else:
await msg.edit(self.strings["split_size_usage"])
os.remove(file)
except Exception as e:
await utils.answer(message, self.strings["error"].format(str(e)))
@loader.command(
ru_doc="Объединить несколько медиафайлов", en_doc="Merge multiple media files"
)
async def merge(self, message: Message):
if not check_ffmpeg():
return await utils.answer(message, self.strings["no_ffmpeg"])
reply = await message.get_reply_message()
if not reply:
return await utils.answer(message, self.strings["merge_usage"])
messages = []
current = reply
while current and current.media:
messages.append(current)
current = await current.get_reply_message()
if len(messages) < 2:
return await utils.answer(message, self.strings["min_files"])
msg = await utils.answer(
message, self.strings["downloading"].format(len(messages))
)
try:
files = []
file_list = "temp/filelist.txt"
with open(file_list, "w") as f:
for i, msg_file in enumerate(messages):
filename = f"temp/merge_{i}.{msg_file.file.ext if msg_file.file else 'mp4'}"
await msg_file.download_media(file=filename)
files.append(filename)
f.write(f"file '{os.path.abspath(filename)}'\n")
output = "temp/merged.mp4"
cmd = [
"ffmpeg",
"-f",
"concat",
"-safe",
"0",
"-i",
file_list,
"-c",
"copy",
"-y",
output,
]
process = await asyncio.create_subprocess_exec(*cmd)
await process.communicate()
await message.client.send_file(
message.peer_id,
output,
caption=self.strings["merged"],
reply_to=reply.id,
)
for file in files:
if os.path.exists(file):
os.remove(file)
os.remove(file_list)
os.remove(output)
await msg.delete()
except Exception as e:
await utils.answer(message, self.strings["error"].format(str(e)))
@loader.command(
ru_doc="Удалить метаданные из медиа", en_doc="Remove metadata from media"
)
async def removemetadata(self, message: Message):
if not check_ffmpeg():
return await utils.answer(message, self.strings["no_ffmpeg"])
reply = await message.get_reply_message()
if not reply or not reply.media:
return await utils.answer(message, self.strings["no_reply"])
msg = await utils.answer(message, self.strings["processing"])
try:
file = await reply.download_media(file="temp/")
file_ext = file.rsplit(".", 1)[1]
output = f"{file.rsplit('.', 1)[0]}_nometa.{file_ext}"
cmd = ["ffmpeg", "-i", file, "-map_metadata", "-1", "-y", output]
process = await asyncio.create_subprocess_exec(*cmd)
await process.communicate()
await message.client.send_file(
message.peer_id,
output,
caption=self.strings["metadata_removed"],
reply_to=reply.id,
)
os.remove(file)
os.remove(output)
await msg.delete()
except Exception as e:
await utils.answer(message, self.strings["error"].format(str(e)))