mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 20:15:10 +00:00
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:
+26
-10
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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})"
|
||||
|
||||
@@ -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})"
|
||||
|
||||
Reference in New Issue
Block a user