diff --git a/docs/OUTPUT_CONFIG.md b/docs/OUTPUT_CONFIG.md index b05ab22c..df10b72a 100644 --- a/docs/OUTPUT_CONFIG.md +++ b/docs/OUTPUT_CONFIG.md @@ -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 `/_repos//` — the first +- On first use the repo is cloned (shallow) to `/_repos//` - 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 `/__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.: diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 06648afb..9c2b643c 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -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 diff --git a/unshackle/core/titles/episode.py b/unshackle/core/titles/episode.py index 6b8a6d26..13196afb 100644 --- a/unshackle/core/titles/episode.py +++ b/unshackle/core/titles/episode.py @@ -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: diff --git a/unshackle/core/titles/movie.py b/unshackle/core/titles/movie.py index c611493e..a1638367 100644 --- a/unshackle/core/titles/movie.py +++ b/unshackle/core/titles/movie.py @@ -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})" diff --git a/unshackle/core/titles/song.py b/unshackle/core/titles/song.py index 465b6591..750168e4 100644 --- a/unshackle/core/titles/song.py +++ b/unshackle/core/titles/song.py @@ -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})"