diff --git a/pyproject.toml b/pyproject.toml index a12aff1..ea003ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,6 +66,7 @@ dependencies = [ "wasmtime>=41.0.0", "animeapi-py>=0.6.0", "rnet>=2.4.2", + "bandit>=1.9.4", ] [project.urls] diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index a83890c..17bdd58 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -573,6 +573,7 @@ class dl: raise ValueError("A subcommand to invoke was not specified, the main code cannot continue.") self.log = logging.getLogger("download") + self.completed_files: list[Path] = [] if not config.output_template: raise click.ClickException( @@ -2559,6 +2560,7 @@ class dl: final_path = final_dir / f"{base_filename}{track_path.suffix}" shutil.move(track_path, final_path) + self.completed_files.append(final_path) self.log.debug(f"Saved: {final_path.name}") else: # Handle muxed files @@ -2596,6 +2598,7 @@ class dl: final_path.unlink() shutil.move(muxed_path, final_path) used_final_paths.add(final_path) + self.completed_files.append(final_path) tags.tag_file(final_path, title, self.tmdb_id, self.imdb_id) title_dl_time = time_elapsed_since(dl_start_time) diff --git a/unshackle/core/api/download_manager.py b/unshackle/core/api/download_manager.py index 1a26235..4b5d220 100644 --- a/unshackle/core/api/download_manager.py +++ b/unshackle/core/api/download_manager.py @@ -2,6 +2,7 @@ import asyncio import json import logging import os +import re import sys import tempfile import threading @@ -149,6 +150,20 @@ def _perform_download( if params.get("export"): params["export"] = bool(params["export"]) + # Convert wanted episode strings to internal "SxE" format + # Accepts: "S01E01", "S01-S03", "s1e1", "1x1", or already-parsed format + wanted_raw = params.get("wanted") + if wanted_raw: + from unshackle.core.utils.click_types import SeasonRange + + if isinstance(wanted_raw, str): + wanted_raw = [wanted_raw] + # Only convert if not already in internal "SxE" format + needs_conversion = any(not re.match(r"^\d+x\d+$", w) for w in wanted_raw) + if needs_conversion: + season_range = SeasonRange() + params["wanted"] = season_range.parse_tokens(*wanted_raw) + # Load service configuration service_config_path = Services.get_path(service) / config.filenames.config if service_config_path.exists(): @@ -271,6 +286,8 @@ def _perform_download( acodec=params.get("acodec"), vbitrate=params.get("vbitrate"), abitrate=params.get("abitrate"), + vbitrate_range=params.get("vbitrate_range"), + abitrate_range=params.get("abitrate_range"), range_=params.get("range", ["SDR"]), channels=params.get("channels"), no_atmos=params.get("no_atmos", False), @@ -306,6 +323,7 @@ def _perform_download( no_mux=params.get("no_mux", False), workers=params.get("workers"), downloads=params.get("downloads", 1), + worst=params.get("worst", False), best_available=params.get("best_available", False), split_audio=params.get("split_audio"), ) @@ -327,9 +345,10 @@ def _perform_download( log.error(f"Stderr: {stderr_str}") raise - log.info(f"Download completed for job {job_id}, files in {original_download_dir}") + output_files = [str(p) for p in dl_instance.completed_files] + log.info(f"Download completed for job {job_id}, {len(output_files)} file(s) in {original_download_dir}") - return [] + return output_files class DownloadQueueManager: diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index 9739132..c576e31 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -29,6 +29,8 @@ DEFAULT_DOWNLOAD_PARAMS = { "acodec": None, "vbitrate": None, "abitrate": None, + "vbitrate_range": None, + "abitrate_range": None, "range": ["SDR"], "channels": None, "no_atmos": False, @@ -62,6 +64,7 @@ DEFAULT_DOWNLOAD_PARAMS = { "no_mux": False, "workers": None, "downloads": 1, + "worst": False, "best_available": False, "repack": False, "imdb_id": None, @@ -981,6 +984,14 @@ def validate_download_parameters(data: Dict[str, Any]) -> Optional[str]: if not isinstance(data["abitrate"], int) or data["abitrate"] <= 0: return "abitrate must be a positive integer" + if "vbitrate_range" in data and data["vbitrate_range"] is not None: + if not isinstance(data["vbitrate_range"], str) or "-" not in data["vbitrate_range"]: + return "vbitrate_range must be a string in 'MIN-MAX' format (e.g., '6000-7000')" + + if "abitrate_range" in data and data["abitrate_range"] is not None: + if not isinstance(data["abitrate_range"], str) or "-" not in data["abitrate_range"]: + return "abitrate_range must be a string in 'MIN-MAX' format (e.g., '128-256')" + if "channels" in data and data["channels"] is not None: if not isinstance(data["channels"], (int, float)) or data["channels"] <= 0: return "channels must be a positive number" diff --git a/uv.lock b/uv.lock index 50c7eb6..bb38b22 100644 --- a/uv.lock +++ b/uv.lock @@ -150,6 +150,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] +[[package]] +name = "bandit" +version = "1.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "stevedore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/c3/0cb80dfe0f3076e5da7e4c5ad8e57bac6ac357ff4a6406205501cade4965/bandit-1.9.4.tar.gz", hash = "sha256:b589e5de2afe70bd4d53fa0c1da6199f4085af666fde00e8a034f152a52cd628", size = 4242677, upload-time = "2026-02-25T06:44:15.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/a4/a26d5b25671d27e03afb5401a0be5899d94ff8fab6a698b1ac5be3ec29ef/bandit-1.9.4-py3-none-any.whl", hash = "sha256:f89ffa663767f5a0585ea075f01020207e966a9c0f2b9ef56a57c7963a3f6f8e", size = 134741, upload-time = "2026-02-25T06:44:13.694Z" }, +] + [[package]] name = "beautifulsoup4" version = "4.14.3" @@ -1520,6 +1535,15 @@ version = "3.5.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/66/b7/4a1bc231e0681ebf339337b0cd05b91dc6a0d701fa852bb812e244b7a030/srt-3.5.3.tar.gz", hash = "sha256:4884315043a4f0740fd1f878ed6caa376ac06d70e135f306a6dc44632eed0cc0", size = 28296, upload-time = "2023-03-28T02:35:44.007Z" } +[[package]] +name = "stevedore" +version = "5.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6d/90764092216fa560f6587f83bb70113a8ba510ba436c6476a2b47359057c/stevedore-5.7.0.tar.gz", hash = "sha256:31dd6fe6b3cbe921e21dcefabc9a5f1cf848cf538a1f27543721b8ca09948aa3", size = 516200, upload-time = "2026-02-20T13:27:06.765Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/06/36d260a695f383345ab5bbc3fd447249594ae2fa8dfd19c533d5ae23f46b/stevedore-5.7.0-py3-none-any.whl", hash = "sha256:fd25efbb32f1abb4c9e502f385f0018632baac11f9ee5d1b70f88cc5e22ad4ed", size = 54483, upload-time = "2026-02-20T13:27:05.561Z" }, +] + [[package]] name = "subby" version = "0.3.27" @@ -1634,6 +1658,7 @@ dependencies = [ { name = "aiohttp-swagger3" }, { name = "animeapi-py" }, { name = "appdirs" }, + { name = "bandit" }, { name = "brotli" }, { name = "chardet" }, { name = "click" }, @@ -1691,6 +1716,7 @@ requires-dist = [ { name = "aiohttp-swagger3", specifier = ">=0.9.0,<1" }, { name = "animeapi-py", specifier = ">=0.6.0" }, { name = "appdirs", specifier = ">=1.4.4,<2" }, + { name = "bandit", specifier = ">=1.9.4" }, { name = "brotli", specifier = ">=1.1.0,<2" }, { name = "chardet", specifier = ">=5.2.0,<6" }, { name = "click", specifier = ">=8.1.8,<9" },