From c2fafcd406a156023e9ee37aa6033b9dfa77da11 Mon Sep 17 00:00:00 2001 From: panitan103 Date: Mon, 30 Mar 2026 11:05:08 +0700 Subject: [PATCH] - add season_overwrite and episode_overwrite for Schedule work - print File path at the end of file for Schedule work - add discord downloader --- .gitignore | 7 + Unshackle-Service-SeFree | 1 + pyproject.toml | 2 + unshackle/commands/dl.py | 18 +- unshackle/core/titles/episode.py | 25 +- usk_downloader_discord.py | 849 +++++++++++++++++++++++++++++++ uv.lock | 36 ++ 7 files changed, 928 insertions(+), 10 deletions(-) create mode 160000 Unshackle-Service-SeFree create mode 100755 usk_downloader_discord.py diff --git a/.gitignore b/.gitignore index e63e7bd..77da163 100644 --- a/.gitignore +++ b/.gitignore @@ -238,3 +238,10 @@ CLAUDE.md marimo/_static/ marimo/_lsp/ __marimo__/ + +WVDs/ +PRDs/ +Logs/ +Cookies/ +Cache/ +Temp/ \ No newline at end of file diff --git a/Unshackle-Service-SeFree b/Unshackle-Service-SeFree new file mode 160000 index 0000000..2d97c3d --- /dev/null +++ b/Unshackle-Service-SeFree @@ -0,0 +1 @@ +Subproject commit 2d97c3d34a6127e870d695ddcf96ef3d31fe98c2 diff --git a/pyproject.toml b/pyproject.toml index 42892af..0a214f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,6 +68,8 @@ dependencies = [ "language-data>=1.4.0", "wasmtime>=41.0.0", "animeapi-py>=0.6.0", + "discord-py>=2.7.1", + "dotenv>=0.9.9", ] [project.urls] diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 6de2bf2..267d921 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -519,6 +519,11 @@ class dl: default=False, help="Continue with best available quality if requested resolutions are not available.", ) + @click.option("-so", "--season-overwrite",type=int, default=None, + help="Overwrite season number") + @click.option("-eo", "--episode-overwrite",type=int, default=None, + help="Overwrite episode number") + @click.pass_context def cli(ctx: click.Context, **kwargs: Any) -> dl: return dl(ctx, **kwargs) @@ -1000,6 +1005,10 @@ class dl: worst: bool, best_available: bool, split_audio: Optional[bool] = None, + + season_overwrite: Optional[int] = None, + episode_overwrite: Optional[int] = None, + *_: Any, **__: Any, ) -> None: @@ -2464,12 +2473,11 @@ class dl: for muxed_path in muxed_paths: media_info = MediaInfo.parse(muxed_path) final_dir = self.output_dir or config.directories.downloads - final_filename = title.get_filename(media_info, show_service=not no_source) + final_filename = title.get_filename(media_info, show_service=not no_source,season_overwrite=int(season_overwrite) if season_overwrite else None,episode_overwrite=int(episode_overwrite) if episode_overwrite else None) audio_codec_suffix = muxed_audio_codecs.get(muxed_path) if not no_folder and isinstance(title, (Episode, Song)): - final_dir /= title.get_filename(media_info, show_service=not no_source, folder=True) - + final_dir /= title.get_filename(media_info, show_service=not no_source, folder=True,season_overwrite=int(season_overwrite) if season_overwrite else None) final_dir.mkdir(parents=True, exist_ok=True) final_path = final_dir / f"{final_filename}{muxed_path.suffix}" template_type = ( @@ -2500,6 +2508,9 @@ class dl: console.print( Padding(f":tada: Title downloaded in [progress.elapsed]{title_dl_time}[/]!", (0, 5, 1, 5)) ) + console.print( + Padding(f"File path - {final_path}", (0, 5, 1, 5)) + ) # update cookies cookie_file = self.get_cookie_path(self.service, self.profile) @@ -2510,6 +2521,7 @@ class dl: console.print(Padding(f"Processed all titles in [progress.elapsed]{dl_time}", (0, 5, 1, 5))) + def prepare_drm( self, drm: DRM_T, diff --git a/unshackle/core/titles/episode.py b/unshackle/core/titles/episode.py index c93404e..51f232b 100644 --- a/unshackle/core/titles/episode.py +++ b/unshackle/core/titles/episode.py @@ -78,14 +78,25 @@ class Episode(Title): self.year = year self.description = description - def _build_template_context(self, media_info: MediaInfo, show_service: bool = True) -> dict: + def _build_template_context(self, media_info: MediaInfo, show_service: bool = True,season_overwrite=None,episode_overwrite=None) -> dict: """Build template context dictionary from MediaInfo.""" context = self._build_base_template_context(media_info, show_service) context["title"] = self.title.replace("$", "S") context["year"] = self.year or "" - context["season"] = f"S{self.season:02}" - context["episode"] = f"E{self.number:02}" - context["season_episode"] = f"S{self.season:02}E{self.number:02}" + + if season_overwrite is not None: + season = season_overwrite + else: + season = self.season + + if episode_overwrite is not None: + episode = episode_overwrite + else: + episode = self.number + + context["season"] = f"S{season:02}" + context["episode"] = f"E{episode:02}" + context["season_episode"] = f"S{season:02}E{episode:02}" context["episode_name"] = self.name or "" return context @@ -98,7 +109,7 @@ class Episode(Title): name=self.name or "", ).strip() - def get_filename(self, media_info: MediaInfo, folder: bool = False, show_service: bool = True) -> str: + def get_filename(self, media_info: MediaInfo, folder: bool = False, show_service: bool = True,season_overwrite=None,episode_overwrite=None) -> str: if folder: series_template = config.output_template.get("series") if series_template: @@ -114,7 +125,7 @@ class Episode(Title): formatter = TemplateFormatter(folder_template) context = self._build_template_context(media_info, show_service) - context['season'] = f"S{self.season:02}" + context['season'] = f"S{self.season:02}" if not season_overwrite else f"S{season_overwrite:02}" folder_name = formatter.format(context) @@ -130,7 +141,7 @@ class Episode(Title): return sanitize_filename(name, " ") formatter = TemplateFormatter(config.output_template["series"]) - context = self._build_template_context(media_info, show_service) + context = self._build_template_context(media_info, show_service,season_overwrite,episode_overwrite) return formatter.format(context) diff --git a/usk_downloader_discord.py b/usk_downloader_discord.py new file mode 100755 index 0000000..2da7501 --- /dev/null +++ b/usk_downloader_discord.py @@ -0,0 +1,849 @@ +import discord +from discord.ext import commands +from discord import app_commands +import os +import json +import asyncio +from datetime import datetime +from dotenv import load_dotenv +from typing import Optional +import subprocess + +# Load environment variables +load_dotenv() + +# Bot configuration +intents = discord.Intents.default() +intents.message_content = True +intents.members = True + +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.download_queue = [] + self.download_history = [] + self.authorized_users = [] + + + + # Load data from files if they exist + self.load_data() + + + def load_data(self): + """Load persistent bot_logs from JSON files""" + try: + if os.path.exists('bot_logs/download_history.json'): + with open('bot_logs/download_history.json', 'r') as f: + self.download_history = json.load(f) + + if os.path.exists('bot_logs/authorized_users.json'): + with open('bot_logs/authorized_users.json', 'r') as f: + self.authorized_users = set(json.load(f)) + except Exception as e: + print(f"Error loading data: {e}") + + def save_data(self): + """Save persistent data to JSON files""" + try: + os.makedirs('bot_logs', exist_ok=True) + + with open('bot_logs/download_history.json', 'w') as f: + json.dump(self.download_history, f, indent=2) + + with open('bot_logs/authorized_users.json', 'w') as f: + json.dump(list(self.authorized_users), f, indent=2) + except Exception as e: + print(f"Error saving bot_logs: {e}") + + async def setup_hook(self): + """Called when the bot is starting up""" + print(f"Logged in as {self.user} (ID: {self.user.id})") + print("------") + + # Sync slash commands + try: + synced = await self.tree.sync() + print(f"Synced {len(synced)} command(s)") + # threading.Thread(target=vt_worker).start() # Start the download worker in the background + except Exception as e: + print(f"Failed to sync commands: {e}") + +bot = DownloadBot() +# Helper function to check if user is authorized +def is_authorized(): + def predicate(interaction: discord.Interaction): + return interaction.user.id in bot.authorized_users or interaction.user.guild_permissions.administrator + return app_commands.check(predicate) + +@bot.event +async def on_ready(): + print(f'{bot.user} has connected to Discord!') + activity = discord.Game(name="Managing downloads | /help") + + await bot.change_presence(activity=activity) + asyncio.create_task(usk_worker()) + +# /download command +@bot.tree.command(name="download", description="Download a file from a URL|ID") +@app_commands.describe( + service="Service to use for downloading (e.g., AMZN, NF, HS, VIU, TID, MMAX, BLBL)", + url="The URL|ID to download from", + keys="Get keys only (default: False, True to get keys only)", + quality="Desired video quality (default: 1080)", + codec="Video codec to use (default: h265)", + range_="Dynamic range to use (default: SDR)", + bitrate="Video bitrate to use (default: Max)", + start_season="Season to download (optional, e.g., 1)", + start_episode="Specific episodes to download (e.g., 1)", + end_season="Season to download (optional, e.g., 2)", + end_episode="Specific episodes to download (e.g., 2)", + video_language="Video language(s) to use (default: orig)", + audio_language="Audio language(s) to use (default: orig,th)", + subtitle_language="Subtitle language(s) to use (default: th,en)", + audio_channel="Audio channel(s) to use (default: 2.0,5.1,Best)", + worst="Download worst quality available (default: False, True to download worst)", + proxy="Proxy to use (optional, e.g., http://username:password@proxyserver:port or nordvpn country code/id)", + + ### Unshackle options + no_cache="Disable vault cache (default: False, True to disable cache)", + + ## for iTunes + store_front="For iTunes: Store front to use (default: 143475)", + + ### for BiliBili or Other + season="For BiliBili: Season to download (optional, e.g., 1)", + title_language="For BiliBili | Laftel: Title language(s) to use (default: ja)", + original_url="For BiliBili: Original URL to download from (optional, e.g., https://www.bilibili.com/video/BV1xxxxxx)", + original_language="For BiliBili: Original language(s) to use (default: ja)", + movie="For BiliBili | Laftel: Is this a movie? (default: False, True for movies, False for series)", # New parameter to indicate if it's a movie + android= "For BiliBili: Use Android app (default: False, True for Android, False for Web)" + + +) +@app_commands.choices(keys=[ + app_commands.Choice(name="True", value='True'), + app_commands.Choice(name="False", value='False'), +]) +@app_commands.choices(service=[ + app_commands.Choice(name="Amazon Prime", value="AMZN"), + app_commands.Choice(name="Netflix", value="NF"), + app_commands.Choice(name="Hotstar", value="HS"), + app_commands.Choice(name="VIU", value="VIU"), + app_commands.Choice(name="TrueID", value="TID"), + app_commands.Choice(name="Mono Max", value="MMAX"), + app_commands.Choice(name="BiliBili", value="BLBL"), + app_commands.Choice(name="FutureSkill", value="FSK"), + app_commands.Choice(name="HBO Max", value="HMAX"), + app_commands.Choice(name="iQIYI", value="IQ"), + app_commands.Choice(name="WeTV", value="WTV"), + app_commands.Choice(name="Crunchyroll", value="CR"), + app_commands.Choice(name="Laftel", value="LT"), + app_commands.Choice(name="Flixer", value="FLX"), + app_commands.Choice(name="iTune", value="IT"), + app_commands.Choice(name="Apple TV+", value="ATVP"), + app_commands.Choice(name="TrueVisionNow", value="TVN"), + app_commands.Choice(name="OneD", value="OND"), + app_commands.Choice(name="HIDIVE", value="HIDI"), +]) +# @app_commands.choices(quality=[ +# app_commands.Choice(name="2160p", value="2160"), +# app_commands.Choice(name="1440p", value="1440"), +# app_commands.Choice(name="1080p", value="1080"), +# app_commands.Choice(name="720p", value="720"), +# app_commands.Choice(name="480p", value="480"), +# app_commands.Choice(name="Best", value="Best"), +# ]) +@app_commands.choices(codec=[ + app_commands.Choice(name="H264", value="H.264"), + app_commands.Choice(name="H265", value="H.265"), + app_commands.Choice(name="AV1", value="AV1"), + app_commands.Choice(name="VP9", value="VP9"), +]) +@app_commands.choices(range_=[ + app_commands.Choice(name="HDR", value="HDR"), + app_commands.Choice(name="SDR", value="SDR"), + app_commands.Choice(name="DV", value="DV"), + app_commands.Choice(name="DV+HDR", value="DV+HDR"), +]) +@app_commands.choices(audio_channel=[ + app_commands.Choice(name="2.0", value="2.0"), + app_commands.Choice(name="5.1", value="5.1"), + app_commands.Choice(name="Best", value= "Best"), +]) +@app_commands.choices(worst=[ + app_commands.Choice(name="True", value='True'), + app_commands.Choice(name="False", value='False'), +]) +@app_commands.choices(movie=[ + app_commands.Choice(name="True", value='True'), + app_commands.Choice(name="False", value='False'), +]) +@app_commands.choices(android=[ + app_commands.Choice(name="True", value='True'), + app_commands.Choice(name="False", value='False'), +]) +@app_commands.choices(no_cache=[ + app_commands.Choice(name="True", value=1), + app_commands.Choice(name="False", value=0), +]) + + +async def download_command( + interaction: discord.Interaction, + service: str, + url: str, + keys: Optional[str] = 'False', + quality: Optional[str] = '1080', + codec: Optional[str] = "h265", + range_: Optional[str] = "SDR", + bitrate: Optional[str] = "Max", + start_season: Optional[int] = None, + start_episode: Optional[int] = None, + end_season: Optional[int] = None, + end_episode: Optional[int] = None, + video_language: Optional[str] = "all", + audio_language: Optional[str] = "orig,th", + subtitle_language: Optional[str] = "th,en", + audio_channel: Optional[str] = "Best", + worst: Optional[str] = 'False', + proxy: Optional[str] = None, + + no_cache: Optional[int] = 0, # 1 for True and 0 for False + # title_cache: Optional[int] = 0, + + # iTunes specific parameters + store_front: Optional[str] = "143475", + + # BiliBili specific parameters + season: Optional[int] = None, + title_language: Optional[str] = "ja", + original_url: Optional[str] = None, + original_language: Optional[str] = "ja", + movie: Optional[str] = 'False', + android: Optional[str] = 'True', + +): + # Check if user has permission + if not (interaction.user.id in bot.authorized_users or interaction.user.guild_permissions.administrator): + embed = discord.Embed( + title="❌ Access Denied", + description="You don't have permission to use this command.", + color=0xff0000 + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + return + try: + bitrate = int(bitrate) + except ValueError: + if bitrate.lower() == 'max': + bitrate = None + else: + embed = discord.Embed( + title="❌ Invalid Bitrate", + description="Bitrate must be an integer or 'Max'.", + color=0xff0000 + ) + await interaction.response.send_message(embed=embed, ephemeral=True) + return + + # Create download entry + download_id = len(bot.download_history) + 1 + download_entry = { + 'interaction': interaction, + 'data': { + 'id': download_id, + 'url': url, + 'user': interaction.user.display_name, + 'user_id': interaction.user.id, + 'channel_id': interaction.channel_id, + 'timestamp': datetime.now().isoformat(), + 'status': 'queued', + 'service': service.upper(), + 'keys': keys == 'True', # Convert to boolean + 'quality': quality.upper() if quality else None, + 'codec': codec.upper() if codec else None, + 'range': range_.upper() if range_ else None, + 'bitrate': bitrate, + 'start_season': f'{start_season:02}' if start_season is not None else None, + 'start_episode': f'{start_episode:02}' if start_episode is not None else None, + 'end_season': f'{end_season:02}' if end_season is not None else None, + 'end_episode': f'{end_episode:02}' if end_episode is not None else None, + 'video_language': video_language.lower() if video_language else None, + 'audio_language': audio_language.lower() if audio_language else None, + 'subtitle_language': subtitle_language.lower() if subtitle_language else None, + 'audio_channel': audio_channel if audio_channel != "Best" else None, + 'worst': worst == 'True', # Convert to boolean + 'no_cache': no_cache == 1, # Convert to boolean + # 'title_cache': title_cache == 1, + 'proxy': proxy, + ### iTunes specific parameters + 'store_front': store_front if store_front else "143475", + + ### BiliBili specific parameters + 'season': season, + 'title_language': title_language.lower() if title_language else None, + 'original_url': original_url, + 'original_language': original_language.lower() if original_language else None, + 'movie': movie if movie is not None else 'False', + 'android': android if android is not None else 'False' + } + } + + embed = discord.Embed( + title="📥 Download Queued", + description="Download request has been added to the queue.", + color=0x00ff00 + ) + embed.add_field(name="🛠 Service", value=service, inline=True) + embed.add_field(name="🆔 Download ID", value=download_id, inline=True) + embed.add_field(name="🔗 URL", value=url, inline=False) + embed.add_field(name="🔑 Keys", value=keys, inline=True) + embed.add_field(name="🎥 Quality", value=quality, inline=True) + embed.add_field(name="🎞 Codec", value=codec, inline=True) + embed.add_field(name="🌈 Range", value=range_, inline=True) + embed.add_field(name="🎥 Bitrate", value=bitrate if bitrate else "Max", inline=True) + embed.add_field(name="🎯 Start Season", value=start_season or "None", inline=True) + embed.add_field(name="🎯 End Season", value=end_season or "None", inline=True) + embed.add_field(name="📺 Start Episode", value=start_episode or "None", inline=True) + embed.add_field(name="📺 End Episode", value=end_episode or "None", inline=True) + embed.add_field(name="🔊 Audio Language", value=audio_language, inline=False) + embed.add_field(name="📜 Subtitle Language", value=subtitle_language, inline=False) + embed.add_field(name="🔊 Audio Channel", value=audio_channel, inline=True) + embed.add_field(name="📊 Queue Position", value=len(bot.download_queue), inline=False) + embed.set_footer(text=f"Requested by {interaction.user.display_name}") + + + await interaction.response.send_message(embed=embed) + + # Add to queue and history + bot.download_queue.append(download_entry) + bot.download_history.append(download_entry['data']) + bot.save_data() + +async def usk_worker(): + """Continuously process the download queue in the background""" + while True: + if bot.download_queue: + entry = bot.download_queue.pop(0) + await process_download(entry['data']) + else: + await asyncio.sleep(5) # Sleep briefly if queue is empty + +async def process_download(entry): + """Background worker to process download queue""" + + entry['status'] = 'in_progress' + bot.save_data() + channel = bot.get_channel(entry['channel_id']) + + cmd=['/root/unshackle/.venv/bin/unshackle','dl'] + + if entry['proxy'] and entry['service'] not in ['HIDI']: + cmd += ['--proxy', entry['proxy']] + elif entry['service'] in ['HIDI']: + cmd += ['--proxy',"ca"] + + if entry['keys']: + cmd.append('--skip-dl') + + # if entry['service'] in ['AMZN'] and not entry['keys']: + # cmd += ['--delay', '30'] + # elif entry['service'] in ['CR'] and not entry['keys']: + # cmd += ['--delay', '15'] + # elif entry['keys']: + # cmd += ['--delay', '3'] + # else: + # cmd += ['--delay', '10'] + + if entry['no_cache'] or entry['service'] in ['HMAX'] : + cmd.append('--no-cache') + + if entry['quality'].lower() != 'best': + cmd += ['--quality', entry['quality']] + + cmd += ['--range', entry['range']] + cmd += ['--vcodec', entry['codec']] + + if entry['bitrate'] is not None: + cmd += ['--vbitrate', str(entry['bitrate'])] + + if entry['worst']: + cmd += ['--worst'] + + cmd += ['--v-lang',entry["video_language"]] + cmd += ['--a-lang', f"{entry['audio_language'] if entry['service'] not in [ 'MMAX'] else 'all'}"] + cmd += ['--s-lang', f"{entry['subtitle_language'] if entry['service'] not in [ 'MMAX'] else 'all'}"] + + if entry['service'] in ['BLBL'] and not entry['audio_channel']: + cmd += ['--channels', '2.0'] + # else: + # cmd += ['--channels', entry['audio_channel']] + + if entry['start_season'] or entry['start_episode'] or entry['end_season'] or entry['end_episode']: + cmd += ['--wanted'] + wanted=None + if entry['start_season']: + wanted = 's'+entry['start_season'] + else: + wanted = "s01" + + if entry['start_episode']: + if wanted: + wanted += ('e'+entry['start_episode']) + else: + wanted = ('e'+entry['start_episode']) + if entry['end_season']: + if wanted: + wanted += '-s'+entry['end_season'] + else: + wanted = 's'+entry['end_season'] + + if entry['end_episode']: + if entry['end_season']: + if wanted: + wanted += ('e'+entry['end_episode']) + else: + wanted = ('e'+entry['end_episode']) + else: + if wanted: + wanted += ('-s01e'+entry['end_episode']) + else: + wanted = ('s01e'+entry['end_episode']) + cmd += [wanted] + + # if entry["title_cache"]: + # cmd.append('--title-cache') + + cmd += [entry['service']] + + if entry['service'] in ['AMZN']: + cmd += ["https://www.primevideo.com/detail/"+entry['url']] + else: + cmd += [entry['url']] + + # if entry['service'] == 'HS': + # cmd += ['--all'] + if entry['service'] == 'LT': + if entry['title_language']: + cmd += ['--title_lang', entry['title_language']] + if entry['movie'] and entry['movie'].lower() == 'true': + cmd += ['--movie'] + + if entry['service'] == 'OND': + if entry['title_language']: + cmd += ['--title_lang', entry['title_language']] + + if entry['service'] in ['FLX','IT','TVN','BLBL','CR']: + if entry['movie'] and entry['movie'].lower() == 'true': + cmd += ['--movie'] + + if entry['service'] == 'IT': + if entry['store_front']: + cmd += ['--storefront', entry['store_front']] + + if entry['service'] == 'TID': + if entry['season']: + cmd += ['--season', str(entry['season'])] + # if entry['title_cache']: + # cmd += ['--title-cache'] + cmd += ['--drm','wv'] + + + + if entry['service'] == 'BLBL': + if entry['season']: + cmd += ['--season', str(entry['season'])] + if entry['title_language']: + cmd += ['--title_lang', entry['title_language']] + if entry['original_url']: + cmd += ['--original_url', entry['original_url']] + if entry['original_language']: + cmd += ['--original_lang', entry['original_language']] + + if entry['android'] and entry['android'].lower() == 'true': + cmd += ['--android'] + + if entry['service'] == 'TVN': + if entry['original_language']: + cmd += ['--original_lang', entry['original_language']] + + print(f"Running command: {cmd}") + # print(f"Running command:\n{' '.join(cmd)}") + embed = discord.Embed( + title="🖹 Download Command", + description=' '.join(cmd), + color=0x0000ff + ) + + await channel.send(embed=embed) + + result = await asyncio.to_thread(subprocess.run, cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) + + try: + if '[E]' in result.stderr.decode() or "Processed all titles" not in result.stderr.decode(): + embed = discord.Embed( + title="❌ Download Failed", + description="Download request has been failed.", + color=0xff0000 + ) + embed.add_field(name="🛠 Service", value=entry['service'], inline=True) + embed.add_field(name="🔗 URL", value=entry['url'], inline=False) + embed.add_field(name="🎥 Quality", value=entry['quality'], inline=True) + embed.add_field(name="🎞 Codec", value=entry['codec'], inline=True) + embed.add_field(name="🌈 Range", value=entry['range'], inline=True) + embed.add_field(name="🎯 Start Season", value=entry['start_season'] or "None", inline=True) + embed.add_field(name="🎯 End Season", value=entry['end_season'] or "None", inline=True) + embed.add_field(name="📺 Start Episode", value=entry['start_episode'] or "None", inline=True) + embed.add_field(name="📺 End Episode", value=entry['end_episode'] or "None", inline=True) + embed.add_field(name="🔊 Audio Language", value=entry['audio_language'], inline=False) + embed.add_field(name="📜 Subtitle Language", value=entry['subtitle_language'], inline=False) + embed.add_field(name="📊 Queue Position", value=len(bot.download_queue), inline=False) + embed.add_field(name="📅 Timestamp", value=entry['timestamp'], inline=False) + embed.set_footer(text=f"Requested by {entry['user']}") + + print(result.stderr.decode()) + print(f"Error downloading {entry['url']}: ") + entry['error'] = result.stderr.decode() + entry['status'] = 'failed' + await channel.send(embed=embed) + + else: + embed = discord.Embed( + title="✅ Download Complete", + description="Download request has been completed.", + color=0x00ff00 + ) + embed.add_field(name="🛠 Service", value=entry['service'], inline=True) + embed.add_field(name="🔗 URL", value=entry['url'], inline=False) + embed.add_field(name="🎥 Quality", value=entry['quality'], inline=True) + embed.add_field(name="🎞 Codec", value=entry['codec'], inline=True) + embed.add_field(name="🌈 Range", value=entry['range'], inline=True) + embed.add_field(name="🎯 Start Season", value=entry['start_season'] or "None", inline=True) + embed.add_field(name="🎯 End Season", value=entry['end_season'] or "None", inline=True) + embed.add_field(name="📺 Start Episode", value=entry['start_episode'] or "None", inline=True) + embed.add_field(name="📺 End Episode", value=entry['end_episode'] or "None", inline=True) + embed.add_field(name="🔊 Audio Language", value=entry['audio_language'], inline=False) + embed.add_field(name="📜 Subtitle Language", value=entry['subtitle_language'], inline=False) + embed.add_field(name="📊 Queue Position", value=len(bot.download_queue), inline=False) + embed.add_field(name="📅 Timestamp", value=entry['timestamp'], inline=False) + + embed.set_footer(text=f"Requested by {entry['user']}") + + + entry['status'] = 'completed' + print(f"Download {entry['url']} completed") + await channel.send(embed=embed) + except Exception as e: + print(f"Error processing download {entry['url']}: {e}") + embed = discord.Embed( + title="❌ Download Error", + description="An error occurred while processing the download.", + color=0xff0000 + ) + embed.add_field(name="🛠 Service", value=entry['service'], inline=True) + embed.add_field(name="🔗 URL", value=entry['url'], inline=False) + embed.add_field(name="🎥 Quality", value=entry['quality'], inline=True) + embed.add_field(name="🎞 Codec", value=entry['codec'], inline=True) + embed.add_field(name="🌈 Range", value=entry['range'], inline=True) + embed.add_field(name="🎯 Start Season", value=entry['start_season'] or "None", inline=True) + embed.add_field(name="🎯 End Season", value=entry['end_season'] or "None", inline=True) + embed.add_field(name="📺 Start Episode", value=entry['start_episode'] or "None", inline=True) + embed.add_field(name="📺 End Episode", value=entry['end_episode'] or "None", inline=True) + embed.add_field(name="🔊 Audio Language", value=entry['audio_language'], inline=False) + embed.add_field(name="📜 Subtitle Language", value=entry['subtitle_language'], inline=False) + embed.add_field(name="📊 Queue Position", value=len(bot.download_queue), inline=False) + embed.add_field(name="📅 Timestamp", value=entry['timestamp'], inline=False) + embed.set_footer(text=f"Requested by {entry['user']}") + await channel.send(embed=embed) + bot.save_data() + +# /check H265 command +@bot.tree.command(name="check_codec", description="Check if codec is available") +@app_commands.describe( + service="Service to use for downloading (e.g., AMZN, NF, HS, VIU, TID, MMAX, BLBL)", + url="The URL|ID to check for codec support", + codec="Video codec to check (default: H265)", + range_="Dynamic range to use (default: SDR)", +) +@app_commands.choices(service=[ + app_commands.Choice(name="Amazon Prime", value="AMZN"), + app_commands.Choice(name="Netflix", value="NF"), + app_commands.Choice(name="Hotstar", value="HS"), + app_commands.Choice(name="VIU", value="VIU"), + app_commands.Choice(name="TrueID", value="TID"), + app_commands.Choice(name="Mono Max", value="MMAX"), + app_commands.Choice(name="BiliBili", value="BLBL"), +]) +@app_commands.choices(codec=[ + app_commands.Choice(name="H264", value="H264"), + app_commands.Choice(name="H265", value="H265"), + app_commands.Choice(name="AV1", value="AV1"), + app_commands.Choice(name="VP9", value="VP9"), +]) +@app_commands.choices(range_=[ + app_commands.Choice(name="HDR", value="HDR"), + app_commands.Choice(name="SDR", value="SDR"), + app_commands.Choice(name="DV", value="DV"), + +]) +async def check_codec_command( + interaction: discord.Interaction, + service: str, + url: str, + codec: str = "H265", + range_: Optional[str] = "SDR", +): + embed = discord.Embed( + title="🛠 H265 Codec Check", + description=f"Checking H265 codec availability for URL: {url}", + color=0x0000ff + ) + embed.add_field(name="🛠 Service", value=service, inline=True) + embed.add_field(name="🌈 Range", value=range_, inline=True) + await interaction.response.send_message(embed=embed) + # Check if H265 codec is available for the given URL + cmd, codec_available, range_available = check_codec_support(url, codec, service, range_) + + if codec_available == 'error': + embed = discord.Embed( + title="❌ Error Checking Codec", + description=f"An error occurred while checking codec support for URL: {url}", + color=0xff0000 + ) + await interaction.followup.send(embed=embed) + return + + embed = discord.Embed( + title=f"🛠 {codec} Codec Check", + description=f"{codec} codec is {'available' if codec_available else 'not available'} for URL: {url}", + color=0x00ff00 if codec_available else 0xff0000 if codec_available or codec_available else 0xffa500 + ) + embed.add_field(name=range_, value='available' if range_available else 'not available', inline=True) + embed.add_field(name="Command", value=cmd, inline=False) + await interaction.followup.send(embed=embed) +# /check H265 command +@bot.tree.command(name="clear_temp", description="Clear temporary files") +async def clear_temp_command( + interaction: discord.Interaction, +): + embed = discord.Embed( + title="🛠 Clear Temporary Files", + description="Clearing temporary files...", + color=0x0000ff + ) + + await interaction.response.send_message(embed=embed) + # Check if H265 codec is available for the given URL + os.removedirs("/root/unshackle/Temp") + embed = discord.Embed( + title="🛠 Temporary Files Cleared", + description="Temporary files have been successfully cleared.", + color=0x00ff00 + ) + + await interaction.followup.send(embed=embed) + + +def check_codec_support(url: str, codec: str, service: str, range_: str): + """Check if H265 codec is available for the given URL""" + h264_alias=['h264', 'H264', 'H.264', 'H.264', 'AVC', 'avc', 'AVC1', 'avc1'] + h265_alias=['h265', 'H265', 'H.265', 'H.265', 'HEVC', 'hevc', 'HEVC1', 'hevc1'] + error_alias=['error', 'Error', 'ERROR', '[E]', '[e]','No tracks returned'] + av1_alias=['av1', 'AV1', 'AV1.0', 'av1.0'] + vp9_alias=['vp9', 'VP9', 'VP9.0', 'vp9.0'] + + + cmd = ['/root/unshackle/.venv/bin/unshackle','dl', '--list', + '--wanted','s01e01', + '--vcodec', codec, '--range', range_] + + cmd += [service,url] # Always disable cache for codec checks + + # if service == 'NF' or service == 'HS': + # cmd += ['--all'] + + try: + print(f"Running command: {' '.join(cmd)}") + + result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + if ("Processed all titles" not in result.stderr.decode() or any(alias in result.stderr.decode() for alias in error_alias)): + print(f"Error checking codec support for {url}: {result.stderr.decode()}") + return ' '.join(cmd),'error','error' + + # codec check + codec_available = False + if codec.lower() in h264_alias: + if any(alias in result.stderr.decode() for alias in h264_alias): + codec_available = True + + elif codec.lower() in h265_alias: + if any(alias in result.stderr.decode() for alias in h265_alias): + codec_available = True + + elif codec.lower() in av1_alias: + if any(alias in result.stderr.decode() for alias in av1_alias): + codec_available = True + + elif codec.lower() in vp9_alias: + if any(alias in result.stderr.decode() for alias in vp9_alias): + codec_available = True + + if not codec_available: + print(f"{codec} codec is not available for {url}") + return ' '.join(cmd), codec_available, False + + print(f"{codec} codec {'is' if codec_available else 'is not'} available for {url}") + + # Check if HDR is available + range_available = False + print(f"Checking {range_} support for {url}") + + if range_ not in result.stderr.decode(): + print(f"HDR support not available for {url}") + else: + print(f"{range_} support available for {url}") + range_available = True + + return ' '.join(cmd), codec_available, range_available + + except Exception as e: + print(f"Exception while checking codec support: {e}") + return ' '.join(cmd),'error','error' + +# /history command +@bot.tree.command(name="history", description="List download history") +@app_commands.describe( + user="Filter by specific user (optional)" +) +async def history_command( + interaction: discord.Interaction, + user: Optional[discord.Member] = None +): + embed = discord.Embed(color=0x0099ff) + + embed.title = "📚 Download History" + + history = bot.download_history + if user: + history = [item for item in history if item['user_id'] == user.id] + embed.title += f" - {user.display_name}" + + if not history: + embed.description = "No download history found." + else: + history_list = [] + for item in history[-20:]: # Show last 20 + status_emoji = "✅" if item['status'] == 'completed' else "❌" if item['status'] == 'failed' else "⏳" if item['status'] == 'in_progress' else "🕔" + timestamp = datetime.fromisoformat(item['timestamp']).strftime("%m/%d %H:%M") + history_list.append(f"{status_emoji} **{item['id']} **{item['service']} **{item['url']} **{item['quality']} **{item['codec']} **{item['range']}") + history_list.append(f" └── {timestamp} • by {item['user']}") + + embed.description = "\n".join(history_list) + + if len(history) > 20: + embed.set_footer(text=f"Showing last 20 of {len(history)} downloads") + + await interaction.response.send_message(embed=embed) + + +# Help command +@bot.tree.command(name="help", description="Show bot commands and usage") +async def help_command(interaction: discord.Interaction): + embed = discord.Embed( + title="🤖 Bot Commands Help", + description="Here are all available commands:", + color=0x0099ff + ) + + embed.add_field( + name="📥 /download", + value="`/download [quality] [codec] [want] [audio_language] [subtitle_language]`\n" + "Download a file from the specified URL|ID\n", + inline=False + ) + + + embed.add_field( + name="📋 /history", + value="`/history `\n" + "List download history for a specific user (or all users if not specified)\n", + inline=False + ) + + embed.add_field( + name="❓ /help", + value="Show this help message", + inline=False + ) + + # embed.set_footer(text="Use /keys list to see authorized users and API keys") + + await interaction.response.send_message(embed=embed) + +# Error handling +# @bot.tree.error +# async def on_app_command_error(interaction: discord.Interaction, error: app_commands.AppCommandError): +# channel = bot.get_channel(interaction.channel_id) +# 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 +# ) + +# try: + +# if interaction.response.is_done(): +# await interaction.followup.send(embed=embed, ephemeral=True) +# else: +# await interaction.response.send_message(embed=embed, ephemeral=True) +# except discord.HTTPException: +# # If the interaction response is already sent, send a follow-up message +# await channel.send(embed=embed) + +@bot.tree.command(name="my_roles", description="List all roles the bot has in this server") +async def my_roles(interaction: discord.Interaction): + # Get the bot's Member object in this guild + bot_member: discord.Member = interaction.guild.get_member(bot.user.id) + + if not bot_member: + await interaction.response.send_message("Couldn't find myself in this guild.", ephemeral=True) + return + + roles = [role.mention for role in bot_member.roles if role.name != "@everyone"] + if roles: + await interaction.response.send_message(f"My roles are: {' '.join(roles)}") + else: + await interaction.response.send_message("I have no roles besides @everyone.") + +# Run the bot +if __name__ == "__main__": + token = os.getenv('DISCORD_TOKEN') + if not token: + print("❌ DISCORD_TOKEN not found in environment variables!") + print("Make sure you have a .env file with your bot token.") + else: + try: + bot.run(token) + except discord.LoginFailure: + print("❌ Invalid bot token! Please check your DISCORD_TOKEN in the .env file.") + except Exception as e: + print(f"❌ An error occurred: {e}") diff --git a/uv.lock b/uv.lock index ed0b7e5..378ef5f 100644 --- a/uv.lock +++ b/uv.lock @@ -472,6 +472,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/35/386550fd60316d1e37eccdda609b074113298f23cef5bddb2049823fe666/dacite-1.9.2-py3-none-any.whl", hash = "sha256:053f7c3f5128ca2e9aceb66892b1a3c8936d02c686e707bee96e19deef4bc4a0", size = 16600, upload-time = "2025-02-05T09:27:24.345Z" }, ] +[[package]] +name = "discord-py" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/57/9a2d9abdabdc9db8ef28ce0cf4129669e1c8717ba28d607b5ba357c4de3b/discord_py-2.7.1.tar.gz", hash = "sha256:24d5e6a45535152e4b98148a9dd6b550d25dc2c9fb41b6d670319411641249da", size = 1106326, upload-time = "2026-03-03T18:40:46.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/a7/17208c3b3f92319e7fad259f1c6d5a5baf8fd0654c54846ced329f83c3eb/discord_py-2.7.1-py3-none-any.whl", hash = "sha256:849dca2c63b171146f3a7f3f8acc04248098e9e6203412ce3cf2745f284f7439", size = 1227550, upload-time = "2026-03-03T18:40:44.492Z" }, +] + [[package]] name = "distlib" version = "0.4.0" @@ -481,6 +493,17 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, ] +[[package]] +name = "dotenv" +version = "0.9.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dotenv" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892, upload-time = "2025-02-19T22:15:01.647Z" }, +] + [[package]] name = "ecpy" version = "1.2.5" @@ -1318,6 +1341,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/09/0fc0719162e5ad723f71d41cf336f18b6b5054d70dc0fe42ace6b4d2bdc9/pysubs2-1.8.0-py3-none-any.whl", hash = "sha256:05716f5039a9ebe32cd4d7673f923cf36204f3a3e99987f823ab83610b7035a0", size = 43516, upload-time = "2024-12-24T12:39:44.469Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + [[package]] name = "pywidevine" version = "1.9.0" @@ -1664,6 +1696,8 @@ dependencies = [ { name = "crccheck" }, { name = "cryptography" }, { name = "curl-cffi" }, + { name = "discord-py" }, + { name = "dotenv" }, { name = "filelock" }, { name = "fonttools" }, { name = "httpx" }, @@ -1723,6 +1757,8 @@ requires-dist = [ { name = "crccheck", specifier = ">=1.3.0,<2" }, { name = "cryptography", specifier = ">=45.0.0,<47" }, { name = "curl-cffi", specifier = ">=0.7.0b4,<0.14" }, + { name = "discord-py", specifier = ">=2.7.1" }, + { name = "dotenv", specifier = ">=0.9.9" }, { name = "filelock", specifier = ">=3.20.3,<4" }, { name = "fonttools", specifier = ">=4.60.2,<5" }, { name = "httpx", specifier = ">=0.28.1,<0.29" },