diff --git a/README.md b/README.md index 45e44118267b..bb189da1e3a2 100644 --- a/README.md +++ b/README.md @@ -383,6 +383,8 @@ programming in Python. - `(text)` will get removed - `[test]` will get replaced by test - `\text\` will get replaced by text with sensitive case + - `METADATA_TXT`: Edit metadata of the video. `Str` + - `META_ATTACHMENT`: Add attachment to the metadata. `Str` **12. Super Group Features** diff --git a/bot/__init__.py b/bot/__init__.py index 0ef32f14d918..ebb66518fb54 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -456,6 +456,20 @@ if len(LEECH_CAPTION_FONT) == 0: LEECH_CAPTION_FONT = "" +METADATA_TXT = environ.get( + "METADATA_TXT", + "" +) +if len(METADATA_TXT) == 0: + METADATA_TXT = "" + +META_ATTACHMENT = environ.get( + "META_ATTACHMENT", + "" +) +if len(META_ATTACHMENT) == 0: + META_ATTACHMENT = "" + SEARCH_PLUGINS = environ.get( "SEARCH_PLUGINS", "" @@ -1098,6 +1112,8 @@ "MIXED_LEECH": MIXED_LEECH, "MEGA_LIMIT": MEGA_LIMIT, "MINIMUM_DURATOIN": MINIMUM_DURATOIN, + "METADATA_TXT": METADATA_TXT, + "META_ATTACHMENT": META_ATTACHMENT, "NAME_SUBSTITUTE": NAME_SUBSTITUTE, "NZB_LIMIT": NZB_LIMIT, "PLAYLIST_LIMIT": PLAYLIST_LIMIT, diff --git a/bot/helper/common.py b/bot/helper/common.py index a2881b83aba7..0fc0a9cb1924 100644 --- a/bot/helper/common.py +++ b/bot/helper/common.py @@ -60,8 +60,10 @@ is_telegram_link, ) from bot.helper.ext_utils.media_utils import ( + add_attachment, createThumb, createSampleVideo, + edit_video_metadata, take_ss, ) from bot.helper.ext_utils.media_utils import ( @@ -79,6 +81,7 @@ from bot.helper.task_utils.status_utils.media_convert_status import ( MediaConvertStatus, ) +from bot.helper.task_utils.status_utils.meta_status import MetaStatus from bot.helper.task_utils.status_utils.split_status import SplitStatus from bot.helper.task_utils.status_utils.zip_status import ZipStatus from bot.helper.telegram_helper.bot_commands import BotCommands @@ -120,6 +123,8 @@ def __init__(self): self.mode = "" self.time = "" self.chatId = "" + self.metaData = None + self.metaAttachment = None self.getChat = None self.splitSize = 0 self.maxSplitSize = 0 @@ -392,6 +397,16 @@ async def beforeStart(self): "!qB" ] ) + self.metaData = self.metaData or self.userDict.get("metatxt") or ( + config_dict["METADATA_TXT"] + if "metatxt" not in self.userDict + else False + ) + self.metaAttachment = self.metaAttachment or self.userDict.get("attachmenturl") or ( + config_dict["META_ATTACHMENT"] + if "attachmenturl" not in self.userDict + else False + ) if self.link not in [ "rcl", "gdl" @@ -1170,7 +1185,11 @@ async def proceedCompress(self, dl_path, gid, o_files, ft_delete): async def proceedSplit(self, up_dir, m_size, o_files, gid): checked = False - for dirpath, _, files in await sync_to_async( + for ( + dirpath, + _, + files + ) in await sync_to_async( walk, up_dir, topdown=False @@ -1702,3 +1721,35 @@ async def substitute(self, dl_path): ) ) return dl_path + + async def proceedMetadata(self, up_path, gid): + ( + is_video, + _, + _ + ) = await get_document_type(up_path) + if is_video: + async with task_dict_lock: + task_dict[self.mid] = MetaStatus( + self, + gid + ) + LOGGER.info(f"Editing Metadata: {self.metaData} into {up_path}") + await edit_video_metadata( + self, + up_path + ) + return up_path + + async def proceedAttachment(self, up_path, gid): + async with task_dict_lock: + task_dict[self.mid] = MetaStatus( + self, + gid + ) + LOGGER.info(f"Adding Attachment: {self.metaAttachment} into {up_path}") + await add_attachment( + self, + up_path + ) + return up_path diff --git a/bot/helper/ext_utils/media_utils.py b/bot/helper/ext_utils/media_utils.py index 773b0cc8b7b7..0261701f3178 100644 --- a/bot/helper/ext_utils/media_utils.py +++ b/bot/helper/ext_utils/media_utils.py @@ -1,3 +1,4 @@ +from pathlib import Path from PIL import Image from aiofiles.os import ( remove, @@ -165,7 +166,7 @@ async def createThumb(msg, _id=""): await remove(photo_dir) return des_dir - +global_streams = {} async def is_multi_streams(path): try: result = await cmd_exec( @@ -189,8 +190,8 @@ async def is_multi_streams(path): ): fields = eval(result[0]).get("streams") if fields is None: - LOGGER.error(f"get_video_streams: {result}") return False + global_streams["stream"] = fields videos = 0 audios = 0 for stream in fields: @@ -804,3 +805,260 @@ async def createSampleVideo(listener, video_file, sample_duration, part_duration if await aiopath.exists(output_file): await remove(output_file) return False + + +SUPPORTED_VIDEO_EXTENSIONS = { + ".mp4", + ".mkv" +} + + +async def edit_video_metadata(listener, dir): + + data = listener.metaData + dir_path = Path(dir) + + if dir_path.suffix.lower() not in SUPPORTED_VIDEO_EXTENSIONS: + return dir + + file_name = dir_path.name + work_path = dir_path.with_suffix(".temp.mkv") + + await is_multi_streams(dir) + + cmd = [ + "ffmpeg", + "-y", + "-i", + dir, + "-c", + "copy", + "-metadata", + f"title={data}", + "-threads", + f"{cpu_count() // 2}", # type: ignore + ] + + meta_info = [ + "copyright", + "description", + "license", + "LICENSE", + "author", + "summary", + "comment", + "artist", + "album", + "genre", + "date", + "creation_time", + "language", + "publisher", + "encoder", + "SUMMARY", + "AUTHOR", + "WEBSITE", + "COMMENT", + "ENCODER", + "FILENAME", + "MIMETYPE", + "PURL", + "ALBUM" + ] + + for field in meta_info: + cmd.extend([ + "-metadata", + f"{field}=" + ]) + + audio_index = 0 + subtitle_index = 0 + first_video = False + + if global_streams: + for stream in global_streams["stream"]: + stream_index = stream["index"] + stream_type = stream["codec_type"] + + if stream_type == "video": + if not first_video: + cmd.extend([ + "-map", + f"0:{stream_index}" + ]) + first_video = True + cmd.extend([ + f"-metadata:s:v:{stream_index}", + f"title={data}" + ]) + + elif stream_type == "audio": + cmd.extend([ + "-map", + f"0:{stream_index}", + f"-metadata:s:a:{audio_index}", + f"title={data}" + ]) + audio_index += 1 + + elif stream_type == "subtitle": + codec_name = stream.get( + "codec_name", + "unknown" + ) + if codec_name not in [ + "webvtt", + "unknown" + ]: + cmd.extend([ + "-map", f"0:{stream_index}", + f"-metadata:s:s:{subtitle_index}", + f"title={data}" + ]) + subtitle_index += 1 + else: + LOGGER.info(f"Skipping unsupported subtitle metadata modification: {codec_name} for stream {stream_index}") + + else: + cmd.extend([ + "-map", + f"0:{stream_index}" + ]) + + else: + LOGGER.info("No streams found. Skipping stream metadata modification.") + return dir + + cmd.append(work_path) + LOGGER.info(f"Modifying metadata for file: {file_name}") + + try: + async with subprocess_lock: + if listener.isCancelled: + if work_path.exists(): + work_path.unlink() + return + listener.suproc = await create_subprocess_exec( + *cmd, + stderr=PIPE, + stdout=PIPE + ) + ( + _, + stderr + ) = await listener.suproc.communicate() + + if listener.suproc.returncode != 0: + if work_path.exists(): + work_path.unlink() + if listener.isCancelled: + return + err = stderr.decode().strip() + LOGGER.error(f"Error modifying metadata for file: {file_name} | {err}") + return dir + + if work_path.exists(): + work_path.replace(dir_path) + LOGGER.info(f"Metadata modified successfully for file: {file_name}") + else: + LOGGER.error(f"Temporary file {work_path} not found. Metadata modification failed.") + + except ( + RuntimeError, + OSError + ) as e: + LOGGER.error(f"Error modifying metadata: {str(e)}") + if work_path.exists(): + work_path.unlink() + return dir + + finally: + if work_path.exists(): + work_path.unlink() + + return dir + + +async def add_attachment(listener, dir): + + MIME_TYPES = { + "jpg": "image/jpeg", + "jpeg": "image/jpeg", + "png": "image/png", + } + + data = listener.metaAttachment + dir_path = Path(dir) + + if dir_path.suffix.lower() not in SUPPORTED_VIDEO_EXTENSIONS: + return dir + + file_name = dir_path.name + work_path = dir_path.with_suffix(".temp.mkv") + + data_ext = data.split(".")[-1].lower() + if not (mime_type := MIME_TYPES.get(data_ext)): + LOGGER.error(f"Unsupported attachment type: {data_ext}") + return dir + + cmd = [ + "ffmpeg", + "-y", + "-i", + dir, + "-attach", + data, + "-metadata:s:t", + f"mimetype={mime_type}", + "-c", + "copy", + "-map", + "0", + work_path + ] + + try: + async with subprocess_lock: + if listener.isCancelled: + if work_path.exists(): + work_path.unlink() + return + listener.suproc = await create_subprocess_exec( + *cmd, + stderr=PIPE, + stdout=PIPE + ) + ( + _, + _ + ) = await listener.suproc.communicate() + + if listener.suproc.returncode != 0: + if work_path.exists(): + work_path.unlink() + if listener.isCancelled: + return + LOGGER.error(f"Error adding photo attachment to file: {file_name}") + return dir + + if work_path.exists(): + work_path.replace(dir_path) + LOGGER.info(f"Photo attachment added successfully to file: {file_name}") + else: + LOGGER.error(f"Temporary file {work_path} not found. Adding photo attachment failed.") + + except ( + RuntimeError, + OSError + ) as e: + LOGGER.error(f"Error adding photo attachment: {str(e)}") + if work_path.exists(): + work_path.unlink() + return dir + + finally: + if work_path.exists(): + work_path.unlink() + + return dir diff --git a/bot/helper/ext_utils/status_utils.py b/bot/helper/ext_utils/status_utils.py index ffa5dcdb1500..85b059a4ac80 100644 --- a/bot/helper/ext_utils/status_utils.py +++ b/bot/helper/ext_utils/status_utils.py @@ -44,6 +44,7 @@ class MirrorStatus: STATUS_SEEDING = "Seed 🌧" STATUS_SAMVID = "SampleVid 🎬" STATUS_CONVERTING = "Convert ♻️" + STATUS_METADATA = "Metadata 📝" STATUSES = { @@ -61,6 +62,7 @@ class MirrorStatus: "CK": MirrorStatus.STATUS_CHECKING, "SV": MirrorStatus.STATUS_SAMVID, "PA": MirrorStatus.STATUS_PAUSED, + "MD": MirrorStatus.STATUS_METADATA } @@ -264,7 +266,9 @@ async def get_readable_message( ) if tstatus not in [ MirrorStatus.STATUS_SEEDING, + MirrorStatus.STATUS_QUEUEDL, MirrorStatus.STATUS_QUEUEUP, + MirrorStatus.STATUS_METADATA ]: progress = ( await task.progress() diff --git a/bot/helper/listeners/task_listener.py b/bot/helper/listeners/task_listener.py index 2d7d037c6280..64c41758e912 100644 --- a/bot/helper/listeners/task_listener.py +++ b/bot/helper/listeners/task_listener.py @@ -301,6 +301,22 @@ async def onDownloadComplete(self): ) self.size = await get_path_size(up_dir) + if self.metaData: + await self.proceedMetadata( + up_path, + gid + ) + if self.isCancelled: + return + + if self.metaAttachment: + await self.proceedAttachment( + up_path, + gid + ) + if self.isCancelled: + return + if self.isLeech and not self.compress: await self.proceedSplit( up_dir, diff --git a/bot/helper/task_utils/status_utils/meta_status.py b/bot/helper/task_utils/status_utils/meta_status.py new file mode 100644 index 000000000000..bc20cb5e8d71 --- /dev/null +++ b/bot/helper/task_utils/status_utils/meta_status.py @@ -0,0 +1,63 @@ +from bot import ( + LOGGER, + subprocess_lock +) +from bot.helper.ext_utils.status_utils import ( + get_readable_file_size, + get_readable_time, + MirrorStatus +) +from subprocess import run as frun +from time import time +from bot.helper.ext_utils.files_utils import get_path_size + + +class MetaStatus: + def __init__( + self, + listener, + gid + ): + self.listener = listener + self._gid = gid + self._size = self.listener.size + self._start_time = time() + self._proccessed_bytes = 0 + self.engine = f"FFmpeg v{self._eng_ver()}" + + def _eng_ver(self): + _engine = frun( + [ + "ffmpeg", + "-version" + ], + capture_output=True, + text=True + ) + return _engine.stdout.split("\n")[0].split(" ")[2].split("-")[0] + + def gid(self): + return self._gid + + def name(self): + return self.listener.name + + def size(self): + return get_readable_file_size(self._size) + + def status(self): + return MirrorStatus.STATUS_METADATA + + def task(self): + return self + + async def cancel_task(self): + LOGGER.info(f"Cancelling metadata editor: {self.listener.name}") + self.listener.isCancelled = True + async with subprocess_lock: + if ( + self.listener.suproc is not None + and self.listener.suproc.returncode is None + ): + self.listener.suproc.kill() + await self.listener.onUploadError("Metadata editing stopped by user!") diff --git a/bot/helper/task_utils/status_utils/queue_status.py b/bot/helper/task_utils/status_utils/queue_status.py index cdde4f3a12cf..ce8b0c475d18 100644 --- a/bot/helper/task_utils/status_utils/queue_status.py +++ b/bot/helper/task_utils/status_utils/queue_status.py @@ -35,18 +35,6 @@ def status(self): return MirrorStatus.STATUS_QUEUEDL return MirrorStatus.STATUS_QUEUEUP - def processed_bytes(self): - return 0 - - def progress(self): - return "0%" - - def speed(self): - return "0B/s" - - def eta(self): - return "-" - def task(self): return self diff --git a/bot/helper/task_utils/telegram_uploader.py b/bot/helper/task_utils/telegram_uploader.py index 30c917fab049..95ff9e0b1649 100644 --- a/bot/helper/task_utils/telegram_uploader.py +++ b/bot/helper/task_utils/telegram_uploader.py @@ -372,7 +372,10 @@ async def _send_screenshots(self, dirpath, outputs): self._sent_DMmsg = None async def _send_media_group(self, subkey, key, msgs): - for index, msg in enumerate(msgs): + for ( + index, + msg + ) in enumerate(msgs): if self._listener.mixedLeech or not self._user_session: # type: ignore msgs[index] = await self._listener.client.get_messages( chat_id=msg[0], @@ -429,7 +432,11 @@ async def upload(self, o_files, ft_delete): res = await self._msg_to_reply() if not res: return - for dirpath, _, files in natsorted( + for ( + dirpath, + _, + files + ) in natsorted( await sync_to_async( walk, self._path @@ -497,8 +504,14 @@ async def upload(self, o_files, ft_delete): and match.group(0) not in group_lists ): - for key, value in list(self._media_dict.items()): - for subkey, msgs in list(value.items()): + for ( + key, + value + ) in list(self._media_dict.items()): + for ( + subkey, + msgs + ) in list(value.items()): if len(msgs) > 1: await self._send_media_group( subkey, @@ -560,7 +573,10 @@ async def upload(self, o_files, ft_delete): ) ): await remove(self._up_path) - for key, value in list(self._media_dict.items()): + for ( + key, + value + ) in list(self._media_dict.items()): for subkey, msgs in list(value.items()): if len(msgs) > 1: try: @@ -635,7 +651,7 @@ async def _send_dm(self): except Exception as err: if isinstance(err, RPCError): LOGGER.error( - f"Error while sending dm {err.NAME}: {err.MESSAGE}") + f"Error while sending dm {err.NAME}: {err.MESSAGE}") # type: ignore else: LOGGER.error( f"Error while sending dm {err.__class__.__name__}") @@ -659,7 +675,11 @@ async def _upload_file(self, cap_mono, file, o_path, force_document=False): thumb = self._thumb self._is_corrupted = False try: - is_video, is_audio, is_image = await get_document_type(self._up_path) + ( + is_video, + is_audio, + is_image + ) = await get_document_type(self._up_path) if not is_image and thumb is None: file_name = ospath.splitext(file)[0] @@ -706,7 +726,10 @@ async def _upload_file(self, cap_mono, file, o_path, force_document=False): ) if thumb is not None: with Image.open(thumb) as img: - width, height = img.size + ( + width, + height + ) = img.size else: width = 480 height = 320 diff --git a/bot/modules/bot_settings.py b/bot/modules/bot_settings.py index 447645f24f2e..23e1b8c4b8dc 100644 --- a/bot/modules/bot_settings.py +++ b/bot/modules/bot_settings.py @@ -1968,6 +1968,20 @@ async def load_config(): if len(LEECH_CAPTION_FONT) == 0: LEECH_CAPTION_FONT = "" + METADATA_TXT = environ.get( + "METADATA_TXT", + "" + ) + if len(METADATA_TXT) == 0: + METADATA_TXT = "" + + META_ATTACHMENT = environ.get( + "META_ATTACHMENT", + "" + ) + if len(META_ATTACHMENT) == 0: + META_ATTACHMENT = "" + SEARCH_PLUGINS = environ.get( "SEARCH_PLUGINS", "" @@ -2724,6 +2738,8 @@ async def load_config(): "LEECH_SPLIT_SIZE": LEECH_SPLIT_SIZE, "MEDIA_GROUP": MEDIA_GROUP, "MIXED_LEECH": MIXED_LEECH, + "METADATA_TXT": METADATA_TXT, + "META_ATTACHMENT": META_ATTACHMENT, "NAME_SUBSTITUTE": NAME_SUBSTITUTE, "OWNER_ID": OWNER_ID, "QUEUE_ALL": QUEUE_ALL, diff --git a/bot/modules/mirror_leech.py b/bot/modules/mirror_leech.py index 527eec4de0f2..e2702f0d3046 100644 --- a/bot/modules/mirror_leech.py +++ b/bot/modules/mirror_leech.py @@ -140,6 +140,8 @@ async def newEvent(self): "-ca": "", "-convertaudio": "", "-cv": "", "-convertvideo": "", "-ns": "", "-namesub": "", + "-md": "", "-metadata": "", + "-mda": "", "-metaattachment": "", } arg_parser( @@ -167,6 +169,8 @@ async def newEvent(self): self.convertVideo = args["-cv"] or args["-convertvideo"] self.nameSub = args["-ns"] or args["-namesub"] self.mixedLeech = args["-ml"] or args["-mixedleech"] + self.metaData = args["-md"] or args["-metadata"] + self.metaAttachment = args["-mda"] or args["-metaattachment"] headers = args["-h"] or args["-headers"] isBulk = args["-b"] or args["-bulk"] diff --git a/bot/modules/users_settings.py b/bot/modules/users_settings.py index 537034394bd9..1a812ff31bd6 100644 --- a/bot/modules/users_settings.py +++ b/bot/modules/users_settings.py @@ -190,6 +190,22 @@ async def get_user_settings(from_user): else: mixed_leech = "Disabled" + if user_dict.get( + "metatxt", + False + ): + metatxt = "Added" + else: + metatxt = "Not Added" + + if user_dict.get( + "attachmenturl", + False + ): + attachmenturl = "Added" + else: + attachmenturl = "Not Added" + buttons.ibutton( "ʟᴇᴇᴄʜ\nꜱᴇᴛᴛɪɴɢꜱ", f"userset {user_id} leech" @@ -352,6 +368,8 @@ async def get_user_settings(from_user): Leech Cap Font : {lcapfont} Leech Split Size : {split_size} Leech Destination: {leech_dest} +Metadata Text : {metatxt} +Attachment Url : {attachmenturl} Thumbnail : {thumbmsg} Equal Splits : {equal_splits} @@ -654,6 +672,8 @@ async def edit_user_settings(client, query): "yt_opt", "lprefix", "lsuffix", + "metatxt", + "attachmenturl", "lcapfont", "index_url", "name_sub", @@ -877,7 +897,28 @@ async def edit_user_settings(client, query): ) else: mixed_leech = "Disabled" - + buttons.ibutton( + "ᴍᴇᴛᴀᴅᴀᴛᴀ\nᴛᴇxᴛ", + f"userset {user_id} metadata_text" + ) + if user_dict.get( + "metatxt", + False + ): + metatxt = user_dict["metatxt"] + else: + metatxt = "None" + buttons.ibutton( + "ᴀᴛᴛᴀᴄʜᴍᴇɴᴛ\nᴜʀʟ", + f"userset {user_id} attachment_url" + ) + if user_dict.get( + "attachmenturl", + False + ): + attachmenturl = user_dict["attachmenturl"] + else: + attachmenturl = "None" buttons.ibutton( "ʙᴀᴄᴋ", f"userset {user_id} back", @@ -897,6 +938,8 @@ async def edit_user_settings(client, query): Leech Suffix : {escape(lsuffix)} Leech Cap Font : {escape(lcapfont)} Leech Destination: {leech_dest} +Metadata Text : {escape(metatxt)} +Attachment Url : {escape(attachmenturl)} Thumbnail : {thumbmsg} Equal Splits : {equal_splits} @@ -1428,6 +1471,94 @@ async def edit_user_settings(client, query): ), update_user_settings(query) ) + + elif data[2] == "metadata_text": + await query.answer() + buttons = ButtonMaker() + if ( + user_dict.get( + "metatxt", + False + ) + ): + buttons.ibutton( + "ʀᴇᴍᴏᴠᴇ\nᴍᴇᴛᴀᴅᴀᴛᴀ ᴛᴇxᴛ", + f"userset {user_id} metatxt" + ) + buttons.ibutton( + "ʙᴀᴄᴋ", + f"userset {user_id} leech" + ) + buttons.ibutton( + "ᴄʟᴏꜱᴇ", + f"userset {user_id} close" + ) + await editMessage( + message, + "Send Leech Metadata Text, Whatever You want to add in the Videos.\n\nTimeout: 60 sec", + buttons.build_menu(1), + ) + try: + event = await event_handler( + client, + query + ) + except ListenerTimeout: + await update_user_settings(query) + except ListenerStopped: + pass + else: + await gather( + set_option( + event, + "metatxt" + ), + update_user_settings(query) + ) + + elif data[2] == "attachment_url": + await query.answer() + buttons = ButtonMaker() + if ( + user_dict.get( + "attachmenturl", + False + ) + ): + buttons.ibutton( + "ʀᴇᴍᴏᴠᴇ ᴀᴛᴛᴀᴄʜᴍᴇɴᴛ ᴜʀʟ", + f"userset {user_id} attachmenturl" + ) + buttons.ibutton( + "ʙᴀᴄᴋ", + f"userset {user_id} leech" + ) + buttons.ibutton( + "ᴄʟᴏꜱᴇ", + f"userset {user_id} close" + ) + await editMessage( + message, + "Send Leech Attachment Url, which you want to get embedded with the video.\n\nTimeout: 60 sec", + buttons.build_menu(1), + ) + try: + event = await event_handler( + client, + query + ) + except ListenerTimeout: + await update_user_settings(query) + except ListenerStopped: + pass + else: + await gather( + set_option( + event, + "attachmenturl" + ), + update_user_settings(query) + ) elif data[2] == "leech_suffix": await query.answer() buttons = ButtonMaker() diff --git a/config_sample.env b/config_sample.env index 36b4097aaf8e..00c026d827b0 100644 --- a/config_sample.env +++ b/config_sample.env @@ -64,6 +64,8 @@ USER_TRANSMISSION = "True" # True or False MIXED_LEECH = "True" # True or False USER_LEECH_DESTINATION = "PM" # PM or DM or chat_id NAME_SUBSTITUTE = "" +METADATA_TXT = "" +META_ATTACHMENT = "" # Super Group Settings REQUEST_LIMITS = "4" # Enter only numbers in seconds