Compare commits

..

10 Commits

Author SHA1 Message Date
panitan103
bbeb93efa1 . 2026-03-30 23:04:48 +07:00
panitan103
0d93afb4af . 2026-03-30 23:04:04 +07:00
panitan103
58db1935ea set forced-subs default to True 2026-03-30 22:03:47 +07:00
panitan103
fea97880ab remove android for BLBL in discord downloader 2026-03-30 11:48:06 +07:00
panitan103
e9ca391575 - handle season,episode overwrite for song,movie
- Make folder for each type of title
- Fix bug for discord downloader
2026-03-30 11:46:33 +07:00
panitan103
c2fafcd406 - add season_overwrite and episode_overwrite for Schedule work
- print File path at the end of file for Schedule work
- add discord downloader
2026-03-30 11:05:08 +07:00
Andy
fe1ccd085c Revert "fix(drm): add track ID fallback for mp4decrypt CBCS zero-KID content"
This reverts commit 23466cae8b.
2026-03-25 14:39:08 -06:00
Andy
23466cae8b fix(drm): add track ID fallback for mp4decrypt CBCS zero-KID content
Some CBCS-encrypted content has an all-zeros default_KID in the tenc box while the real KID is only in the PSSH boxes. mp4decrypt matches keys against the tenc KID, so it silently skips decryption when the provided KID doesn't match. This adds a track ID-based key fallback when a zero KID is detected, matching the existing shaka-packager zero-KID fallback behavior.
2026-03-25 14:36:26 -06:00
Andy
d4bc095f96 fix: update actions/checkout to v5 in release workflow 2026-03-17 09:16:46 -06:00
Andy
79e8184474 ci: enable manual triggering of release workflow 2026-03-17 09:10:50 -06:00
11 changed files with 936 additions and 16 deletions

View File

@@ -1,6 +1,7 @@
name: Release name: Release
on: on:
workflow_dispatch:
push: push:
branches: [main] branches: [main]
paths: paths:
@@ -16,7 +17,7 @@ jobs:
should_release: ${{ steps.version_check.outputs.should_release }} should_release: ${{ steps.version_check.outputs.should_release }}
new_version: ${{ steps.version_check.outputs.new_version }} new_version: ${{ steps.version_check.outputs.new_version }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -56,7 +57,7 @@ jobs:
if: needs.check-version.outputs.should_release == 'true' if: needs.check-version.outputs.should_release == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@v4 uses: astral-sh/setup-uv@v4

11
.gitignore vendored
View File

@@ -238,3 +238,14 @@ CLAUDE.md
marimo/_static/ marimo/_static/
marimo/_lsp/ marimo/_lsp/
__marimo__/ __marimo__/
WVDs/
PRDs/
Logs/
Cookies/
Cache/
Temp/
bot_logs/
test*.py
Unshackle-Service-SeFree/

Submodule Unshackle-Service-SeFree added at 4fff7cf20e

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

@@ -392,7 +392,7 @@ class dl:
default=[], default=[],
help="Required subtitle languages. Downloads all subtitles only if these languages exist. Cannot be used with --s-lang.", help="Required subtitle languages. Downloads all subtitles only if these languages exist. Cannot be used with --s-lang.",
) )
@click.option("-fs", "--forced-subs", is_flag=True, default=False, help="Include forced subtitle tracks.") @click.option("-fs", "--forced-subs", is_flag=True, default=True, help="Include forced subtitle tracks.")
@click.option( @click.option(
"--exact-lang", "--exact-lang",
is_flag=True, is_flag=True,
@@ -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,18 @@ 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 isinstance(title, Movie):
final_dir = Path.joinpath(Path(final_dir),"Movie")
elif isinstance(title, Episode):
final_dir = Path.joinpath(Path(final_dir),"Series")
elif isinstance(title, Song):
final_dir = Path.joinpath(Path(final_dir),"Song")
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 +2515,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 +2528,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)

View File

@@ -57,7 +57,7 @@ class Movie(Title):
return f"{self.name} ({self.year})" return f"{self.name} ({self.year})"
return self.name return self.name
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:
name = f"{self.name}" name = f"{self.name}"
if self.year: if self.year:

View File

@@ -92,7 +92,7 @@ class Song(Title):
context["disc"] = f"{self.disc:02}" if self.disc > 1 else "" context["disc"] = f"{self.disc:02}" if self.disc > 1 else ""
return context return context
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:
name = f"{self.artist} - {self.album}" name = f"{self.artist} - {self.album}"
if self.year: if self.year:

View File

@@ -175,7 +175,7 @@ class Title:
return context return context
@abstractmethod @abstractmethod
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:
""" """
Get a Filename for this Title with the provided Media Info. Get a Filename for this Title with the provided Media Info.
All filenames should be sanitized with the sanitize_filename() utility function. All filenames should be sanitized with the sanitize_filename() utility function.

839
usk_downloader_discord.py Executable file
View File

@@ -0,0 +1,839 @@
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
)
@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(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] = "h.265",
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',
):
# 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',
}
}
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-SeFree/.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.PIPE, stderr=subprocess.PIPE)
try:
if '[E]' in result.stdout.decode() or "Processed all titles" not in result.stdout.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.stdout.decode())
print(f"Error downloading {entry['url']}: ")
entry['error'] = result.stdout.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="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"),
])
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-SeFree/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-SeFree/.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.stdout.decode() or any(alias in result.stdout.decode() for alias in error_alias)):
print(f"Error checking codec support for {url}: {result.stdout.decode()}")
return ' '.join(cmd),'error','error'
# codec check
codec_available = False
if codec.lower() in h264_alias:
if any(alias in result.stdout.decode() for alias in h264_alias):
codec_available = True
elif codec.lower() in h265_alias:
if any(alias in result.stdout.decode() for alias in h265_alias):
codec_available = True
elif codec.lower() in av1_alias:
if any(alias in result.stdout.decode() for alias in av1_alias):
codec_available = True
elif codec.lower() in vp9_alias:
if any(alias in result.stdout.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.stdout.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" },