feat(output): support nested directories in folder templates

Treat `/` in folder templates as a path separator (sanitize per segment) and let movies use a folder when a `movies` template is set.
This commit is contained in:
imSp4rky
2026-06-25 10:40:49 -06:00
parent 14c96371b9
commit e96e9be143
5 changed files with 61 additions and 30 deletions
+26 -10
View File
@@ -33,7 +33,7 @@ filenames:
## output_template (dict)
Configure custom output filename templates for movies, series, songs, and albums.
This is **required** in your `unshackle.yaml` — a warning is shown if not configured.
This is **required** in your `unshackle.yaml` - a warning is shown if not configured.
Add `?` suffix to make a variable conditional (omitted when empty): `{year?}`, `{hdr?}`, `{repack?}`
@@ -123,6 +123,15 @@ If not configured, the default folder naming is used:
`folder` accepts either a single string (applies to all title kinds) or a mapping with per-kind
templates keyed by `movies`, `series`, `songs`, and/or `albums`. Unknown keys are warned about and ignored.
Use `/` in a folder template to create nested directories - each segment is sanitized
independently and joined as real path separators. For example `'{source}/Series/{title}.{year?}'`
produces `EXAMPLE/Series/The.Show.2024/`. Note `{source}` is the service **tag** (e.g. `EXAMPLE`,
`EXAMPLE2`), not the display name; it is blank when `--no-source` is used.
Movies only get their own folder when a `movies` folder template is set; without one the movie file
is written directly into the downloads directory (unchanged default behaviour). Series and songs
always get a folder unless `--no-folder` is passed.
Useful music variables for album folders: `album_artist`, `album`, `artist`, `year`, `genre`, `label`, `release_type`, `track_total`, `disc_total`.
```yaml
@@ -143,11 +152,18 @@ output_template:
# series: '{title} ({year?})'
# songs: '{artist} - {album} ({year?})'
# albums: '{album_artist} - {album} ({year?})'
# Nested per-service layout (EXAMPLE/Series/Title.Year/...)
# folder:
# series: '{source}/Series/{title}.{year?}'
# movies: '{source}/Movies/{title}.{year?}'
# songs: '{source}/Music/{album_artist}/{album}.{year?}'
```
Example outputs:
- Scene folder: `Example.Show.2024.S01.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG/`
- Plex folder: `Example Show (2024)/`
- Nested folder: `EXAMPLE/Series/Example.Show.2024/`
---
@@ -278,7 +294,7 @@ Enable/disable tagging downloaded files with IMDB/TMDB/TVDB identifiers (when av
Override which track is flagged as the default in the muxed MKV, regardless
of the title's original language. Useful when you always want your player to
open on a specific language (e.g. always default to Polish audio even on
English originals). Only affects the MKV `--default-track` flag — track
English originals). Only affects the MKV `--default-track` flag - track
selection (`-l`, `--alang`, etc.) is unchanged. All keys are optional; each
track type falls back to its previous default rule when the configured
language isn't present in the manifest.
@@ -363,21 +379,21 @@ GitHub or any git host (GitLab, Gitea, self-hosted). Local paths and repo specs
```yaml
directories:
services:
- https://github.com/you/your-services # https repo (highest priority — listed first)
- https://github.com/you/your-services # https repo (highest priority - listed first)
- git@gitlab.com:you/your-services.git # ssh repo (private, via your git auth)
- you/your-services@main # owner/repo shorthand + optional @branch
- ~/my-local-services # local path (fallback — listed last)
- ~/my-local-services # local path (fallback - listed last)
```
How it works:
- On first use the repo is cloned (shallow) to `<your-services-dir>/_repos/<repo-name>/` — the first
- On first use the repo is cloned (shallow) to `<your-services-dir>/_repos/<repo-name>/` - the first
local `services` entry, or the bundled `unshackle/services` if you configured none. **Nothing is
written to the `cache` directory.**
- After that, unshackle does **not** hit the network on every run. It re-pulls at most once every 24h,
or immediately when you run `unshackle util refresh-services`.
- Requires `git` on your PATH. Private repos use your existing git credential helper — unshackle
stores no tokens. Git use is **read-only on the remote** — only `clone`, `fetch`, `pull`, and a
- Requires `git` on your PATH. Private repos use your existing git credential helper - unshackle
stores no tokens. Git use is **read-only on the remote** - only `clone`, `fetch`, `pull`, and a
local `reset` are run; nothing is ever pushed.
- **Local edits and refresh.** You can edit a clone under `_repos/` directly. The two refresh paths
treat your edits differently:
@@ -385,16 +401,16 @@ How it works:
changes to tracked files or unpushed local commits, unshackle **refuses to refresh and exits**,
naming the clone, so a background pull never clobbers work in progress. Commit and push it
upstream (or revert), then it refreshes normally.
- *Manual* (`unshackle util refresh-services`): an explicit "get upstream's latest" — it
- *Manual* (`unshackle util refresh-services`): an explicit "get upstream's latest" - it
**hard-resets the clone to upstream, discarding local changes**. Run it only when you want to
throw away local edits.
- Untracked files (new service folders, `__pycache__`) never block the automatic path — a
- Untracked files (new service folders, `__pycache__`) never block the automatic path - a
fast-forward pull doesn't touch them.
- The repo's **top level** must contain `<TAG>/__init__.py` service dirs (same layout as
`unshackle/services/`).
- **Priority is list order.** The first source to define a tag is the one that loads; if a later
source (repo or local) has the same tag, that copy is treated as a duplicate and ignored. So list
the sources you trust most first — e.g. repos first and local last to make local a fallback, or
the sources you trust most first - e.g. repos first and local last to make local a fallback, or
local first to let your local tweaks override a repo.
- **What you see on load.** A one-line summary is logged each run, e.g.:
+31 -16
View File
@@ -1422,8 +1422,8 @@ class dl:
titles.year = enrich_year
music_mode = isinstance(titles, Music)
music_collection_mode = isinstance(titles, list) and bool(titles) and all(
isinstance(title, Music) for title in titles
music_collection_mode = (
isinstance(titles, list) and bool(titles) and all(isinstance(title, Music) for title in titles)
)
music_titles = list(titles) if music_collection_mode else ([titles] if music_mode else [])
music_plans = {}
@@ -1558,13 +1558,10 @@ class dl:
and not no_audio
)
if music_group_download:
def download_music_title(titles: Music, plan: Any) -> bool:
music_items: list[tuple[Song, Audio, Callable[..., None]]] = []
music_song_plans = {
id(song_plan.song): song_plan
for disc in plan.discs
for song_plan in disc.songs
}
music_song_plans = {id(song_plan.song): song_plan for disc in plan.discs for song_plan in disc.songs}
music_renderer = MusicRenderer()
music_start_time = time.time()
@@ -1624,7 +1621,9 @@ class dl:
for language in audio_languages:
if language == "orig":
if song.language:
orig_lang = str(song.language) if hasattr(song.language, "__str__") else song.language
orig_lang = (
str(song.language) if hasattr(song.language, "__str__") else song.language
)
if orig_lang not in processed_lang:
processed_lang.append(orig_lang)
else:
@@ -1866,7 +1865,9 @@ class dl:
if not final_path:
continue
integrity_result = integrity_results.get(id(track))
metadata_result = metadata_results.get(id(track), MusicMetadataResult(skipped=True, reason="not processed"))
metadata_result = metadata_results.get(
id(track), MusicMetadataResult(skipped=True, reason="not processed")
)
manifest_records.append(
{
"track_number": song.track,
@@ -1949,7 +1950,9 @@ class dl:
"install mutagen to write music tags",
)
console.print(Padding(f"Metadata skipped: {reason}", (0, 5)))
console.print(Padding(f"{release_label} downloaded in [progress.elapsed]{album_time}[/]!", (0, 5, 1, 5)))
console.print(
Padding(f"{release_label} downloaded in [progress.elapsed]{album_time}[/]!", (0, 5, 1, 5))
)
return True
@@ -1982,9 +1985,7 @@ class dl:
continue
title_rule = (
f"Track {title.track:02}: {title.name}"
if music_mode and isinstance(title, Song)
else str(title)
f"Track {title.track:02}: {title.name}" if music_mode and isinstance(title, Song) else str(title)
)
console.print(Padding(Rule(f"[rule.text]{title_rule}"), (1, 2)))
temp_font_files = []
@@ -3273,7 +3274,14 @@ class dl:
base_filename = str(title)
sidecar_dir = self.output_dir or config.directories.downloads
if not no_folder and isinstance(title, (Episode, Song)) and media_info:
if (
not no_folder
and media_info
and (
isinstance(title, (Episode, Song))
or (isinstance(title, Movie) and config.get_folder_template("movies"))
)
):
sidecar_dir /= title.get_filename(
media_info, show_service=not no_source, folder=True
)
@@ -3327,7 +3335,10 @@ class dl:
if no_mux:
# Handle individual track files without muxing
final_dir = self.output_dir or config.directories.downloads
if not no_folder and isinstance(title, (Episode, Song)):
if not no_folder and (
isinstance(title, (Episode, Song))
or (isinstance(title, Movie) and config.get_folder_template("movies"))
):
# Create folder based on title
# Use first available track for filename generation
sample_track = (
@@ -3384,7 +3395,10 @@ class dl:
final_filename = title.get_filename(media_info, show_service=not no_source)
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))
or (isinstance(title, Movie) and config.get_folder_template("movies"))
):
final_dir /= title.get_filename(media_info, show_service=not no_source, folder=True)
final_dir.mkdir(parents=True, exist_ok=True)
@@ -4159,6 +4173,7 @@ class dl:
if ff_settings:
try:
from unshackle.core.utils.firefox_cookie_extractor import get_firefox_cookies
extracted_jar = get_firefox_cookies(ff_settings)
if extracted_jar:
return extracted_jar
+2 -2
View File
@@ -110,7 +110,7 @@ class Episode(Title):
separators = re.sub(r"\{[^}]*\}", "", template)
spacer = "." if "." in separators and " " not in separators else " "
return sanitize_filename(folder_name, spacer)
return "/".join(sanitize_filename(p, spacer) for p in folder_name.split("/") if p)
series_template = config.output_template.get("series")
if series_template:
@@ -160,7 +160,7 @@ class Series(SortedKeyList, ABC):
sum(seasons.values())
season_breakdown = ", ".join(f"S{season}({count})" for season, count in sorted(seasons.items()))
tree = Tree(
f"{num_seasons} season{'s'[:num_seasons^1]}, {season_breakdown}",
f"{num_seasons} season{'s'[: num_seasons ^ 1]}, {season_breakdown}",
guide_style="bright_black",
)
if verbose:
+1 -1
View File
@@ -68,7 +68,7 @@ class Movie(Title):
separators = re.sub(r"\{[^}]*\}", "", template)
spacer = "." if "." in separators and " " not in separators else " "
return sanitize_filename(folder_name, spacer)
return "/".join(sanitize_filename(p, spacer) for p in folder_name.split("/") if p)
name = f"{self.name}"
if self.year:
name += f" ({self.year})"
+1 -1
View File
@@ -103,7 +103,7 @@ class Song(Title):
separators = re.sub(r"\{[^}]*\}", "", template)
spacer = "." if "." in separators and " " not in separators else " "
return sanitize_filename(folder_name, spacer)
return "/".join(sanitize_filename(p, spacer) for p in folder_name.split("/") if p)
name = f"{self.artist} - {self.album}"
if self.year:
name += f" ({self.year})"