commit b9bef86ea296fb48c32ea8de01f0d9c8aa80b2be Author: panitan103 Date: Mon Mar 30 13:43:29 2026 +0700 initial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1b610da --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv +log/ +.ruff* +test*.py +.env +app.sqlite \ No newline at end of file diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/discord_torrent.py b/discord_torrent.py new file mode 100644 index 0000000..3f43512 --- /dev/null +++ b/discord_torrent.py @@ -0,0 +1,403 @@ +import discord +from discord.ext import commands +from discord import app_commands +from lib.torrent_creator import TorrentUpload +from lib.logging_data import logger +from lib.sonarr import Sonarr_API + +from dotenv import load_dotenv +from typing import Optional +import os +import asyncio +from pathlib import Path + +load_dotenv(".env") +# Bot configuration +intents = discord.Intents.default() +intents.message_content = True +intents.members = True + +console = logger(app_name="torrent_uploader",log_dir="./log") + +class DownloadBot(commands.Bot): + def __init__(self): + super().__init__( + command_prefix='!', + intents=intents, + help_command=None + ) + + # Initialize data storage (in-memory for this example) + # In production, you'd want to use a database + self.upload_queue = [] + + async def setup_hook(self): + """Called when the bot is starting up""" + console.log(f"Logged in as {self.user} (ID: {self.user.id})") + console.log("------") + + # Sync slash commands + try: + synced = await self.tree.sync() + console.log(f"Synced {len(synced)} command(s)") + # threading.Thread(target=vt_worker).start() # Start the download worker in the background + except Exception as e: + console.error(f"Failed to sync commands: {e}") +bot = DownloadBot() +console.client=bot +sonarr=Sonarr_API(os.getenv("sonarr_ip"),os.getenv("sonarr_key")) +@bot.event +async def on_ready(): + console.log(f'{bot.user} has connected to Discord!') + activity = discord.Game(name="Managing upload | /help") + await bot.change_presence(activity=activity) + asyncio.create_task(torrent_worker()) + +@bot.tree.error +async def on_app_command_error(interaction: discord.Interaction, error: app_commands.AppCommandError): + if isinstance(error, app_commands.CheckFailure): + embed = discord.Embed( + title="❌ Permission Denied", + description="You don't have permission to use this command.", + color=0xff0000 + ) + if not interaction.response.is_done(): + await interaction.response.send_message(embed=embed, ephemeral=True) + return + + embed = discord.Embed( + title="❌ Error", + description=f"An error occurred: {str(error)}", + color=0xff0000 + ) + + channel = bot.get_channel(interaction.channel_id) + if interaction.response.is_done(): + await interaction.followup.send(embed=embed, ephemeral=True) + else: + await channel.send(embed=embed) + console.error(error) + + +# /upload command +@bot.tree.command(name="sonarr_upload", description="Create and Upload a torrent file to Torrent server from Sonarr") +@app_commands.describe( + sonarr_id="Sonarr title id", + season="Sonarr season", + episode="Sonarr episode", + category="Category of the torrent (e.g., Anime, Western Series, Hi-Def Movie)", + source_type="Source type of the torrent (e.g., Blu-ray, WEB-DL, Encode)", + source="Source of the torrent (e.g., Master, Zoom, TV)", + country="Country of the torrent (e.g., Thai, Western, Korean)", + original_platform="Original platform of the torrent (e.g., DVD, Hi-def, TV)", + is_subdir="Is the torrent in a subdirectory?", + bearbit="Bearbit flag (True/False)", + torrentdd="TorrentDD flag (True/False)", + is_movie="Is this a movie upload? (True/False)", + pack="Pack flag (True/False)", + # single_season="Single Season (True/False)" +) +@app_commands.choices(category=[ + ### Anime + app_commands.Choice(name="Anime", value="Anime"), + ### Series + app_commands.Choice(name="Western Series", value="Western Series"), + app_commands.Choice(name="Korean Series", value="Korean Series"), + app_commands.Choice(name="Japanese Series", value="Japanese Series"), + app_commands.Choice(name="Chinese Series", value="Chinese Series"), + app_commands.Choice(name="Thai Series", value="Thai Series"), + app_commands.Choice(name="Other Series", value="Other Series"), + ### Movies + app_commands.Choice(name="Hi-Def Movie", value="Hi-Def Movie"), + app_commands.Choice(name="4K", value="4K"), + ### Documentaries + app_commands.Choice(name="Documentary", value="สารคดี"), +]) +@app_commands.choices(source_type=[ + app_commands.Choice(name="Blu-ray", value="Blu-ray"), + app_commands.Choice(name="CD", value="CD"), + app_commands.Choice(name="DVD5", value="DVD5"), + app_commands.Choice(name="DVD9", value="DVD9"), + app_commands.Choice(name="Encode", value="Encode"), + app_commands.Choice(name="HD DVD", value="HD DVD"), + app_commands.Choice(name="HDTV", value="HDTV"), + app_commands.Choice(name="MiniBD", value="MiniBD"), + app_commands.Choice(name="Remux", value="Remux"), + app_commands.Choice(name="Track", value="Track"), + app_commands.Choice(name="WEB-DL", value="WEB-DL"), + app_commands.Choice(name="Image", value="Image") +]) +@app_commands.choices(source=[ + app_commands.Choice(name="Master", value="Master"), + app_commands.Choice(name="หนังซูม", value="Zoom"), + app_commands.Choice(name="V2D From Master", value="V2D"), + app_commands.Choice(name="From TV", value="TV"), + app_commands.Choice(name="From HD-TV", value="HD-TV"), + app_commands.Choice(name="Hi-def rip from Master", value="Hi-def"), + ### Anime + app_commands.Choice(name="V2D From Master/DVD Modified", value="From Master/DVD"), + app_commands.Choice(name="อัดจาก TV", value="Rip TV"), + app_commands.Choice(name="rip from Master", value="Rip Master"), + app_commands.Choice(name="Scan", value="scan") + +]) +@app_commands.choices(country=[ + app_commands.Choice(name="Thai", value="Thai"), + app_commands.Choice(name="Western", value="Western"), + app_commands.Choice(name="Korean", value="Korean"), + app_commands.Choice(name="Japanese", value="Japanese"), + app_commands.Choice(name="Chinese", value="Chinese"), + app_commands.Choice(name="Other", value="Other") +]) +@app_commands.choices(original_platform=[ + app_commands.Choice(name="DVD", value="DVD"), + app_commands.Choice(name="Hi-def", value="Hi-def"), + app_commands.Choice(name="TV", value="TV"), + app_commands.Choice(name="Books", value="Books"), + app_commands.Choice(name="Other", value="Other"), + app_commands.Choice(name="Netflix", value="Netflix") +]) +@app_commands.choices(is_subdir=[ + app_commands.Choice(name="True", value='True'), + app_commands.Choice(name="False", value='False') +]) +@app_commands.choices(bearbit=[ + app_commands.Choice(name="True", value='True'), + app_commands.Choice(name="False", value='False') +]) +@app_commands.choices(torrentdd=[ + app_commands.Choice(name="True", value='True'), + app_commands.Choice(name="False", value='False') +]) +@app_commands.choices(is_movie=[ + app_commands.Choice(name="True", value='True'), + app_commands.Choice(name="False", value='False') +]) +@app_commands.choices(pack=[ + app_commands.Choice(name="True", value='True'), + app_commands.Choice(name="False", value='False') +]) +# @app_commands.choices(single_season=[ +# app_commands.Choice(name="True", value='True'), +# app_commands.Choice(name="False", value='False') +# ]) + +async def sonarr_upload_command(interaction: discord.Interaction, + sonarr_id: str, + season: Optional[str], + episode: Optional[str], + category: Optional[str], + source_type: Optional[str], + source: Optional[str], + country: Optional[str], + original_platform: Optional[str], + is_subdir: Optional[str] = "False", + bearbit: Optional[str] = "True", + torrentdd: Optional[str] = "True", + is_movie: Optional[str] = "False", + pack: Optional[str] = "False", + # single_season: Optional[str] = "False", + ): + embed = discord.Embed( + title="Torrent Upload", + description="Creating and uploading torrent file...", + color=discord.Color.blue() + ) + embed.add_field(name="Sonarr title id", value=sonarr_id, inline=False) + embed.add_field(name="Season", value=season, inline=False) + embed.add_field(name="Episode", value=episode, inline=False) + embed.add_field(name="Category", value=category if category else "Not provided", inline=False) + embed.add_field(name="Source Type", value=source_type if source_type else "Not provided", inline=False) + embed.add_field(name="Source", value=source if source else "Not provided", inline=False) + embed.add_field(name="Country", value=country if country else "Not provided", inline=False) + embed.add_field(name="Original Platform", value=original_platform if original_platform else "Not provided", inline=False) + embed.add_field(name="Is Subdirectory", value=is_subdir if is_subdir else "Not provided", inline=False) + embed.add_field(name="BearBit", value=bearbit if bearbit else "Not provided", inline=False) + embed.add_field(name="TorrentDD", value=torrentdd if torrentdd else "Not provided", inline=False) + embed.add_field(name="Is Movie", value=is_movie if is_movie else "Not provided", inline=False) + embed.add_field(name="Pack", value=pack if pack else "Not provided", inline=False) + # embed.add_field(name="Single Season", value=pack if pack else "Not provided", inline=False) + embed.set_footer(text="This may take a while, please be patient...") + await interaction.response.send_message(embed=embed) + + console.log("Starting torrent creation and upload process...") + print_detail=('Sonarr title id:', sonarr_id, '| Sonarr season:', season if season else "Not provided", '| Sonarr episode:', episode if episode else "Not provided", '| category:', category if category else "Not provided", '| source_type:', source_type if source_type else "Not provided", '| source:', source if source else "Not provided", '| country:', country if country else "Not provided", '| original_platform:', original_platform if original_platform else "Not provided", '| is_subdir:', is_subdir, '| bearbit:', bearbit, '| torrentdd:', torrentdd, '| is_movie:', is_movie, '| pack:', pack) + console.log("".join(print_detail)) + + if not sonarr_id: + await interaction.followup.send("❌ Sonarr id is required for torrent creation.") + return + + if not category: + await interaction.followup.send("❌ Category is required for torrent creation.") + return + if not source_type: + await interaction.followup.send("❌ Source type is required for torrent creation.") + return + if not source: + await interaction.followup.send("❌ Source is required for torrent creation.") + return + if not country: + await interaction.followup.send("❌ Country is required for torrent creation.") + return + if not original_platform: + await interaction.followup.send("❌ Original platform is required for torrent creation.") + return + if is_subdir not in ['True', 'False']: + await interaction.followup.send("❌ is_subdir must be either 'True' or 'False'.") + return + if bearbit not in ['True', 'False']: + await interaction.followup.send("❌ bearbit must be either 'True' or 'False'.") + return + if torrentdd not in ['True', 'False']: + await interaction.followup.send("❌ torrentdd must be either 'True' or 'False'.") + return + if is_movie not in ['True', 'False']: + await interaction.followup.send("❌ is_movie must be either 'True' or 'False'.") + return + if pack not in ['True', 'False']: + await interaction.followup.send("❌ pack must be either 'True' or 'False'.") + return + + is_subdir = is_subdir == 'True' + bearbit = bearbit == 'True' + torrentdd = torrentdd == 'True' + is_movie = is_movie == 'True' + pack = pack == 'True' + file_path=get_file_path(sonarr,sonarr_id,season=season,episode=episode) + entry= { + "file_path": file_path["path"], + "imdb_id": file_path["imdbId"], + "tmdb_id": file_path["tmdbId"], + "category": category, + "source_type": source_type, + "source": source, + "country": country, + "original_platform": original_platform, + "is_subdir": is_subdir, + "bearbit": bearbit, + "torrentdd": torrentdd, + "is_movie": is_movie, + 'channel_id': interaction.channel_id, + 'pack': pack, + # 'single_season' : single_season + } + # Add the entry to the upload queue + bot.upload_queue.append(entry) + console.log(f"Added to upload queue: {entry}") + # Start the torrent worker if not already running + embed = discord.Embed( + title="Torrent Upload", + description="Torrent creation and upload process has been started. You will be notified once the process is completed.", + color=discord.Color.green() + ) + embed.set_footer(text="Please wait while the bot processes your request...") + await interaction.followup.send(embed=embed) + + +def get_file_path(sonarr:Sonarr_API,sonarr_id,season,episode): + sonarr_series=sonarr.get_series_detail(sonarr_id) + tmdbId=sonarr_series["tmdbId"] + imdbId=sonarr_series["imdbId"] + sonarr_seasons=sonarr_series["seasons"] + + if season is not None and episode is None: + for ss in sonarr_seasons: + if int(season)==int(ss["seasonNumber"]): + if season>0: + file_path=Path.joinpath(Path(sonarr_series["path"]),f"S{season:02}").__str__() + return {"path":file_path,"tmdbId":tmdbId,"imdbId":imdbId} + else: + file_path=Path.joinpath(Path(sonarr_series["path"]),"Specials").__str__() + return {"path":file_path,"tmdbId":tmdbId,"imdbId":imdbId} + elif season is not None and episode is not None: + season_episode_list=sonarr.get_episode_detail_from_season(sonarr_id,season=season) + for ep in season_episode_list: + if int(ep["seasonNumber"] )==int(season) and int(ep["episodeNumber"] )==int(episode) : + ep_detail=sonarr.get_episode_detail(ep["id"]) + file_path=ep_detail["episodeFile"]["path"] + return {"path":file_path,"tmdbId":tmdbId,"imdbId":imdbId} + + else: + file_path=sonarr_series["path"] + return {"path":file_path,"tmdbId":tmdbId,"imdbId":imdbId} + +async def torrent_worker(): + """Continuously process the upload queue in the background""" + + while True: + if bot.upload_queue: + discord_webhook=os.getenv('torrent_update_webhook').split(",") + entry = bot.upload_queue.pop(0) + channel=bot.get_channel(entry['channel_id']) + + torrentupload=TorrentUpload(console=console) + status = await torrentupload.upload_torrent(entry) + if not (any(item['status'] is False for item in status['bearbit']) or any(item['status'] is False for item in status['torrentdd'])): + embed = discord.Embed( + title="Torrent Upload Completed", + description="All torrents have been created and uploaded successfully.", + color=discord.Color.green() + ) + bb_text="" + for staBB in status['bearbit']: + bb_text+=f"{"Uploaded" if staBB["status"] else "Fail Upload"} : [{staBB["name"]}]({staBB["url"]})" + bb_text+="\n" + # bb_text+=staBB["url"] + # bb_text+="\n" + embed.add_field(name="BearBit", value=bb_text, inline=False) + + dd_text="" + for staDD in status['torrentdd']: + dd_text+=f"{"Uploaded" if staDD["status"] else "Fail Upload"} : [{staDD["name"]}]({staDD["url"]})" + dd_text+="\n" + # dd_text+=staBB["url"] + # dd_text+="\n" + embed.add_field(name="TorrentDD", value=dd_text, inline=False) + + # if status['bearbit'] or status['torrentdd']: + # for i,torrent in enumerate(torrent_files['all']): + # embed.add_field(name="Title", value=os.path.basename(torrent).replace(".all.torrent",""), inline=False) + # embed.add_field(name="BearBit", value="[Click here to visit BearBit](https://bearbit.org/)", inline=False) + # embed.add_field(name="TorrentDD", value="[Click here to visit TorrentDD](https://torrentdd.com/)", inline=False) + embed.set_footer(text="Thank you for visiting!") + console.debug(status) + console.log('Torrent Upload Completed',is_discord={"channel": channel,"embed": embed,"web_hook_urls":discord_webhook}) + else: + embed = discord.Embed( + title="Torrent Upload Failed", + description="Some or all torrents were fail to uploaded. Please check the logs for details.", + color=discord.Color.red() if (any(item['status'] is False for item in status['bearbit']) and any(item['status'] is False for item in status['torrentdd'])) else discord.Color.orange() + ) + bb_text="" + # torrent_name=None + for staBB in status['bearbit']: + bb_text+=f"{"Uploaded" if staBB["status"] else "Fail Upload"} : {staBB["name"]}" + bb_text+="\n" + embed.add_field(name="BearBit", value=bb_text, inline=False) + + dd_text="" + for staDD in status['torrentdd']: + dd_text+=f"{"Uploaded" if staDD["status"] else "Fail Upload"} : {staDD["name"]}" + dd_text+="\n" + embed.add_field(name="TorrentDD", value=dd_text, inline=False) + + + embed.set_footer(text="Please check the logs for more details.") + console.debug(status) + console.error('Torrent Upload Failed',is_discord={"channel": channel,"embed": embed}) + else: + await asyncio.sleep(5) # Sleep briefly if queue is empty + +if __name__ == "__main__": + token = os.getenv('TORRENT_DISCORD_TOKEN') + if not token: + console.error("❌ TORRENT_DISCORD_TOKEN not found in environment variables!") + console.error("Make sure you have a .env file with your bot token.") + else: + try: + bot.run(token) + except discord.LoginFailure: + console.error("❌ Invalid bot token! Please check your TORRENT_DISCORD_TOKEN in the .env file.") + except Exception as e: + console.error(f"❌ An error occurred: {e}") diff --git a/lib/ScreenShot.py b/lib/ScreenShot.py new file mode 100644 index 0000000..d533409 --- /dev/null +++ b/lib/ScreenShot.py @@ -0,0 +1,338 @@ +import os +import re +import asyncio +import subprocess +import cv2 +import numpy as np +import shutil +import time +from PIL import Image, ImageDraw, ImageFont +from urllib.parse import urljoin +import aiohttp +from dotenv import dotenv_values +from datetime import timedelta + +from lib.logging_data import logger +import json + +class ScreenShot: + FONT_PATH = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" + + def __init__(self, OUTPUT_DIR="temp_screenshots",console:logger=None): + self.VIDEO_PATH = None + self.OUTPUT_DIR = OUTPUT_DIR + self.OUTPUT_IMAGE = None + + self.GRID_COLS = 3 + self.GRID_ROWS = 5 + self.TOTAL_FRAMES = self.GRID_COLS * self.GRID_ROWS + # self.WIDTH = 1600 + # self.HEIGHT = 900 + self.WIDTH = None + self.HEIGHT = None + self.HEADER_HEIGHT = 90 + + self.base_url = "https://imgbb.ws" + self.imgbb_token = None + self.session = None + + self.console=console or logger(app_name="torrent_uploader",log_dir="./log") + + def get_metadata(self): + def ffprobe_entry(stream, entry): + cmd = [ + "ffprobe", "-v", "error", "-select_streams", stream, + "-show_entries", f"stream={entry}", + "-of", "default=noprint_wrappers=1:nokey=1", self.VIDEO_PATH + ] + # print(f"Running command: {' '.join(cmd)}") + result = subprocess.run(cmd, stdout=subprocess.PIPE) + return result.stdout.decode().strip() + + def ffprobe_tag_languages(stream_type): + cmd = [ + "ffprobe", "-v", "error", "-select_streams", stream_type, + "-show_entries", "stream_tags=language", + "-of", "csv=p=0", self.VIDEO_PATH + ] + result = subprocess.run(cmd, stdout=subprocess.PIPE) + langs = list(set([lang.strip() for lang in result.stdout.decode().splitlines() if lang.strip()])) + return ",".join(langs) if langs else "und" + + duration = float(self.get_duration(self.VIDEO_PATH)) + vcodec = ffprobe_entry("v:0", "codec_name").splitlines()[0] + " " + ffprobe_entry("v:0", "profile").splitlines()[0] + acodec_profile = ffprobe_entry("a:0", "profile").splitlines()[0] + acodec = ffprobe_entry("a:0", "codec_name").splitlines()[0] + acodec += " DDP" if "Dolby Digital Plus" in acodec_profile else " DD" if "Dolby Digital" in acodec_profile else "" + acodec += " Atmos" if "Atmos" in acodec_profile else "" + audio_channels = ffprobe_entry("a:0", "channel_layout").splitlines()[0] + resolution = f"{ffprobe_entry('v:0', 'width').splitlines()[0]}x{ffprobe_entry('v:0', 'height').splitlines()[0]}" + self.WIDTH, self.HEIGHT = map(int, resolution.split('x')) + self.WIDTH, self.HEIGHT=self.WIDTH/self.GRID_COLS, self.HEIGHT/self.GRID_COLS + size_mb = os.path.getsize(self.VIDEO_PATH) / (1024 * 1024) + audio_lang = ffprobe_tag_languages("a").upper() + subtitle_lang = ffprobe_tag_languages("s").upper() + + return duration, vcodec.upper(), acodec.upper(), audio_channels, resolution, size_mb, audio_lang, subtitle_lang + + # def get_metadata(self): + # def get_mkv_json(): + # cmd = ["mkvmerge", "-J", self.VIDEO_PATH] + # result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + # return json.loads(result.stdout.decode()) + + # data = get_mkv_json() + + # video_track = next(t for t in data["tracks"] if t["type"] == "video") + # audio_track = next(t for t in data["tracks"] if t["type"] == "audio") + + # # duration (ns -> sec) + # duration = data["container"]["properties"]["duration"] / 1_000_000_000 + + # # video codec + # vcodec = video_track["codec"] + # profile = video_track.get("properties", {}).get("profile") + # if profile: + # vcodec = f"{vcodec} {profile}" + + # # audio codec + # acodec = audio_track["codec"] + # acodec_profile = audio_track.get("properties", {}).get("profile", "") + + # if "Dolby Digital Plus" in acodec_profile: + # acodec += " DDP" + # elif "Dolby Digital" in acodec_profile: + # acodec += " DD" + + # if "Atmos" in acodec_profile: + # acodec += " Atmos" + + # # channels + # audio_channels = audio_track.get("properties", {}).get("audio_channels") + + # # resolution + # width = video_track["properties"]["pixel_dimensions"].split("x")[0] + # height = video_track["properties"]["pixel_dimensions"].split("x")[1] + + # resolution = f"{width}x{height}" + + # self.WIDTH, self.HEIGHT = int(width), int(height) + # self.WIDTH, self.HEIGHT = self.WIDTH/self.GRID_COLS, self.HEIGHT/self.GRID_COLS + + # # size + # size_mb = os.path.getsize(self.VIDEO_PATH) / (1024 * 1024) + + # # audio languages + # audio_langs = { + # t.get("properties", {}).get("language", "und") + # for t in data["tracks"] if t["type"] == "audio" + # } + + # subtitle_langs = { + # t.get("properties", {}).get("language", "und") + # for t in data["tracks"] if t["type"] == "subtitles" + # } + + # audio_lang = ",".join(sorted(audio_langs)).upper() + # subtitle_lang = ",".join(sorted(subtitle_langs)).upper() + + # return ( + # duration, + # vcodec.upper(), + # acodec.upper(), + # audio_channels, + # resolution, + # size_mb, + # audio_lang, + # subtitle_lang + # ) + @staticmethod + def get_duration(filename): + result = subprocess.run( + ["ffprobe", "-v", "error", "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", filename], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT + ) + return float(result.stdout) + + async def extract_screenshots(self, duration): + return await asyncio.to_thread(self._extract_screenshots_blocking, duration) + + def _extract_screenshots_blocking(self, duration): + os.makedirs(self.OUTPUT_DIR, exist_ok=True) + interval =duration / self.TOTAL_FRAMES + timestamps = [] + + for i in range(self.TOTAL_FRAMES): + timestamp = int(i * interval) + if timestamp ==0: + timestamp = 5 + output_file = os.path.join(self.OUTPUT_DIR, f"shot_{i:02d}.jpg") + + # drawtext = ( + # f"drawtext=fontfile={self.FONT_PATH}:" + # f"text='%{{pts\\:hms}}':" + # f"x=10:y=10:fontsize=18:fontcolor=white:borderw=2" + # ) + + cmd = [ + "ffmpeg", "-ss", str(timestamp), "-i", self.VIDEO_PATH, + "-frames:v", "1", "-q:v", "2", + "-vf", + f"scale={self.WIDTH}:{self.HEIGHT}", + output_file, "-y" + ] + result = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + self.console.debug("result : ",result) + # print(" ".join(cmd)) + # print(result) + # Draw timestamp with Pillow + timestamp_str = str(timedelta(seconds=timestamp)).split(".")[0] + img = Image.open(output_file) + draw = ImageDraw.Draw(img) + font = ImageFont.truetype(self.FONT_PATH, 32) + draw.text((10, 10), timestamp_str, font=font, fill="white", stroke_width=2, stroke_fill="black") + img.save(output_file) + + timestamps.append(timestamp) + + return timestamps + + + def stitch_images(self, metadata_text, timestamps): + images = [] + for i in range(self.TOTAL_FRAMES): + img_path = os.path.join(self.OUTPUT_DIR, f"shot_{i:02d}.jpg") + img = cv2.imread(img_path) + if img is not None: + img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + images.append(img_rgb) + + if len(images) != self.TOTAL_FRAMES: + self.console.error("Not enough images. Exiting.") + return + + rows = [np.hstack(images[i*self.GRID_COLS:(i+1)*self.GRID_COLS]) for i in range(self.GRID_ROWS)] + sheet = np.vstack(rows) + + sheet_img = Image.fromarray(sheet) + banner = Image.new("RGB", (sheet_img.width, self.HEADER_HEIGHT), color=(40, 40, 40)) + draw = ImageDraw.Draw(banner) + font = ImageFont.truetype(self.FONT_PATH, 20) + draw.text((10, 10), metadata_text, font=font, fill="white") + + final_image = Image.new("RGB", (sheet_img.width, sheet_img.height + self.HEADER_HEIGHT)) + final_image.paste(banner, (0, 0)) + final_image.paste(sheet_img, (0, self.HEADER_HEIGHT)) + final_image.save(self.OUTPUT_IMAGE, quality=95, subsampling=0) + + self.console.log(f"📷 Saved to {self.OUTPUT_IMAGE}") + self.cleanup_screenshots() + + def cleanup_screenshots(self): + if os.path.exists(self.OUTPUT_DIR): + shutil.rmtree(self.OUTPUT_DIR) + self.console.log("✅ All extracted screenshots deleted.") + + async def run(self, VIDEO_PATH, is_movie=False): + self.VIDEO_PATH = VIDEO_PATH + base = os.path.dirname(self.VIDEO_PATH) + parent = os.path.dirname(base) + filename = os.path.basename(self.VIDEO_PATH).replace("#", "") + "_screenshot.jpg" + self.OUTPUT_IMAGE = os.path.join(base if is_movie else parent, filename) + + duration, vcodec, acodec, audio_channels, resolution, size_mb, audio_lang, subtitle_lang = self.get_metadata() + metadata_text = ( + f"{os.path.basename(self.VIDEO_PATH)}\n" + f"{vcodec} | {acodec} {audio_channels} \n" + f"{resolution} | {size_mb:.2f} MB | {duration/60:.2f} min | Audio: {audio_lang} | Subtitles: {subtitle_lang}" + ) + self.console.log(f"🎬 Metadata: {metadata_text}") + + timestamps = await self.extract_screenshots(duration) + self.stitch_images(metadata_text, timestamps) + return self.OUTPUT_IMAGE + + async def upload_to_imgbb(self, image_path): + + timestamp = str(int(time.time() * 1000)) + retry = 0 + + while retry < 5: + form = aiohttp.FormData() + form.add_field('source', open(image_path, 'rb'), filename=os.path.basename(image_path), content_type='image/jpeg') + form.add_field('type', 'file') + form.add_field('action', 'upload') + form.add_field('timestamp', timestamp) + form.add_field('auth_token', self.imgbb_token) + form.add_field('nsfw', '0') + form.add_field('mimetype', 'image/jpeg') + + async with self.session.post(urljoin(self.base_url, '/json'), data=form) as response: + await response.text() # drain the response + if 200 <= response.status < 300: + self.console.log("✅ Upload successful") + await asyncio.sleep(5) + data = await response.json() + os.remove(image_path) + return data['image']['url'] + else: + self.console.warn(f"❌ Upload failed ({response.status})") + retry += 1 + await asyncio.sleep(10) + + self.console.error("❌ Max retries reached") + return None + + async def login(self): + if not self.session: + self.session = aiohttp.ClientSession(headers={ + 'accept': 'application/json', + 'accept-language': 'en-US,en;q=0.9,th;q=0.8', + 'cache-control': 'no-cache', + 'origin': 'https://imgbb.ws', + 'pragma': 'no-cache', + 'referer': 'https://imgbb.ws/', + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0', + }) + + async with self.session.get(self.base_url) as response: + html = await response.text() + if response.status != 200: + self.console.error(f"❌ Failed to connect to {self.base_url}: {response.status}") + exit(1) + + match = re.search(r'auth_token\s*=\s*"([a-f0-9]{40})"', html) + if match: + self.imgbb_token = match.group(1) + self.console.log("Auth token:", self.imgbb_token) + else: + self.console.error("Auth token not found.") + + creds = dotenv_values(".env") + data = { + 'login-subject': creds.get("imgbb_id"), + 'password': creds.get("imgbb_password"), + 'auth_token': self.imgbb_token, + } + async with self.session.post(urljoin(self.base_url, '/login'), data=data) as response: + self.console.log(f"Login status: {response.status}") + + +if __name__ == "__main__": + from lib.torrent_creator import TorrentCreator,ffprobe_streams_to_bbcode + + torrent = TorrentCreator() + # VIDEO_PATH='/Entertainment_1/Anime/Series/365 Days to the Wedding (2024) [tvdbid-433584]/S01/365.Days.to.the.Wedding.2024.S01E01.Why.Dont.We.Get.Married.CR.WEBDL-1080P.X264.AAC.[SeFree].mkv' + VIDEO_PATH='/Entertainment_1/Anime/Movie/KPop Demon Hunters (2025) [tmdbid-803796]/KPop.Demon.Hunters.2025.NF.WEBDL-1080P.AV1.EAC3.ATMOS.[SeFree].mkv' + + async def main(): + duration, video_codec, audio_codec, audio_channels, resolution, size_mb, audio_lang, subtitle_lang, json_metadata = torrent.get_metadata(VIDEO_PATH) + with open("output.json","w") as f: + json.dump(json_metadata,f,indent=4,ensure_ascii=True) + + bb=ffprobe_streams_to_bbcode(json_metadata, os.path.basename(VIDEO_PATH)) + with open("output_bb.txt","w") as f: + # json.dump(bb,f,indent=4,ensure_ascii=True) + f.write(bb) + asyncio.run(main()) diff --git a/lib/__init__.py b/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lib/check_track_detail.py b/lib/check_track_detail.py new file mode 100644 index 0000000..ecc39e4 --- /dev/null +++ b/lib/check_track_detail.py @@ -0,0 +1,247 @@ +import re +from langcodes import Language, tag_is_valid + +def first_valid_bcp47(parts): + """ + Return the first token in parts that is a valid BCP 47 tag, + or None if none are valid. + """ + for p in parts: + + tok = p.strip() + # Remove bracketed markers like [Original] + if tok.startswith("[") and tok.endswith("]"): + continue + # langcodes works with exact case; tags are typically case-insensitive + # but language=lower, region/script=proper-case is okay. + # We'll just feed the token as-is; tag_is_valid handles common cases. + if tag_is_valid(tok): + return tok + return None + +def extract_langs(text): + audio = [] + subs = [] + LANG = r'([a-z]{2}(?:-[A-Z]{2})?)' + for line in text.splitlines(): + # audio + m_audio = re.search( + rf'\[(AAC|DD\+?|AC-4|OPUS|VORB|DTS|ALAC|FLAC)\]\s*\|\s*{LANG}', + line + ) + if m_audio: + lang = m_audio.group(2) + if lang not in audio: + audio.append(lang) + + # subtitles + m_sub = re.search( + rf'\[(SRT|SSA|ASS|VTT|TTML|SMI|SUB|MPL2|TMP|STPP|WVTT)\]\s*\|\s*{LANG}', + line + ) + if m_sub: + lang = m_sub.group(2) + if lang not in subs: + subs.append(lang) + + return audio, subs + +def check_langs_with_langcodes(stderr_text: str, audio_lang_cfg: list[str], sub_lang_cfg: list[str]): + # audio_tags = find_audio_tags(stderr_text) + # sub_tags = find_sub_tags(stderr_text) + audio_tags,sub_tags=extract_langs(stderr_text) + + + # Normalize found tags to their primary language subtags + audio_langs_found = {Language.get(tag).language for tag in audio_tags} + sub_langs_found = {Language.get(tag).language for tag in sub_tags} + + return { + "audio": { + "configured": audio_lang_cfg, + "found_tags": audio_tags, + "found_langs": sorted(audio_langs_found), + "exists_all": all(Language.get(c).language in audio_langs_found for c in audio_lang_cfg), + }, + "subtitle": { + "configured": sub_lang_cfg, + "found_tags": sub_tags, + "found_langs": sorted(sub_langs_found), + "exists_all": all(Language.get(c).language in sub_langs_found for c in sub_lang_cfg), + }, + } + +def video_details(stderr_text: str): + """ + Parses the 'All Tracks' part (stopping at 'Selected Tracks') using a single regex. + Returns a list of dicts with codec, range, resolution [w,h], bitrate (int kb/s), + framerate (float or None if unknown), and size (e.g., '376.04 MiB'). + """ + # One regex, anchored to 'VID | [ ... ]' so it won't ever read the log-level [I] + VID_RE = re.compile(r""" + VID\s*\|\s*\[\s*(?P[^,\]]+)\s*(?:,\s*(?P[^\]]+))?\]\s*\|\s* + (?P\d{3,4})x(?P\d{3,4})\s*@\s*(?P[\d,]+)\s*kb/s + (?:\s*\((?P[^()]*?(?:MiB|GiB)[^()]*)\))?\s*,\s*(?P\d+(?:\.\d+)?)\s*FPS + """, re.VERBOSE) + + # Only parse the 'All Tracks' section if 'Selected Tracks' exists + if "Selected Tracks" in stderr_text: + all_section = stderr_text.split("Selected Tracks", 1)[0] + else: + all_section = stderr_text + + results = [] + for m in VID_RE.finditer(all_section): + bitrate_kbps = int(m.group("kbps").replace(",", "")) + fps_val = None + if m.group("fps"): + try: + fps_val = float(m.group("fps")) + except ValueError: + fps_val = None # fallback if numeric parse fails + + results.append({ + "codec": m.group("codec").strip() if m.group("codec") else None, + "range": (m.group("range").strip() if m.group("range") else None), + "resolution": [m.group("width"), m.group("height")], + "bitrate": bitrate_kbps, + "framerate": fps_val, # None when 'Unknown FPS' + "size": (m.group("size").strip() if m.group("size") else None), + }) + + return results + +def extract_chapters(stderr_text: str): + """ + Parse chapter lines from vinetrimmer-like logs. + Returns: list of dicts: {'index': '01', 'time': '00:04:21.762', 'name': 'intro'} + Stops parsing at 'Selected Tracks' to prefer the 'All Tracks' inventory if present. + """ + # Matches: "CHP | [01] | 00:04:21.762 | intro" + CHAPTER_RE = re.compile( + r""" + ^.*?\bCHP\b\s*\|\s*\[(?P\d{1,3})\]\s*\|\s* + (?P