5 Commits

8 changed files with 101 additions and 32 deletions

View File

@@ -5,6 +5,38 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.4.2] - 2025-08-14
### Added
- **Session Management for API Requests**: Enhanced API reliability with retry logic
- Implemented session management for tags functionality with automatic retry mechanisms
- Improved API request stability and error handling
- **Series Year Configuration**: New `series_year` option for title naming control
- Added configurable `series_year` option to control year inclusion in series titles
- Enhanced YAML configuration with series year handling options
- **Audio Language Override**: New audio language selection option
- Added `audio_language` option to override default language selection for audio tracks
- Provides more granular control over audio track selection
- **Vault Key Reception Control**: Enhanced vault security options
- Added `no_push` option to Vault and its subclasses to control key reception
- Improved key management security and flexibility
### Changed
- **HLS Segment Processing**: Enhanced segment retrieval and merging capabilities
- Enhanced segment retrieval to allow all file types for better compatibility
- Improved segment merging with recursive file search and fallback to binary concatenation
- Fixed issues with VTT files from HLS not being found correctly due to format changes
- Added cleanup of empty segment directories after processing
- **Documentation**: Updated README.md with latest information
### Fixed
- **Audio Track Selection**: Improved per-language logic for audio tracks
- Adjusted `per_language` logic to ensure correct audio track selection
- Fixed issue where all tracks for selected language were being downloaded instead of just the intended ones
## [1.4.1] - 2025-08-08 ## [1.4.1] - 2025-08-08
### Added ### Added

View File

@@ -2,6 +2,10 @@
<img width="16" height="16" alt="no_encryption" src="https://github.com/user-attachments/assets/6ff88473-0dd2-4bbc-b1ea-c683d5d7a134" /> unshackle <img width="16" height="16" alt="no_encryption" src="https://github.com/user-attachments/assets/6ff88473-0dd2-4bbc-b1ea-c683d5d7a134" /> unshackle
<br/> <br/>
<sup><em>Movie, TV, and Music Archival Software</em></sup> <sup><em>Movie, TV, and Music Archival Software</em></sup>
<br/>
<a href="https://discord.gg/mHYyPaCbFK">
<img src="https://img.shields.io/discord/1395571732001325127?label=&logo=discord&logoColor=ffffff&color=7289DA&labelColor=7289DA" alt="Discord">
</a>
</p> </p>
## What is unshackle? ## What is unshackle?

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "unshackle" name = "unshackle"
version = "1.4.1" version = "1.4.2"
description = "Modular Movie, TV, and Music Archival Software." description = "Modular Movie, TV, and Music Archival Software."
authors = [{ name = "unshackle team" }] authors = [{ name = "unshackle team" }]
requires-python = ">=3.10,<3.13" requires-python = ">=3.10,<3.13"

View File

@@ -1 +1 @@
__version__ = "1.4.1" __version__ = "1.4.2"

View File

@@ -91,6 +91,7 @@ class Config:
self.update_checks: bool = kwargs.get("update_checks", True) self.update_checks: bool = kwargs.get("update_checks", True)
self.update_check_interval: int = kwargs.get("update_check_interval", 24) self.update_check_interval: int = kwargs.get("update_check_interval", 24)
self.scene_naming: bool = kwargs.get("scene_naming", True) self.scene_naming: bool = kwargs.get("scene_naming", True)
self.series_year: bool = kwargs.get("series_year", True)
self.title_cache_time: int = kwargs.get("title_cache_time", 1800) # 30 minutes default self.title_cache_time: int = kwargs.get("title_cache_time", 1800) # 30 minutes default
self.title_cache_max_retention: int = kwargs.get("title_cache_max_retention", 86400) # 24 hours default self.title_cache_max_retention: int = kwargs.get("title_cache_max_retention", 86400) # 24 hours default

View File

@@ -81,7 +81,7 @@ class Episode(Title):
def __str__(self) -> str: def __str__(self) -> str:
return "{title}{year} S{season:02}E{number:02} {name}".format( return "{title}{year} S{season:02}E{number:02} {name}".format(
title=self.title, title=self.title,
year=f" {self.year}" if self.year else "", year=f" {self.year}" if self.year and config.series_year else "",
season=self.season, season=self.season,
number=self.number, number=self.number,
name=self.name or "", name=self.name or "",
@@ -95,13 +95,13 @@ class Episode(Title):
# Title [Year] SXXEXX Name (or Title [Year] SXX if folder) # Title [Year] SXXEXX Name (or Title [Year] SXX if folder)
if folder: if folder:
name = f"{self.title}" name = f"{self.title}"
if self.year: if self.year and config.series_year:
name += f" {self.year}" name += f" {self.year}"
name += f" S{self.season:02}" name += f" S{self.season:02}"
else: else:
name = "{title}{year} S{season:02}E{number:02} {name}".format( name = "{title}{year} S{season:02}E{number:02} {name}".format(
title=self.title.replace("$", "S"), # e.g., Arli$$ title=self.title.replace("$", "S"), # e.g., Arli$$
year=f" {self.year}" if self.year else "", year=f" {self.year}" if self.year and config.series_year else "",
season=self.season, season=self.season,
number=self.number, number=self.number,
name=self.name or "", name=self.name or "",
@@ -197,7 +197,7 @@ class Series(SortedKeyList, ABC):
def __str__(self) -> str: def __str__(self) -> str:
if not self: if not self:
return super().__str__() return super().__str__()
return self[0].title + (f" ({self[0].year})" if self[0].year else "") return self[0].title + (f" ({self[0].year})" if self[0].year and config.series_year else "")
def tree(self, verbose: bool = False) -> Tree: def tree(self, verbose: bool = False) -> Tree:
seasons = Counter(x.season for x in self) seasons = Counter(x.season for x in self)

View File

@@ -10,6 +10,7 @@ from pathlib import Path
from typing import Optional, Tuple from typing import Optional, Tuple
import requests import requests
from requests.adapters import HTTPAdapter, Retry
from unshackle.core import binaries from unshackle.core import binaries
from unshackle.core.config import config from unshackle.core.config import config
@@ -25,6 +26,22 @@ HEADERS = {"User-Agent": "unshackle-tags/1.0"}
log = logging.getLogger("TAGS") log = logging.getLogger("TAGS")
def _get_session() -> requests.Session:
"""Create a requests session with retry logic for network failures."""
session = requests.Session()
session.headers.update(HEADERS)
retry = Retry(
total=3, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504], allowed_methods=["GET", "POST"]
)
adapter = HTTPAdapter(max_retries=retry)
session.mount("https://", adapter)
session.mount("http://", adapter)
return session
def _api_key() -> Optional[str]: def _api_key() -> Optional[str]:
return config.tmdb_api_key or os.getenv("TMDB_API_KEY") return config.tmdb_api_key or os.getenv("TMDB_API_KEY")
@@ -59,7 +76,8 @@ def search_simkl(title: str, year: Optional[int], kind: str) -> Tuple[Optional[d
filename += " 2160p.mkv" filename += " 2160p.mkv"
try: try:
resp = requests.post("https://api.simkl.com/search/file", json={"file": filename}, headers=HEADERS, timeout=30) session = _get_session()
resp = session.post("https://api.simkl.com/search/file", json={"file": filename}, timeout=30)
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
log.debug("Simkl API response received") log.debug("Simkl API response received")
@@ -139,17 +157,21 @@ def search_tmdb(title: str, year: Optional[int], kind: str) -> Tuple[Optional[in
if year is not None: if year is not None:
params["year" if kind == "movie" else "first_air_date_year"] = year params["year" if kind == "movie" else "first_air_date_year"] = year
r = requests.get( try:
f"https://api.themoviedb.org/3/search/{kind}", session = _get_session()
params=params, r = session.get(
headers=HEADERS, f"https://api.themoviedb.org/3/search/{kind}",
timeout=30, params=params,
) timeout=30,
r.raise_for_status() )
js = r.json() r.raise_for_status()
results = js.get("results") or [] js = r.json()
log.debug("TMDB returned %d results", len(results)) results = js.get("results") or []
if not results: log.debug("TMDB returned %d results", len(results))
if not results:
return None, None
except requests.RequestException as exc:
log.warning("Failed to search TMDB for %s: %s", title, exc)
return None, None return None, None
best_ratio = 0.0 best_ratio = 0.0
@@ -196,10 +218,10 @@ def get_title(tmdb_id: int, kind: str) -> Optional[str]:
return None return None
try: try:
r = requests.get( session = _get_session()
r = session.get(
f"https://api.themoviedb.org/3/{kind}/{tmdb_id}", f"https://api.themoviedb.org/3/{kind}/{tmdb_id}",
params={"api_key": api_key}, params={"api_key": api_key},
headers=HEADERS,
timeout=30, timeout=30,
) )
r.raise_for_status() r.raise_for_status()
@@ -219,10 +241,10 @@ def get_year(tmdb_id: int, kind: str) -> Optional[int]:
return None return None
try: try:
r = requests.get( session = _get_session()
r = session.get(
f"https://api.themoviedb.org/3/{kind}/{tmdb_id}", f"https://api.themoviedb.org/3/{kind}/{tmdb_id}",
params={"api_key": api_key}, params={"api_key": api_key},
headers=HEADERS,
timeout=30, timeout=30,
) )
r.raise_for_status() r.raise_for_status()
@@ -243,16 +265,21 @@ def external_ids(tmdb_id: int, kind: str) -> dict:
return {} return {}
url = f"https://api.themoviedb.org/3/{kind}/{tmdb_id}/external_ids" url = f"https://api.themoviedb.org/3/{kind}/{tmdb_id}/external_ids"
log.debug("Fetching external IDs for %s %s", kind, tmdb_id) log.debug("Fetching external IDs for %s %s", kind, tmdb_id)
r = requests.get(
url, try:
params={"api_key": api_key}, session = _get_session()
headers=HEADERS, r = session.get(
timeout=30, url,
) params={"api_key": api_key},
r.raise_for_status() timeout=30,
js = r.json() )
log.debug("External IDs response: %s", js) r.raise_for_status()
return js js = r.json()
log.debug("External IDs response: %s", js)
return js
except requests.RequestException as exc:
log.warning("Failed to fetch external IDs for %s %s: %s", kind, tmdb_id, exc)
return {}
def _apply_tags(path: Path, tags: dict[str, str]) -> None: def _apply_tags(path: Path, tags: dict[str, str]) -> None:

View File

@@ -15,6 +15,11 @@ set_terminal_bg: false
# false for style - Prime Suspect S07E01 The Final Act - Part One # false for style - Prime Suspect S07E01 The Final Act - Part One
scene_naming: true scene_naming: true
# Whether to include the year in series names for episodes and folders (default: true)
# true for style - Show Name (2023) S01E01 Episode Name
# false for style - Show Name S01E01 Episode Name
series_year: true
# Check for updates from GitHub repository on startup (default: true) # Check for updates from GitHub repository on startup (default: true)
update_checks: true update_checks: true