- add season_overwrite and episode_overwrite for Schedule work

- print File path at the end of file for Schedule work
- add discord downloader
This commit is contained in:
2026-03-30 11:05:08 +07:00
parent fe1ccd085c
commit c2fafcd406
7 changed files with 928 additions and 10 deletions

7
.gitignore vendored
View File

@@ -238,3 +238,10 @@ CLAUDE.md
marimo/_static/ marimo/_static/
marimo/_lsp/ marimo/_lsp/
__marimo__/ __marimo__/
WVDs/
PRDs/
Logs/
Cookies/
Cache/
Temp/

Submodule Unshackle-Service-SeFree added at 2d97c3d34a

View File

@@ -68,6 +68,8 @@ dependencies = [
"language-data>=1.4.0", "language-data>=1.4.0",
"wasmtime>=41.0.0", "wasmtime>=41.0.0",
"animeapi-py>=0.6.0", "animeapi-py>=0.6.0",
"discord-py>=2.7.1",
"dotenv>=0.9.9",
] ]
[project.urls] [project.urls]

View File

@@ -519,6 +519,11 @@ class dl:
default=False, default=False,
help="Continue with best available quality if requested resolutions are not available.", 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 @click.pass_context
def cli(ctx: click.Context, **kwargs: Any) -> dl: def cli(ctx: click.Context, **kwargs: Any) -> dl:
return dl(ctx, **kwargs) return dl(ctx, **kwargs)
@@ -1000,6 +1005,10 @@ class dl:
worst: bool, worst: bool,
best_available: bool, best_available: bool,
split_audio: Optional[bool] = None, split_audio: Optional[bool] = None,
season_overwrite: Optional[int] = None,
episode_overwrite: Optional[int] = None,
*_: Any, *_: Any,
**__: Any, **__: Any,
) -> None: ) -> None:
@@ -2464,12 +2473,11 @@ class dl:
for muxed_path in muxed_paths: for muxed_path in muxed_paths:
media_info = MediaInfo.parse(muxed_path) media_info = MediaInfo.parse(muxed_path)
final_dir = self.output_dir or config.directories.downloads 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) audio_codec_suffix = muxed_audio_codecs.get(muxed_path)
if not no_folder and isinstance(title, (Episode, Song)): 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_dir.mkdir(parents=True, exist_ok=True)
final_path = final_dir / f"{final_filename}{muxed_path.suffix}" final_path = final_dir / f"{final_filename}{muxed_path.suffix}"
template_type = ( template_type = (
@@ -2500,6 +2508,9 @@ class dl:
console.print( console.print(
Padding(f":tada: Title downloaded in [progress.elapsed]{title_dl_time}[/]!", (0, 5, 1, 5)) 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 # update cookies
cookie_file = self.get_cookie_path(self.service, self.profile) 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))) console.print(Padding(f"Processed all titles in [progress.elapsed]{dl_time}", (0, 5, 1, 5)))
def prepare_drm( def prepare_drm(
self, self,
drm: DRM_T, drm: DRM_T,

View File

@@ -78,14 +78,25 @@ class Episode(Title):
self.year = year self.year = year
self.description = description 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.""" """Build template context dictionary from MediaInfo."""
context = self._build_base_template_context(media_info, show_service) context = self._build_base_template_context(media_info, show_service)
context["title"] = self.title.replace("$", "S") context["title"] = self.title.replace("$", "S")
context["year"] = self.year or "" context["year"] = self.year or ""
context["season"] = f"S{self.season:02}"
context["episode"] = f"E{self.number:02}" if season_overwrite is not None:
context["season_episode"] = f"S{self.season:02}E{self.number:02}" 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 "" context["episode_name"] = self.name or ""
return context return context
@@ -98,7 +109,7 @@ class Episode(Title):
name=self.name or "", name=self.name or "",
).strip() ).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: if folder:
series_template = config.output_template.get("series") series_template = config.output_template.get("series")
if series_template: if series_template:
@@ -114,7 +125,7 @@ class Episode(Title):
formatter = TemplateFormatter(folder_template) formatter = TemplateFormatter(folder_template)
context = self._build_template_context(media_info, show_service) 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) folder_name = formatter.format(context)
@@ -130,7 +141,7 @@ class Episode(Title):
return sanitize_filename(name, " ") return sanitize_filename(name, " ")
formatter = TemplateFormatter(config.output_template["series"]) 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) return formatter.format(context)

849
usk_downloader_discord.py Executable file
View File

@@ -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 <service> <url> [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 <user>`\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}")

36
uv.lock generated
View File

@@ -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" }, { 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]] [[package]]
name = "distlib" name = "distlib"
version = "0.4.0" 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" }, { 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]] [[package]]
name = "ecpy" name = "ecpy"
version = "1.2.5" 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" }, { 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]] [[package]]
name = "pywidevine" name = "pywidevine"
version = "1.9.0" version = "1.9.0"
@@ -1664,6 +1696,8 @@ dependencies = [
{ name = "crccheck" }, { name = "crccheck" },
{ name = "cryptography" }, { name = "cryptography" },
{ name = "curl-cffi" }, { name = "curl-cffi" },
{ name = "discord-py" },
{ name = "dotenv" },
{ name = "filelock" }, { name = "filelock" },
{ name = "fonttools" }, { name = "fonttools" },
{ name = "httpx" }, { name = "httpx" },
@@ -1723,6 +1757,8 @@ requires-dist = [
{ name = "crccheck", specifier = ">=1.3.0,<2" }, { name = "crccheck", specifier = ">=1.3.0,<2" },
{ name = "cryptography", specifier = ">=45.0.0,<47" }, { name = "cryptography", specifier = ">=45.0.0,<47" },
{ name = "curl-cffi", specifier = ">=0.7.0b4,<0.14" }, { 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 = "filelock", specifier = ">=3.20.3,<4" },
{ name = "fonttools", specifier = ">=4.60.2,<5" }, { name = "fonttools", specifier = ">=4.60.2,<5" },
{ name = "httpx", specifier = ">=0.28.1,<0.29" }, { name = "httpx", specifier = ">=0.28.1,<0.29" },