From 68c7d95173a680fdb025d1467f94c35a78692e0b Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Tue, 18 Aug 2026 15:05:30 -0600 Subject: [PATCH] refactor(music)!: make Song an ordinary Title on the generic path Delete the dead grouped-download block, the planner, and the music service hooks; Song now flows through the one download loop, gets tagged via tag_file, and answers -w with {disc}x{track} keys. BREAKING CHANGE: GROUP_AUDIO_DOWNLOADS, get_music_track_options and get_music_collection_label are gone. A music service returns one Audio track per quality and lets the framework choose. --- docs/dev/creating-a-service.md | 29 +- docs/dev/rest-api/endpoints.md | 11 +- docs/dev/rest-api/quickstart.md | 2 +- docs/dev/service-architecture.md | 47 +- docs/getting-started/quickstart.md | 2 +- docs/guide/cli-reference.md | 2 +- docs/guide/downloading.md | 90 ++- docs/guide/output-and-naming.md | 46 +- docs/reference/configuration/post-scripts.md | 24 +- tests/core/test_music_display.py | 213 ------- tests/core/test_music_extract.py | 14 - tests/core/test_music_folder_template.py | 46 +- tests/core/test_music_tagger_roundtrip.py | 198 ++++++ tests/core/test_music_wanted.py | 126 ++++ tests/core/test_tag_file_dispatch.py | 120 ++++ tests/core/test_template_formatter.py | 5 +- tests/test_post_scripts_music.py | 209 +++--- unshackle/commands/dl.py | 638 ++----------------- unshackle/core/api/download_manager.py | 6 +- unshackle/core/api/handlers.py | 17 +- unshackle/core/api/routes.py | 4 +- unshackle/core/config.py | 2 +- unshackle/core/console.py | 8 + unshackle/core/music/__init__.py | 23 - unshackle/core/music/display.py | 174 ----- unshackle/core/music/extract.py | 31 +- unshackle/core/music/hasher.py | 16 - unshackle/core/music/integrity.py | 189 ------ unshackle/core/music/manifest.py | 23 - unshackle/core/music/models.py | 58 -- unshackle/core/music/planner.py | 214 ------- unshackle/core/music/renderer.py | 266 ++------ unshackle/core/music/tagger.py | 74 ++- unshackle/core/titles/episode.py | 3 +- unshackle/core/titles/movie.py | 3 +- unshackle/core/titles/music.py | 31 +- unshackle/core/titles/song.py | 136 +--- unshackle/core/tracks/audio.py | 2 +- unshackle/core/utils/click_types.py | 52 +- unshackle/core/utils/post_scripts.py | 13 + unshackle/core/utils/tags.py | 16 +- unshackle/services/EXAMPLE/__init__.py | 29 +- unshackle/services/EXAMPLE/config.yaml | 2 +- 43 files changed, 1082 insertions(+), 2132 deletions(-) delete mode 100644 tests/core/test_music_display.py create mode 100644 tests/core/test_music_tagger_roundtrip.py create mode 100644 tests/core/test_music_wanted.py create mode 100644 tests/core/test_tag_file_dispatch.py delete mode 100644 unshackle/core/music/display.py delete mode 100644 unshackle/core/music/hasher.py delete mode 100644 unshackle/core/music/integrity.py delete mode 100644 unshackle/core/music/manifest.py delete mode 100644 unshackle/core/music/models.py delete mode 100644 unshackle/core/music/planner.py diff --git a/docs/dev/creating-a-service.md b/docs/dev/creating-a-service.md index 91547c3a..fb70733e 100644 --- a/docs/dev/creating-a-service.md +++ b/docs/dev/creating-a-service.md @@ -12,10 +12,11 @@ downloading, decryption, muxing, naming) is handled by the unshackle core. the CLI. Service code is deliberately kept separate from the core so it can live in its own private repository. -Unshackle ships one reference service, **EXAMPLE**, at -`unshackle/services/EXAMPLE/`. It is a deliberately exhaustive, non-runnable -showcase of every framework feature in a single file. Read it alongside this -page; most snippets below are drawn from it. +unshackle ships two reference services. **EXAMPLE**, at +`unshackle/services/EXAMPLE/`, is a non-runnable showcase of every framework +feature in a single file. Read it alongside this page. Most snippets below come +from it. **MUSIC_EXAMPLE**, at `unshackle/services/MUSIC_EXAMPLE/`, is the same +kind of showcase for a music service. If your platform has songs, read that one too. --- @@ -318,11 +319,20 @@ Common constructor fields: === "Song" ```python - Song(id_, service, name, artist, album, track, disc, year, - language=None, data=None) + Song(id_, service, name, artist, album, track, disc=1, year=None, + language=None, data=None, ...) ``` - Return an `Album([...])` of songs. + Return an `Album([...])` of songs. `disc` defaults to 1 and `year` is optional. + Optional metadata keywords such as `album_artist`, `genre`, `isrc`, `label`, + `lyrics` and `artwork_url` feed the tagger and the naming templates. Read the + class for the current list. + + From `get_tracks()`, give one `Audio` track for each codec and bitrate you offer + for the song. The framework then selects one with its usual audio options. + A `Song` skips the muxer, so restore the real container suffix on the track path + in `on_track_downloaded`. The **MUSIC_EXAMPLE** service shows the full music + surface. !!! note "The ID must be unique and stable" `id_` must be truthy and, if it has a length, at least 4 characters, to @@ -696,5 +706,6 @@ unshackle dl MYSVC https://myservice.com/watch/abc123 - [ ] URLs, user agents, and certificates live in `config.yaml`, read via `self.config[...]`. - [ ] Multi-file services import their own modules relatively (`from .helpers import x`), so they work when loaded from a repo. -Read `unshackle/services/EXAMPLE/` end to end; it annotates every feature -touched above in one place. +Read `unshackle/services/EXAMPLE/` end to end. It annotates every feature +touched above in one place. For a music service, read +`unshackle/services/MUSIC_EXAMPLE/` too. diff --git a/docs/dev/rest-api/endpoints.md b/docs/dev/rest-api/endpoints.md index f40ee4e9..47520282 100644 --- a/docs/dev/rest-api/endpoints.md +++ b/docs/dev/rest-api/endpoints.md @@ -223,6 +223,13 @@ Service-specific CLI options may also be passed as additional keys. Each serialized title carries `type` (`"episode"`, `"movie"`, or `"other"`), `name`, `id`, `language`, `description`, `date`, and `cover_url`. Episodes and movies add `year`; episodes additionally add `series_title`, `season`, and `number`. +!!! warning "A song serializes as `"other"`" + A music track has no `type` of its own. It is sent as `"other"`, and the response + carries none of its music fields: no artist, no album, no track or disc number, and no + `year`. `name` holds the track name. To read the disc and track numbers of a release, + run the CLI with `--list-titles`. Selecting music tracks over the API still works, + through the `wanted` parameter. + These keys are sent only when the title carries them, so a title without them serializes exactly as before: | Key | Type | On | Meaning | @@ -261,7 +268,7 @@ List the video, audio, and subtitle tracks for a title. For series, you can scop | --- | --- | --- | --- | | `service` | string | yes | Service tag. | | `title_id` | string | yes | Title identifier. | -| `wanted` | string | no | Episode/season range (e.g. `"S01E01-S01E03"`). | +| `wanted` | string / string[] | no | Episode/season range (e.g. `"S01E01-S01E03"`), or a music track selector (e.g. `"1-5"`, `"1,3,7"`, or `"2x3"` for disc 2 track 3). A list of selectors is accepted too. | | `season` | int/string | no | Season number (combined with `episode`). | | `episode` | int/string | no | Episode number (combined with `season`). | | `part` | int/string | no | Part index of a split episode (combined with `season` and `episode`). | @@ -378,7 +385,7 @@ Create a download job. Requires `service` and `title_id`; every other field is a | `range` | string[] | `["SDR"]` | Dynamic range(s). | | `channels` | number | `null` | Audio channel count. | | `no_atmos` | boolean | `false` | Exclude Atmos tracks. | -| `wanted` | string[] | `[]` | Episode/season selectors. Accepts the part form, `"S01E01.2"`, and the air-date form, `"2026-08-11"` or `"2026-08-01:2026-08-31"`. | +| `wanted` | string / string[] | `[]` | Episode/season selectors, as a list or as one comma-separated string. Accepts the part form, `"S01E01.2"`, and the air-date form, `"2026-08-11"` or `"2026-08-01:2026-08-31"`. For a music release, a selector is a track number, `"1-5"` or `"1,3,7"`, or `"{disc}x{track}"` such as `"2x3"`. | | `latest_episode` | boolean | `false` | Only the newest episode. | | `lang` / `v_lang` / `a_lang` / `s_lang` | string[] | `["orig"]` / `[]` / `[]` / `["all"]` | Language filters. | | `require_subs` | string[] | `[]` | Required subtitle languages. | diff --git a/docs/dev/rest-api/quickstart.md b/docs/dev/rest-api/quickstart.md index e06e1aff..d99c8483 100644 --- a/docs/dev/rest-api/quickstart.md +++ b/docs/dev/rest-api/quickstart.md @@ -232,7 +232,7 @@ The request body accepts the full download parameter set. A few of the most usef | `vcodec` | `null` | Video codec, e.g. `"H265"` | | `acodec` | `null` | Audio codec, e.g. `"EC3"` | | `range` | `["SDR"]` | Dynamic range: `SDR`, `HDR10`, `HDR10+`, `DV`, `HLG`, `HYBRID` | -| `wanted` | `[]` | Episodes to fetch, e.g. `["S01E01"]`; empty means all | +| `wanted` | `[]` | Episodes to fetch, e.g. `["S01E01"]`, or music tracks, e.g. `["1-5"]`. Empty means all | | `lang` | `["orig"]` | Video/audio language(s) | | `s_lang` | `["all"]` | Subtitle language(s) | | `downloads` | `1` | Number of tracks to download concurrently | diff --git a/docs/dev/service-architecture.md b/docs/dev/service-architecture.md index 4819ffe4..36465957 100644 --- a/docs/dev/service-architecture.md +++ b/docs/dev/service-architecture.md @@ -33,7 +33,8 @@ Track.download → fetch + merge segments (core-driven) │ DRM license + decrypt → service license hooks → CDM → decrypt tool │ -Tracks.mux → mkvmerge → tagged .mkv +Tracks.mux → mkvmerge → tagged .mkv (movie / episode) + │ a Song skips the muxer and keeps its own container ``` 1. **`get_titles()`** returns a `Titles` collection: the movies, episodes, or songs @@ -173,11 +174,45 @@ Each subclass adds its own required fields on top of `id_` and `service`: === "Song" ```python - Song(id_, service, name, artist, album, track, disc, year, - language=None, data=None) + Song(id_, service, name, artist, album, track, disc=1, year=None, + language=None, data=None, ...) ``` - `name`, `artist`, `album`, `track`, `disc`, and `year` are all required and validated. + `name`, `artist`, `album`, and `track` are required and validated. `disc` defaults to + 1, and `year` is optional. A single therefore needs no `disc`, and an undated release + needs no `year`. + + A `Song` also takes optional metadata keywords that the tagger and the naming templates + read: `album_artist`, `release_type`, `total_tracks`, `total_discs`, `genre`, + `explicit`, `isrc`, `upc`, `copyright`, `label`, `lyrics`, and `artwork_url`. Read the + class for the current list. + + The tagger also falls back to `Song.data` for fields the constructor did not get, such + as the composer or a full release date. It reads the top level of that dict and a + nested `metadata` sub-dict, with the nested keys winning, so either shape works. + + From `get_tracks()`, a music service gives **one `Audio` track for each codec and + bitrate it offers for a `Song`**. The framework then selects one with its usual audio + options, `-a`/`--acodec` and `--abitrate`, and its usual audio sorting. A movie or an + episode uses the same options. `-q`/`--quality` sets the video height only and does not + apply to music. `Audio` has no `bit_depth` or `sample_rate` field. Therefore `bitrate` + separates a hi-res FLAC from a CD FLAC, and the audio sort already ranks it. + + A `Song` is an ordinary `Title` on the one generic download path. unshackle has no + music-specific service API and no separate music download loop. The + `GROUP_AUDIO_DOWNLOADS` flag, `get_music_track_options` and `get_music_collection_label` + no longer exist, and a music service does not need them. + + !!! warning "Restore the container in `on_track_downloaded`" + Every track downloads to a temp file named `Audio_.mp4` whatever it holds, and + the final move keeps that suffix. A `Song` skips the muxer, so its download **is** + the delivered file, and the tagger picks its metadata format from the suffix. + Rename the track path to the true container in `on_track_downloaded`, or a FLAC + lands as an untaggable `.mp4`. The event fires twice, once before decryption and + once at the end, so wait until `track.drm` is clear before you rename. + + The **MUSIC_EXAMPLE** reference service, at `unshackle/services/MUSIC_EXAMPLE/`, shows + the full music surface. ### Title remapping @@ -237,8 +272,8 @@ closed-caption descriptors. The relevant enums: [Dolby Vision hybrid](#dolby-vision-and-hybrid)). !!! note "Bitrate units" - Both `Video` and `Audio` store `bitrate` in **bytes per second** (rounded up), even - though the string form displays kb/s. + Both `Video` and `Audio` store `bitrate` in **bits per second** (rounded up). The + string form displays kb/s, and `--vbitrate` and `--abitrate` match on kb/s. ### `Audio` diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index c645eeec..d0bbdfde 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -187,7 +187,7 @@ keys through your CDM (and any key vaults), then decrypt, mux, and tag the resul | `-sl`, `--s-lang` | Subtitle language(s); defaults to `all`. | | `-v`, `--vcodec` | Video codec, e.g. `-v H.265`. Defaults to any. | | `-r`, `--range` | Dynamic range, e.g. `-r HDR10` or `-r DV`. Defaults to `SDR`. | -| `-w`, `--wanted` | Which episodes, e.g. `-w S01` or `-w S01E01-S01E03`. | +| `-w`, `--wanted` | Which episodes, e.g. `-w S01` or `-w S01E01-S01E03`. For music, which tracks, e.g. `-w 1-5` or `-w 2x3` for disc 2 track 3. | | `-o`, `--output` | Override the output directory for this run. | | `--list` | List the tracks that would be downloaded, then stop. | diff --git a/docs/guide/cli-reference.md b/docs/guide/cli-reference.md index 3c25caff..45cf4240 100644 --- a/docs/guide/cli-reference.md +++ b/docs/guide/cli-reference.md @@ -112,7 +112,7 @@ The special language tokens `orig`, `all`, and `best` are honoured everywhere a | Flag | Description | |---|---| -| `-w`, `--wanted` | Wanted episodes, e.g. `S01-S05,S07`, `S01E01-S02E03`. Supports exclusions with a leading `-` (e.g. `-S03`). For a [split episode](downloading.md#split-episodes), `.N` picks one part (`S01E01.2`), a range must stay inside the episode (`S01E01.1-S01E01.3`), and `S01E01` on its own takes every part. For [dated content](downloading.md#daily-and-date-based-content), a token can also be an ISO air date (`2026-08-11`) or a date range with a colon (`2026-08-01:2026-08-31`). | +| `-w`, `--wanted` | Wanted episodes, e.g. `S01-S05,S07`, `S01E01-S02E03`. Supports exclusions with a leading `-` (e.g. `-S03`). For a [split episode](downloading.md#split-episodes), `.N` picks one part (`S01E01.2`), a range must stay inside the episode (`S01E01.1-S01E01.3`), and `S01E01` on its own takes every part. For [dated content](downloading.md#daily-and-date-based-content), a token can also be an ISO air date (`2026-08-11`) or a date range with a colon (`2026-08-01:2026-08-31`). For a [music release](downloading.md#music-tracks), a token is a track number (`1-5`, `1,3,7`), or `{disc}x{track}` (`2x3`) when the release has more than one disc. | | `--select-titles` | Interactively select what to download: episodes of a series, or films when a title has more than one. **Cannot combine with `-w`.** | | `--latest-episode` | Download only the single most recent episode. | | `--list-titles` | List titles only; do not download. | diff --git a/docs/guide/downloading.md b/docs/guide/downloading.md index 0a504c73..0d9e13f4 100644 --- a/docs/guide/downloading.md +++ b/docs/guide/downloading.md @@ -232,6 +232,27 @@ unshackle dl -a EC3,AAC EXAMPLE 81234567 unshackle dl -c 5.1 --noatmos EXAMPLE 0ABC123 ``` +### Music quality + +A music service gives one `Audio` track for each codec and bitrate it offers for a song. +The audio options above choose between them: `-a` picks the codec and `-ab` or `-ab-range` +picks the bitrate. If you set neither, unshackle takes the best track. + +`-q` / `--quality` is a video height. It has no effect on a music download. + +```shell title="Take the lossless track" +unshackle dl -a FLAC EXAMPLE 81234567 +``` + +```shell title="Take the lossy track at 320 kb/s" +unshackle dl -a AAC -ab 320 EXAMPLE 81234567 +``` + +!!! tip "Two FLAC tracks in one release" + A service can offer both a CD FLAC and a hi-res FLAC. The two have the same codec, so + the bitrate is what separates them. Run `--list` to read the bitrates, then give `-ab` + the one you want. + ## Languages ### Video and audio language @@ -381,15 +402,16 @@ unshackle dl --sub-format srt EXAMPLE 81234567 automatically. This behaviour is governed by the `subtitle.strip_sdh` config option (default on). -## Selecting episodes +## Selecting episodes and tracks -For series, several flags control which episodes are downloaded. By default, **all** -episodes are downloaded. +For series, several flags control which episodes are downloaded. The same flags select the +tracks of a music release. By default, **all** episodes and **all** tracks are downloaded. ### Wanted ranges -`-w` / `--wanted` accepts flexible season/episode ranges, comma-separated. Prefix a token -with `-` to exclude it. +`-w` / `--wanted` accepts season and episode ranges, comma-separated. Prefix a token +with `-` to exclude it. For a music release, the same option takes track numbers. Read +[music tracks](#music-tracks) below. === "Whole seasons" @@ -440,6 +462,49 @@ with `-` to exclude it. unshackle dl -w 2026-08-01:2026-08-31,-2026-08-15 EXAMPLE 81234567 ``` +### Music tracks + +`-w` selects the tracks of an album, EP, single, or playlist. Give a track by its number. +A release with more than one disc uses the `{disc}x{track}` form. A number on its own is a +track on disc 1, which is the number the track list shows for a single-disc release. + +=== "Tracks" + + ```shell + # Track 3 + unshackle dl -w 3 EXAMPLE 81234567 + + # Tracks 1 through 5 + unshackle dl -w 1-5 EXAMPLE 81234567 + + # Tracks 1, 3 and 7 + unshackle dl -w 1,3,7 EXAMPLE 81234567 + ``` + +=== "More than one disc" + + ```shell + # Disc 2, track 3 + unshackle dl -w 2x3 EXAMPLE 81234567 + + # Disc 2, tracks 1 through 4 + unshackle dl -w 2x1-2x4 EXAMPLE 81234567 + + # Disc 1 track 3, through disc 2 track 2 + unshackle dl -w 1x3-2x2 EXAMPLE 81234567 + ``` + +=== "Exclusions" + + ```shell + # Tracks 1 through 8, but not track 4 + unshackle dl -w 1-8,-4 EXAMPLE 81234567 + ``` + +!!! tip "Read the track list first" + `--list-titles` prints the release with its disc and track numbers, so you can see + which numbers to give `-w`. + ### Daily and date-based content Talk shows, news and sports have no official episode numbering, so unshackle names them @@ -570,8 +635,8 @@ unshackle dl -o ~/Videos/incoming EXAMPLE 81234567 ### Muxing behaviour -By default, tracks are muxed into a single Matroska (`.mkv`) file with `mkvmerge`. These -flags change how the output is assembled: +By default, the tracks of a movie or an episode are muxed into a single Matroska (`.mkv`) +file with `mkvmerge`. These flags change how the output is assembled: | Flag | Behaviour | Default source | | --- | --- | --- | @@ -579,12 +644,17 @@ flags change how the output is assembled: | `--split-audio` | Write a separate output file per audio codec instead of merging all audio. | config `muxing.merge_audio` (on) | | `--merge-video` | Mux video tracks that share a height, range, and codec into one file, so only language varies inside a file. | config `muxing.merge_video` (off) | +!!! note "Music is never muxed" + A music track skips the muxer. unshackle keeps the container that the service + delivered, such as `.flac`, `.m4a`, or `.mp3`, and writes the metadata into that file. + Read [Music output files](output-and-naming.md#music-output-files). + ### After the download `--postscript ""` runs your own command once per output file, with unshackle's metadata substituted into `{variable}` placeholders. It is repeatable, and it replaces the -`post_scripts` config for that run. Nothing runs under `--no-mux`, since no output is -written. +`post_scripts` config for that run. No `success` hook runs under `--no-mux`, because that +run writes no muxed output. A `failure` hook still runs if the download itself fails. ```shell title="Hand each finished file to an uploader" unshackle dl --postscript "python /opt/upload.py {filepath} --service={service}" EXAMPLE 81234567 @@ -820,7 +890,7 @@ authoritative list. | `--forced-subs` | `-fs` | Include forced subtitles. | | `--forced-s-lang` | `-fsl` | Forced subtitle language(s); implies `-fs`. `-` excludes. | | `--sub-format` | | Output subtitle format. | -| `--wanted` | `-w` | Episode/season range, or an air date. | +| `--wanted` | `-w` | Episode/season range, an air date, or a music track (`1-5`, `2x3`). | | `--daily` | | Fill missing air dates from TVDB during `--enrich`. | | `--select-titles` | | Interactively pick episodes or films. | | `--latest-episode` | | Only the newest episode. | diff --git a/docs/guide/output-and-naming.md b/docs/guide/output-and-naming.md index d2b13051..4e88b588 100644 --- a/docs/guide/output-and-naming.md +++ b/docs/guide/output-and-naming.md @@ -6,7 +6,7 @@ If you have not configured `unshackle.yaml` yet, read [Configuration File](../ge ## How an output path is assembled -When a download finishes muxing, unshackle builds the final path from three pieces: +When a download finishes, unshackle builds the final path from three pieces: ```text / / @@ -15,7 +15,7 @@ When a download finishes muxing, unshackle builds the final path from three piec - **Output directory**: `directories.downloads` from your config, or whatever you pass to `-o/--output` on the command line for a single run. - **Folder template**: an optional per-title subfolder. TV episodes and music tracks are **always** placed in a folder; movies only get one if you define a `movies` folder template, or a single-string `folder` template that applies to all title kinds (see [Folder templates](#folder-templates)). - **Filename template**: the `output_template` for the title kind (movie, series, or song). -- **Extension**: chosen by the muxer: `.mkv` for video, `.mka` for audio-only, `.mks` for subtitle-only. +- **Extension**: chosen by the muxer for a movie or an episode: `.mkv` for video, `.mka` for audio-only, `.mks` for subtitle-only. A music track is not muxed. It keeps the container the service delivered, such as `.flac`, `.m4a`, or `.mp3`. Read [Music output files](#music-output-files). !!! example "A typical episode path" ```text @@ -218,14 +218,52 @@ The per-kind folder keys are `movies`, `series`, `songs`, and `albums`. Any othe **Fallback behavior:** - A per-kind folder template wins if present; otherwise the single `folder` string is used; otherwise unshackle falls back to a built-in default. -- Built-in defaults when no folder template is set: movies get no folder at all unless a `movies` folder template (or a single-string `folder` template) exists; series fall back to a folder *derived* from the `series` output template (stripping `{episode}`, `{episode_name}`, and collapsing `{season_episode}` down to `{season}`); music albums fall back to `{artist} - {album} ({year})`. +- Built-in defaults when no folder template is set. Movies get no folder at all unless a `movies` folder template (or a single-string `folder` template) exists. Series fall back to a folder *derived* from the `series` output template (stripping `{episode}`, `{episode_name}`, and collapsing `{season_episode}` down to `{season}`). Music albums fall back to `{artist} - {album} ({year})`. If the song has no year, the folder name is only `{artist} - {album}`. !!! note "Movies are flat by default" Episodes and songs are always foldered. Movies are written directly into the output directory *unless* you define a `movies` folder template (or a single-string `folder` template, which folders every kind). Set one if you want each movie in its own directory. +## Music output files + +A music track does not go through the muxer. unshackle moves the downloaded audio file to +its final path. It keeps the container that the service delivered. A lossless download +arrives as a `.flac` file. An AAC download arrives as a `.m4a` file. unshackle writes no +`.mkv` file for music, and the [muxing options](#muxing-options) below do not apply. + +The `songs` output template names the file. The `albums` folder template names the folder +around it. + +```text +/home/user/Downloads/ + └─ The Artist - The Album (2024)/ + ├─ 01. First Track.flac + └─ 02. Second Track.flac +``` + +### Music metadata + +After the file moves, unshackle writes the metadata into the audio file. It uses Vorbis +comments for FLAC and Ogg, ID3 frames for MP3, and MP4 atoms for M4A. The tags hold the +track title, artist, album artist, track and disc numbers, date, genre, ISRC, UPC, +copyright, and record label. When `tag` and `tag_group_name` are set, unshackle also writes the group +name into a `GROUP` tag. + +Cover art is embedded when the service gives an artwork URL. unshackle downloads the image +and puts it in the file. + +Lyrics are written when the service supplies them: a `LYRICS` Vorbis comment for FLAC, a +`USLT` frame for MP3, and a `©lyr` atom for M4A. Ogg uses the same `LYRICS` field as +FLAC. unshackle does not write synchronized lyrics (`SYLT`). + +A metadata fault does not stop the run. The file is already in place, so unshackle writes a +warning and continues. + +!!! warning "`--no-mux` leaves music files untagged" + [`--no-mux`](cli-reference.md#output-muxing-files) moves each downloaded track file to the output path without the tagging step. A music file from such a run holds only the metadata that the service put in it. + ## Muxing options -Muxing (combining video, audio, subtitle, chapter and attachment tracks into a single Matroska file with `mkvmerge`) is configured under the `muxing` key. +Muxing (combining video, audio, subtitle, chapter and attachment tracks into a single Matroska file with `mkvmerge`) is configured under the `muxing` key. Only movies and episodes are muxed. Read [Music output files](#music-output-files) for what a music download does instead. ```yaml title="unshackle.yaml" muxing: diff --git a/docs/reference/configuration/post-scripts.md b/docs/reference/configuration/post-scripts.md index 26e27ea3..ca14e971 100644 --- a/docs/reference/configuration/post-scripts.md +++ b/docs/reference/configuration/post-scripts.md @@ -141,11 +141,16 @@ release fields instead of the season and episode ones. `{season}`, `{episode}` a | `{album}` | Album name | | `{track_number}` `{disc}` | Zero padded. `{disc}` is empty on a single-disc release | | `{track_total}` `{disc_total}` | Zero padded totals, empty when the service did not give them | -| `{isrc}` `{upc}` `{label}` `{genre}` `{release_type}` `{explicit}` | As the service reported them, empty when absent | +| `{isrc}` `{upc}` `{label}` `{genre}` | As the service reported them, empty when absent | +| `{release_type}` | `album`, `single`, `ep`, and so on. A service that reports nothing gives `album` | +| `{explicit}` | The word `Explicit` when the track is flagged, otherwise empty | In a `file` hook `{title}` is the track name. See [Modes](#modes) for what an album hook puts in `{title}`. +`{year}` is empty when the service gives the release no year. `{ext}` is the container the +track arrived in, such as `.flac`, because a music download is not muxed. + ### Tagging IDs { #tagging-ids } `{tmdb}`, `{imdb}` and `{tvdb}` carry the IDs unshackle is tagging the file with, read at @@ -191,9 +196,10 @@ subs = [p for p in sys.argv[i].removeprefix("--subs=").split("\n") if p] about whether the season is complete on disk. Downloading a single episode fires the hook for that season as soon as the episode lands. - A title that fails takes its group with it. If one episode of S01 fails, the S01 hook - does not fire at all, because the run never finished everything it queued for that - season. + A title that fails stops the whole run. unshackle dispatches the `failure` hook for + that title and then returns. It downloads no further title, and no `season` or `run` + hook fires after that point. This applies to every title type. If one episode of S01 + fails, the S01 hook does not fire at all. The hook is keyed to the folder, not to the season, so one season landing in several folders (`-q 1080,720` with a quality-dependent folder template) fires it once per folder, @@ -218,11 +224,17 @@ In a `file` hook `{title}` and `{title_raw}` are the track name. In an album hoo the album name, and the per-track variables `{track_number}`, `{disc}` and `{isrc}` are empty, in the same way that `{episode}` is empty in a season hook. +An album hook obeys the same rule as a season hook. It fires when the last track **this run +queued** for that album lands. This does not mean that the album is complete on disk. A +one-track download fires the album hook as soon as that track lands. If a track fails, the +run stops there, so the album hook never fires, in the same way as an episode and its +season. + ## Events { #events } `success` runs after the file has moved to its final path. `failure` runs when the download -fails, with `{filepath}` empty and `{error}` set. A music `failure` hook describes the -release rather than a track, because the whole album stops when one track fails. +fails, with `{filepath}` empty and `{error}` set. Each track of an album is its own title. +A music `failure` hook therefore describes the track that failed, not the release. !!! warning "`failure` only fires in `file` mode" A `failure` entry with `mode: season` or `mode: run` never runs. Leave `mode` at its diff --git a/tests/core/test_music_display.py b/tests/core/test_music_display.py deleted file mode 100644 index 0992086d..00000000 --- a/tests/core/test_music_display.py +++ /dev/null @@ -1,213 +0,0 @@ -"""Unit tests for the shared music rendering helpers in unshackle.core.music.display. - -Rendering is visual, so these assert structure/type/no-crash rather than exact -ANSI. No network: the artwork soft-fail path is exercised with a session whose -get raises, and (when Pillow is absent) via the missing-dependency guard. -""" - -from __future__ import annotations - -from typing import Any, Optional - -from rich.console import Console, RenderableType -from rich.panel import Panel - -from unshackle.core.music.display import ( - MusicHeaderInfo, - TrackRow, - format_track_detail_quality, - render_album_header, - render_artwork_preview, - render_track_panel, -) -from unshackle.core.titles.music import Song - - -class DummyService: - """Stand-in service class; Song only requires a type, never an instance.""" - - -def make_song(*, track: int = 1, name: str = "Song", album: str = "Album") -> Song: - return Song( - id_=f"{album}-{track}", - service=DummyService, - name=name, - artist="Artist", - album=album, - track=track, - disc=1, - year=2020, - ) - - -def render_to_text(renderable: RenderableType) -> str: - """Render a rich renderable to plain text so we can assert on its content.""" - console = Console(width=120, record=True, force_terminal=False) - console.print(renderable) - return console.export_text() - - -def test_trackrow_constructs() -> None: - song = make_song(name="Track A") - row = TrackRow( - song=song, - quality_label="FLAC 16-bit/44.1kHz", - layout="Stereo", - duration_str="3:21", - hires=True, - cd=True, - note="", - ) - assert row.song is song - assert row.quality_label == "FLAC 16-bit/44.1kHz" - assert row.layout == "Stereo" - assert row.hires is True - assert row.cd is True - assert row.note == "" - - -def test_trackrow_defaults() -> None: - row = TrackRow(song=make_song(), quality_label="MP3 320", layout="Stereo", duration_str="2:00") - assert row.hires is False - assert row.cd is False - assert row.note == "" - - -def test_musicheaderinfo_constructs() -> None: - info = MusicHeaderInfo( - artist="Some Artist", - album="Some Album", - year="2021", - track_count=12, - quality_label="FLAC 24-bit/96kHz", - duration_str="42:10", - ) - assert info.artist == "Some Artist" - assert info.album == "Some Album" - assert info.year == "2021" - assert info.track_count == 12 - assert info.quality_label == "FLAC 24-bit/96kHz" - assert info.duration_str == "42:10" - assert info.artist_label == "Artist" - - -def test_format_track_detail_quality_brackets_codec() -> None: - assert format_track_detail_quality("FLAC 16-bit/44.1kHz") == "[FLAC] | 16-bit/44.1kHz" - assert format_track_detail_quality("ogg 320kbps") == "[OGG] | 320kbps" - - -def test_format_track_detail_quality_passthrough() -> None: - assert format_track_detail_quality("Lossless") == "Lossless" - assert format_track_detail_quality("") == "" - - -def test_render_track_panel_returns_panel() -> None: - rows = [ - TrackRow( - song=make_song(track=1, name="First"), - quality_label="FLAC 16-bit/44.1kHz", - layout="Stereo", - duration_str="3:00", - cd=True, - ), - TrackRow( - song=make_song(track=2, name="Second"), - quality_label="OGG 320", - layout="Stereo", - duration_str="4:00", - hires=True, - ), - ] - panel = render_track_panel(rows, total=len(rows)) - assert isinstance(panel, Panel) - - text = render_to_text(panel) - assert "First" in text - assert "Second" in text - assert "2 Tracks" in text - assert "CD" in text - assert "Hi-Res" in text - - -def test_render_track_panel_singular_label() -> None: - rows = [ - TrackRow(song=make_song(name="Only"), quality_label="FLAC 16-bit/44.1kHz", layout="Stereo", duration_str="3:00") - ] - text = render_to_text(render_track_panel(rows, total=1)) - # The count node reads "1 Track" (singular); the panel title is always - # "Available Tracks", so we only check the count node phrasing here. - assert "1 Track" in text - assert "1 Tracks" not in text - - -def test_render_track_panel_note_marks_unavailable() -> None: - rows = [ - TrackRow( - song=make_song(name="Gone"), - quality_label="FLAC", - layout="Stereo", - duration_str="3:00", - cd=True, - hires=True, - note="(unavailable in region)", - ) - ] - text = render_to_text(render_track_panel(rows, total=1)) - assert "unavailable in region" in text - # When a note is present, the badges are suppressed. - assert "CD" not in text - assert "Hi-Res" not in text - - -def test_render_album_header_without_artwork() -> None: - info = MusicHeaderInfo( - artist="The Artist", - album="Greatest Hits", - year="1999", - track_count=10, - quality_label="FLAC 16-bit/44.1kHz", - duration_str="55:00", - ) - header = render_album_header(info) - assert header is not None - text = render_to_text(header) - assert "The Artist" in text - assert "Greatest Hits" in text - assert "1999" in text - assert "10" in text - - -def test_render_album_header_with_artwork() -> None: - info = MusicHeaderInfo( - artist="Owner Name", - album="A Playlist", - year="2024", - track_count=3, - quality_label="OGG 320", - artist_label="Owner", - ) - artwork = render_album_header(MusicHeaderInfo("x", "y", "1", 1, "q")) # any renderable - header = render_album_header(info, artwork=artwork) - assert header is not None - text = render_to_text(header) - assert "Owner Name" in text - assert "A Playlist" in text - assert "Owner" in text - - -class RaisingSession: - """Session stub whose get() always raises, to drive the soft-fail path.""" - - def get(self, *args: Any, **kwargs: Any) -> Any: - raise RuntimeError("network disabled in tests") - - -def test_render_artwork_preview_empty_url_returns_none() -> None: - assert render_artwork_preview(RaisingSession(), "") is None - - -def test_render_artwork_preview_soft_fails_without_network() -> None: - # Either Pillow is absent (guard returns None) or the session.get raises - # (caught and returns None). Both paths must yield None, never raise. - result: Optional[RenderableType] = render_artwork_preview(RaisingSession(), "https://example.invalid/cover.jpg") - assert result is None diff --git a/tests/core/test_music_extract.py b/tests/core/test_music_extract.py index 3b7ca080..0c5b3f3e 100644 --- a/tests/core/test_music_extract.py +++ b/tests/core/test_music_extract.py @@ -9,7 +9,6 @@ import pytest from unshackle.core.music.extract import ( build_music_from_songs, classify_release_kind, - dedupe_track_options, duration_seconds, first_number, first_text, @@ -17,7 +16,6 @@ from unshackle.core.music.extract import ( format_names, year_from_value, ) -from unshackle.core.music.models import MusicTrackOption from unshackle.core.titles.music import Music, Song @@ -171,18 +169,6 @@ def test_classify_release_kind(raw_kind: str, count: Optional[float], expected: assert classify_release_kind(raw_kind, count) == expected -def test_dedupe_track_options() -> None: - a = MusicTrackOption(codec="flac", bit_depth=16, sample_rate=44100, bitrate=None, quality_label="L", explicit=False) - a_dup = MusicTrackOption( - codec="FLAC", bit_depth=16, sample_rate=44100, bitrate=None, quality_label="L", explicit=False - ) # codec case-insensitive duplicate of `a` - b = MusicTrackOption(codec="flac", bit_depth=24, sample_rate=96000, bitrate=None, quality_label="H", explicit=False) - c = MusicTrackOption(codec="flac", bit_depth=16, sample_rate=44100, bitrate=None, quality_label="L", explicit=True) - - result = dedupe_track_options([a, a_dup, b, c]) - assert result == [a, b, c] - - def test_build_music_from_songs() -> None: songs = [ make_song( diff --git a/tests/core/test_music_folder_template.py b/tests/core/test_music_folder_template.py index 1f7c7908..fb002dee 100644 --- a/tests/core/test_music_folder_template.py +++ b/tests/core/test_music_folder_template.py @@ -26,13 +26,13 @@ def make_song(**overrides) -> Song: kwargs = dict( id_="track-0001", service=DummyService, - name="NUEVAYoL", - artist="Bad Bunny", - album="DeBI TiRAR MaS FOToS", + name="Example Track", + artist="Example Artist", + album="Example Album", track=1, disc=1, year=2025, - album_artist="Bad Bunny", + album_artist="Example Artist", ) kwargs.update(overrides) return Song(**kwargs) @@ -52,13 +52,13 @@ def reset_folder_config(): def test_folder_fallback_when_no_templates(reset_folder_config): song = make_song() result = song.get_filename(StubMediaInfo(), folder=True) - assert result == sanitize_filename("Bad Bunny - DeBI TiRAR MaS FOToS (2025)", " ") + assert result == sanitize_filename("Example Artist - Example Album (2025)", " ") def test_albums_template_used(reset_folder_config): reset_folder_config.folder_templates = {"albums": "{album_artist} - {album} ({year})"} result = make_song().get_filename(StubMediaInfo(), folder=True) - assert result == sanitize_filename("Bad Bunny - DeBI TiRAR MaS FOToS (2025)", " ") + assert result == sanitize_filename("Example Artist - Example Album (2025)", " ") # Album folder must NOT carry per-track info like the song file name does. assert "01" not in result @@ -91,3 +91,37 @@ def test_validation_warns_on_unknown_folder_kind(): with pytest.warns(UserWarning, match="Unknown folder template kind"): # A non-folder key is required so output-template validation actually runs. Config(output_template={"songs": "{track_number}. {title}", "folder": {"bogus": "{album}"}}) + + +def test_undated_single_degrades_cleanly(reset_folder_config): + """A single with no release year and no disc must not leave empty parens behind.""" + song = Song( + id_="track-0001", + service=DummyService, + name="Example Track", + artist="Example Artist", + album="Example Album", + track=1, + album_artist="Example Artist", + ) # no disc, no year: both are optional + + assert str(song) == "Example Artist - Example Album (?) / 01. Example Track" + assert song.get_filename(StubMediaInfo(), folder=True) == sanitize_filename("Example Artist - Example Album", " ") + assert song.get_filename(StubMediaInfo()) == "01.Example.Track" # spacer applies to the title too + + context = song.build_template_context(StubMediaInfo()) + assert context["year"] == "" + assert context["disc"] == "" # a lone disc is never numbered + assert context["track_number"] == "01" + + +def test_undated_album_template_omits_the_year_group(reset_folder_config): + reset_folder_config.folder_templates = {"albums": "{album_artist} - {album} ({year?})"} + result = make_song(year=None).get_filename(StubMediaInfo(), folder=True) + assert result == "Example Artist - Example Album" + + +def test_songs_separator_survives_a_config_without_a_songs_template(reset_folder_config): + """A user config with output_template but no `songs` key used to raise a KeyError.""" + reset_folder_config.output_template = {"movies": "{title}.{year}"} + assert reset_folder_config.get_template_separator("songs") == "." diff --git a/tests/core/test_music_tagger_roundtrip.py b/tests/core/test_music_tagger_roundtrip.py new file mode 100644 index 00000000..13c59ac1 --- /dev/null +++ b/tests/core/test_music_tagger_roundtrip.py @@ -0,0 +1,198 @@ +"""Round-trip ``write_music_metadata`` against real audio files. + +The dispatch test pins which tagger runs; this pins what it writes. Silent audio is +generated with ffmpeg, tagged from a fully populated Song, and read back with mutagen. +Cover art goes through the same path with a fake session, so no network is touched. +""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path +from typing import Any + +import pytest +from mutagen.flac import FLAC +from mutagen.mp3 import MP3 +from mutagen.mp4 import MP4 +from mutagen.oggvorbis import OggVorbis + +from unshackle.core.music.tagger import write_music_metadata +from unshackle.core.titles.music import Song + +pytestmark = pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="requires ffmpeg") + +PNG_STUB = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16 # magic only; Pillow dimension probing soft-fails + + +class DummyService: + """Stand-in service class; a Title only requires a type, never an instance.""" + + +class FakeResponse: + def __init__(self, content: bytes) -> None: + self.content = content + self.headers = {"Content-Type": "image/png"} + + def raise_for_status(self) -> None: + pass + + def __enter__(self) -> "FakeResponse": + return self + + def __exit__(self, *exc_info: Any) -> None: + pass + + +class FakeSession: + def __init__(self, content: bytes) -> None: + self.content = content + self.requested: list[str] = [] + + def get(self, url: str, timeout: int = 0) -> FakeResponse: + self.requested.append(url) + return FakeResponse(self.content) + + +@pytest.fixture +def flac_path(tmp_path: Path) -> Path: + path = tmp_path / "04. Example Track.flac" + subprocess.run( + ["ffmpeg", "-nostdin", "-v", "error", "-f", "lavfi", "-i", "anullsrc=r=44100:cl=mono", "-t", "0.2", str(path)], + check=True, + ) + return path + + +def make_song() -> Song: + return Song( + id_="track-0001", + service=DummyService, + name="Example Track", + artist="Example Artist", + album="Example Album, Live & Remastered", + track=4, + year=1983, + album_artist="Example Artist", + total_tracks=8, + genre="Synth-pop", + isrc="ZZABC2400001", + artwork_url="https://cdn.example/cover.png", + ) + + +def test_flac_tags_read_back(flac_path: Path) -> None: + result = write_music_metadata(flac_path, make_song()) + + assert result.written + assert not result.skipped + assert not result.artwork_embedded, "no session was given, so no cover may be embedded" + + audio = FLAC(flac_path) + assert audio["TITLE"] == ["Example Track"] + assert audio["ARTIST"] == ["Example Artist"] + assert audio["ALBUM"] == ["Example Album, Live & Remastered"] + assert audio["ALBUMARTIST"] == ["Example Artist"] + assert audio["TRACKNUMBER"] == ["4/8"] + assert audio["TRACKTOTAL"] == ["8"] + assert audio["DATE"] == ["1983"] + assert audio["GENRE"] == ["Synth-pop"] + assert audio["ISRC"] == ["ZZABC2400001"] + + +def test_lyrics_land_in_the_container_field_players_read(tmp_path: Path) -> None: + """Each container has one lyrics field. A generic custom tag is not read as lyrics. + + Both the MP3 and MP4 writers fall back to writing an unmapped key as a custom tag + (TXXX / freeform atom), so a lyrics field that is not mapped explicitly is written + but never displayed. + """ + lyrics = "Line one\nLine two" + song = Song( + id_="track-0001", + service=DummyService, + name="Example Track", + artist="Example Artist", + album="Example Album", + track=1, + lyrics=lyrics, + ) + + for suffix, encoder in ((".flac", "flac"), (".ogg", "libvorbis"), (".mp3", "libmp3lame"), (".m4a", "aac")): + path = tmp_path / f"track{suffix}" + subprocess.run( + [ + "ffmpeg", + "-nostdin", + "-v", + "error", + "-f", + "lavfi", + "-i", + "anullsrc=r=44100:cl=mono", + "-t", + "0.2", + "-c:a", + encoder, + str(path), + ], + check=True, + ) + assert write_music_metadata(path, song).written + + if suffix == ".flac": + assert FLAC(path)["LYRICS"] == [lyrics] + elif suffix == ".ogg": + assert OggVorbis(path)["LYRICS"] == [lyrics] + elif suffix == ".mp3": + tags = MP3(path).tags + assert tags.getall("USLT")[0].text == lyrics + assert not tags.getall("TXXX:LYRICS") + else: + audio = MP4(path) + assert audio["\xa9lyr"] == [lyrics] + assert not [key for key in audio if "LYRICS" in key] + + +def test_a_song_without_lyrics_writes_no_lyrics_field(flac_path: Path) -> None: + write_music_metadata(flac_path, make_song()) + + assert "LYRICS" not in FLAC(flac_path) + + +def test_cover_art_is_fetched_with_the_session_and_embedded(flac_path: Path) -> None: + session = FakeSession(PNG_STUB) + + result = write_music_metadata(flac_path, make_song(), session=session) + + assert result.artwork_embedded + assert session.requested == ["https://cdn.example/cover.png"] + audio = FLAC(flac_path) + assert len(audio.pictures) == 1 + assert audio.pictures[0].mime == "image/png" + assert audio.pictures[0].data == PNG_STUB + + +def test_a_nested_metadata_dict_reaches_the_tags(flac_path: Path) -> None: + """The renderer reads a nested "metadata" sub-dict, so the tagger must read it too. + + A service that nests its metadata used to lose every tag sourced from song.data, + silently and with no log line. + """ + song = Song( + id_="track-0001", + service=DummyService, + name="Example Track", + artist="Example Artist", + album="Example Album", + track=1, + data={"metadata": {"composer": "Example Composer", "release_date": "2020-01-02"}}, + ) + + write_music_metadata(flac_path, song) + + audio = FLAC(flac_path) + assert audio["COMPOSER"] == ["Example Composer"] + assert audio["RELEASEDATE"] == ["2020-01-02"] + assert audio["DATE"] == ["2020-01-02"], "a full release date must beat the bare year" diff --git a/tests/core/test_music_wanted.py b/tests/core/test_music_wanted.py new file mode 100644 index 00000000..7eb8b2ea --- /dev/null +++ b/tests/core/test_music_wanted.py @@ -0,0 +1,126 @@ +"""`-w`/`--wanted` track selection for music. + +A song shares the episode key space, its disc reading as the season and its track as the +episode. These check the whole path a user takes: the tokens they type, the keys the +click type makes of them, and the titles the download loop keeps. +""" + +from __future__ import annotations + +import click +import pytest + +from unshackle.commands.dl import title_wanted +from unshackle.core.titles.movie import Movie +from unshackle.core.titles.music import Song +from unshackle.core.utils.click_types import SeasonRange + + +class DummyService: + pass + + +def make_song(track: int = 1, disc: int = 1) -> Song: + return Song( + id_=f"track-{disc:02}{track:04}", + service=DummyService, + name=f"Example Track {track}", + artist="Example Artist", + album="Example Album", + track=track, + disc=disc, + year=2025, + album_artist="Example Artist", + ) + + +def make_movie() -> Movie: + return Movie(id_="movie-0001", service=DummyService, name="Example Movie", year=2025) + + +def select(tokens: str, songs: list[Song]) -> list[int]: + """Track numbers kept, given what a user types after -w.""" + wanted = SeasonRange().convert(tokens) + return [song.track for song in songs if title_wanted(song, wanted)] + + +def single_disc(count: int = 8) -> list[Song]: + return [make_song(track=n) for n in range(1, count + 1)] + + +def multi_disc(per_disc: int = 4) -> list[Song]: + return [make_song(track=n, disc=d) for d in (1, 2) for n in range(1, per_disc + 1)] + + +def test_a_bare_number_takes_one_track(): + assert select("3", single_disc()) == [3] + + +def test_a_range_takes_the_tracks_inside_it(): + assert select("2-5", single_disc()) == [2, 3, 4, 5] + + +def test_a_comma_list_takes_each_track(): + assert select("1,3,7", single_disc()) == [1, 3, 7] + + +def test_a_leading_minus_removes_a_track_from_a_range(): + assert select("1-5,-3", single_disc()) == [1, 2, 4, 5] + + +def test_the_disc_form_takes_one_track_of_that_disc(): + songs = multi_disc() + kept = [(s.disc, s.track) for s in songs if title_wanted(s, SeasonRange().convert("2x3"))] + assert kept == [(2, 3)] + + +def test_the_disc_form_ranges_inside_one_disc(): + songs = multi_disc() + kept = [(s.disc, s.track) for s in songs if title_wanted(s, SeasonRange().convert("2x1-2x3"))] + assert kept == [(2, 1), (2, 2), (2, 3)] + + +def test_a_bare_number_on_a_multi_disc_release_is_disc_one(): + """An omitted disc is disc 1, the number the tracklist shows for a one-disc release.""" + songs = multi_disc() + kept = [(s.disc, s.track) for s in songs if title_wanted(s, SeasonRange().convert("1-2"))] + assert kept == [(1, 1), (1, 2)] + + +def test_discs_mix_in_one_selection(): + songs = multi_disc() + kept = [(s.disc, s.track) for s in songs if title_wanted(s, SeasonRange().convert("1,2x4"))] + assert kept == [(1, 1), (2, 4)] + + +def test_a_selection_that_matches_nothing_keeps_no_track(): + assert select("40-45", single_disc()) == [] + + +def test_the_whole_release_downloads_without_a_selection(): + songs = single_disc() + assert [s.track for s in songs if title_wanted(s, [])] == [1, 2, 3, 4, 5, 6, 7, 8] + + +def test_a_movie_stays_selected_whatever_the_tokens_are(): + """A movie has no selection key, so -w must not remove it.""" + assert title_wanted(make_movie(), SeasonRange().convert("1-5")) is True + + +def test_a_range_across_discs_takes_the_rest_of_the_first_disc(): + """Same rule as a season range: the left disc runs to its end before the right one starts.""" + songs = multi_disc() + kept = [(s.disc, s.track) for s in songs if title_wanted(s, SeasonRange().convert("1x3-2x2"))] + assert kept == [(1, 3), (1, 4), (2, 1), (2, 2)] + + +@pytest.mark.parametrize("token", ["1x", "x1", "1x2x3", "1.2"]) +def test_bad_music_tokens_fail(token): + with pytest.raises(click.UsageError): + SeasonRange().parse_tokens(token) + + +def test_song_matches_only_its_own_key(): + song = make_song(track=3, disc=2) + assert song.matches_wanted(["2x3"]) is True + assert song.matches_wanted(["1x3", "2x4"]) is False diff --git a/tests/core/test_tag_file_dispatch.py b/tests/core/test_tag_file_dispatch.py new file mode 100644 index 00000000..346ce2bc --- /dev/null +++ b/tests/core/test_tag_file_dispatch.py @@ -0,0 +1,120 @@ +"""Which tagger ``tag_file`` picks per title type. + +A Song is an audio file, so it must reach mutagen and never mkvpropedit. The bug this +pins ran ``mkvpropedit`` against a ``.flac``. Movies and Episodes keep the Matroska path. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest + +from unshackle.core.config import config +from unshackle.core.music.tagger import MusicMetadataResult +from unshackle.core.titles.episode import Episode +from unshackle.core.titles.movie import Movie +from unshackle.core.titles.music import Song +from unshackle.core.utils import tags as tags_module + +pytestmark = pytest.mark.unit + +SESSION = object() # sentinel: tag_file must hand this straight to the music tagger + + +class DummyService: + """Stand-in service class; a Title only requires a type, never an instance.""" + + +class Calls: + """Records which tagger ran, and with what.""" + + def __init__(self) -> None: + self.music: list[tuple[Path, Any, Any]] = [] + self.mkv: list[tuple[Path, dict[str, str]]] = [] + + +@pytest.fixture +def calls(monkeypatch: pytest.MonkeyPatch) -> Calls: + recorded = Calls() + + def fake_write_music_metadata(path: Path, song: Any, *, session: Any = None, source_md5: str = "") -> Any: + recorded.music.append((path, song, session)) + return MusicMetadataResult(written=True) + + def fake_apply_tags(path: Path, tags: dict[str, str]) -> None: + recorded.mkv.append((path, tags)) + + monkeypatch.setattr(tags_module, "write_music_metadata", fake_write_music_metadata) + monkeypatch.setattr(tags_module, "apply_tags", fake_apply_tags) + monkeypatch.setattr(config, "tag_imdb_tmdb", False) # keep provider lookups out of a unit test + return recorded + + +def make_song(**overrides: Any) -> Song: + kwargs: dict[str, Any] = dict( + id_="track-0001", + service=DummyService, + name="Example Track", + artist="Example Artist", + album="Example Album, Live & Remastered", + track=4, + year=1983, + ) + kwargs.update(overrides) + return Song(**kwargs) + + +def test_a_song_is_tagged_by_the_music_tagger(calls: Calls) -> None: + path = Path("04. Example Track.flac") + song = make_song() + + tags_module.tag_file(path, song, session=SESSION) + + assert calls.music == [(path, song, SESSION)], "the music tagger needs the session for cover art" + assert calls.mkv == [], "mkvpropedit must never touch an audio container" + + +def test_a_skipped_song_warns_with_the_reason( + calls: Calls, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + monkeypatch.setattr( + tags_module, + "write_music_metadata", + lambda path, song, **kwargs: MusicMetadataResult(skipped=True, reason="install mutagen to write FLAC tags"), + ) + with caplog.at_level("WARNING", logger="TAGS"): + tags_module.tag_file(Path("04. Example Track.flac"), make_song(), session=SESSION) + assert "install mutagen to write FLAC tags" in caplog.text + + +def test_a_tagger_fault_warns_and_never_raises( + calls: Calls, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """The file already moved when tag_file runs, so a corrupt file must not abort the run.""" + + def broken_tagger(path: Path, song: Any, **kwargs: Any) -> Any: + raise OSError("not a FLAC stream") + + monkeypatch.setattr(tags_module, "write_music_metadata", broken_tagger) + with caplog.at_level("WARNING", logger="TAGS"): + tags_module.tag_file(Path("04. Example Track.flac"), make_song(), session=SESSION) + assert "not a FLAC stream" in caplog.text + + +@pytest.mark.parametrize( + "title", + [ + Movie(id_="movie-0001", service=DummyService, name="Some Movie", year=2024), + Episode(id_="episode-0001", service=DummyService, title="Some Show", season=1, number=2, name="Pilot"), + ], + ids=["movie", "episode"], +) +def test_a_movie_or_episode_still_takes_the_mkv_path(calls: Calls, title: Any) -> None: + path = Path("Some Title.mkv") + + tags_module.tag_file(path, title) + + assert calls.music == [], "write_music_metadata must not see a Matroska file" + assert [call[0] for call in calls.mkv] == [path] diff --git a/tests/core/test_template_formatter.py b/tests/core/test_template_formatter.py index 34716208..fa639be9 100644 --- a/tests/core/test_template_formatter.py +++ b/tests/core/test_template_formatter.py @@ -13,8 +13,7 @@ import pytest from unshackle.core.config import config from unshackle.core.titles.episode import Episode from unshackle.core.titles.movie import Movie -from unshackle.core.titles.music import Song as MusicSong -from unshackle.core.titles.song import Song +from unshackle.core.titles.music import Song from unshackle.core.utilities import sanitize_filename from unshackle.core.utils.template_formatter import TemplateFormatter, detect_spacer @@ -183,7 +182,7 @@ def make_album_song(**overrides): album_artist="Some Artist", ) kwargs.update(overrides) - return MusicSong(**kwargs) + return Song(**kwargs) def assert_safe_path(result: str): diff --git a/tests/test_post_scripts_music.py b/tests/test_post_scripts_music.py index 62134529..5f2d3849 100644 --- a/tests/test_post_scripts_music.py +++ b/tests/test_post_scripts_music.py @@ -1,9 +1,10 @@ """Music post-script hooks, which no live service was available to test against. -Music returns from ``dl.result`` before the title loop, so its hooks are dispatched inside -the music pipeline. These check the pieces that pipeline feeds them: that a Song builds a -usable context, that an Album groups the way a season does, and that the album and run -contexts blank the file-level variables. +A Song runs through the same title loop as an Episode, so its hooks come from the same +two functions: ``build_context`` for a file and ``season_context`` for the release folder. +These check what those two feed a script: that a Song builds a usable context, that an +album groups the way a season does, and that the album hook describes the album rather +than whichever track landed last. """ from __future__ import annotations @@ -13,7 +14,7 @@ from types import SimpleNamespace import pytest -from unshackle.core.utils.post_scripts import build_context, dispatch, substitute, tokenize +from unshackle.core.utils.post_scripts import build_context, dispatch, season_context, substitute, tokenize class FakeSong(SimpleNamespace): @@ -33,26 +34,43 @@ class FakeSong(SimpleNamespace): def song(**kwargs): defaults = dict( - name="Blue Monday", artist="New Order", album="Power, Corruption & Lies", track=4, year=1983, id="s1" + name="Example Track", + artist="Example Artist", + album="Example Album, Live & Remastered", + track=4, + year=1983, + id="s1", ) return FakeSong(**{**defaults, "id": "s1", **kwargs}) +class FakeEpisode(SimpleNamespace): + """An Episode as the hook sees it: a show title, a season, and a number.""" + + def build_template_context(self, media_info, show_service=True): + return {"title": self.title, "season": f"{self.season:02}", "audio": "EAC3", "video": "H.265"} + + +def episode(**kwargs): + defaults = dict(title="Some Show", season=1, number=5, name="Pilot", year=2024, id="e1") + return FakeEpisode(**{**defaults, **kwargs}) + + def test_song_context_has_music_fields_and_blank_episode_fields(): context = build_context( song(), media_info=object(), - filepath=Path("/dl/New Order/04. Blue Monday.flac"), + filepath=Path("/dl/Example Artist/04. Example Track.flac"), service="EXAMPLE", ) - assert context["title"] == "Blue Monday" - assert context["artist"] == "New Order" - assert context["album"] == "Power, Corruption & Lies" + assert context["title"] == "Example Track" + assert context["artist"] == "Example Artist" + assert context["album"] == "Example Album, Live & Remastered" assert context["track_number"] == "04" assert context["service"] == "EXAMPLE" - assert context["filename"] == "04. Blue Monday.flac" + assert context["filename"] == "04. Example Track.flac" assert context["ext"] == ".flac" - assert context["folder"] == str(Path("/dl/New Order")) + assert context["folder"] == str(Path("/dl/Example Artist")) assert context["season"] == "" assert context["episode"] == "" @@ -60,24 +78,33 @@ def test_song_context_has_music_fields_and_blank_episode_fields(): def test_song_context_survives_without_media_info(): """The failure path and any pre-mux dispatch pass no MediaInfo.""" context = build_context(song(), None, service="EXAMPLE", error="HTTPError: 403") - assert context["title"] == "Blue Monday" + assert context["title"] == "Example Track" assert context["error"] == "HTTPError: 403" assert context["filepath"] == "" def test_album_grouping_matches_the_season_grouping_rule(): """post_script_group's music branch: albums group by (album artist, album).""" - from unshackle.commands.dl import dl # noqa: F401 - import guard, the grouping is inlined in result() + from unshackle.commands.dl import post_script_group + from unshackle.core.titles.music import Song - def group(candidate): - artist = getattr(candidate, "album_artist", None) or getattr(candidate, "artist", "") - return ("album", artist, getattr(candidate, "album", "")) + def real_song(**kwargs): + defaults = dict( + id_="track-0001", + service=type("DummyService", (), {}), + name="Example Track", + artist="Example Artist", + album="Example Album, Live & Remastered", + track=4, + ) + return Song(**{**defaults, **kwargs}) - a = song(track=1) - b = song(track=2) - c = song(album="Technique") - assert group(a) == group(b) - assert group(a) != group(c) + a = real_song(track=1, id_="track-0002") + b = real_song(track=2, id_="track-0003") + c = real_song(album="Technique", id_="track-0004") + assert post_script_group(a) == post_script_group(b) + assert post_script_group(a) != post_script_group(c) + assert post_script_group(a)[0] == "album" @pytest.mark.parametrize("mode", ["season", "run"]) @@ -86,25 +113,24 @@ def test_album_and_run_contexts_blank_the_file_variables(mode): base = build_context( song(), media_info=object(), - filepath=Path("/dl/New Order/04. Blue Monday.flac"), + filepath=Path("/dl/Example Artist/04. Example Track.flac"), service="EXAMPLE", ) - context = dict(base) + folder = Path("/dl/Example Artist") if mode == "season": - for key in ("filepath", "filename", "ext", "sidecars", "episode", "episode_name"): - context[key] = "" + context = season_context(base, folder) else: context = dict.fromkeys(base, "") - context["folder"] = "/dl/New Order" + context["folder"] = str(folder) assert context["filepath"] == "" assert context["sidecars"] == "" - assert context["folder"] == "/dl/New Order" + assert context["folder"] == str(folder) if mode == "run": assert context["title"] == "" assert set(context) == set(base) else: - assert context["album"] == "Power, Corruption & Lies" + assert context["album"] == "Example Album, Live & Remastered" def test_a_music_hook_command_builds_the_argv_a_script_would_get(monkeypatch, tmp_path): @@ -126,7 +152,7 @@ def test_a_music_hook_command_builds_the_argv_a_script_would_get(monkeypatch, tm context = build_context( song(), media_info=object(), - filepath=Path("/dl/New Order/04. Blue Monday.flac"), + filepath=Path("/dl/Example Artist/04. Example Track.flac"), service="EXAMPLE", ) dispatch("success", "file", context) @@ -139,93 +165,100 @@ def test_a_music_hook_command_builds_the_argv_a_script_would_get(monkeypatch, tm time.sleep(0.05) assert marker.exists(), "the hook process never ran" argv = marker.read_text().splitlines() - assert "--album=Power, Corruption & Lies" in argv - assert "--artist=New Order" in argv + assert "--album=Example Album, Live & Remastered" in argv + assert "--artist=Example Artist" in argv assert "--track=04" in argv - assert f"--file={Path('/dl/New Order/04. Blue Monday.flac')}" in argv + assert f"--file={Path('/dl/Example Artist/04. Example Track.flac')}" in argv -def test_album_hook_mapping_is_bound_before_any_early_branch(): - """--skip-dl skips the final-move block; the album hook still runs after it. +def test_season_context_blanks_the_file_variables_and_sets_the_folder(): + """An album hook describes the release folder, so no track's file may leak into it.""" + folder = Path("/dl/Example Artist - Example Album, Live & Remastered (1983)") + context = build_context( + song(), + media_info=object(), + filepath=folder / "04. Example Track.flac", + sidecars=[folder / "04. Example Track.lrc"], + service="EXAMPLE", + ) - Guards a NameError: the mapping used to be created inside that block, so a music - download with --skip-dl reached the album hook with an unbound name. - """ - import inspect + album = season_context(context, folder) - from unshackle.commands.dl import dl - - source = inspect.getsource(dl.result) - body = source[source.index("def download_music_title") :] - bind = body.index("album_post_script: dict") - for use in ("album_post_script[final_path.parent]", "album_post_script.items()"): - assert bind < body.index(use), f"{use} can run before the mapping is bound" - line = body[:bind].rsplit("\n", 1)[-1] - assert len(line) == 16, f"bound at indent {len(line)}, expected the function body level" + for key in ("filepath", "filename", "ext", "sidecars", "episode", "episode_name"): + assert album[key] == "", f"{key} still describes a file" + assert album["folder"] == str(folder) + assert album["service"] == "EXAMPLE" + assert context["filepath"] != "", "the file context handed in must not be mutated" -def test_album_hook_identifies_the_album_not_the_last_track(): - """A Song's ``{title}`` is the song, so blanking only the video fields is not enough. +def test_season_context_identifies_the_album_not_the_last_track(): + """A Song's ``{title}`` is the song, so blanking only the file fields is not enough. Guards a regression where an album hook was handed whichever track landed last: ``{title}`` was a song name and ``{track_number}``/``{isrc}`` were that track's. """ - from unshackle.commands.dl import dl - + folder = Path("/dl/Example Artist - Example Album (1983)") context = build_context( - song(track=11, isrc="GBAAA8300001"), + song(track=11), media_info=object(), - filepath=Path("/dl/New Order - PCL (1983)/11. Blue Monday.flac"), + filepath=folder / "11. Example Track.flac", service="EXAMPLE", ) - context.setdefault("isrc", "GBAAA8300001") + context["isrc"] = "ZZABC2400001" + context["disc"] = "02" - album = dict(context) - for key in ("filepath", "filename", "ext", "sidecars", "episode", "episode_name"): - album[key] = "" - for key in ("track_number", "disc", "isrc"): - if key in album: - album[key] = "" - album["title"] = album["title_raw"] = album.get("album", "") + album = season_context(context, folder) - assert album["title"] == "Power, Corruption & Lies" - assert album["title_raw"] == "Power, Corruption & Lies" + assert album["title"] == "Example Album, Live & Remastered" + assert album["title_raw"] == "Example Album, Live & Remastered" assert album["track_number"] == "" + assert album["disc"] == "" assert album["isrc"] == "" - assert album["artist"] == "New Order" # album-level, kept - - import inspect - - body = inspect.getsource(dl.result) - body = body[body.index("for folder, context in album_post_script.items()") :][:900] - for key in ("track_number", "disc", "isrc"): - assert f'"{key}"' in body, f"the album hook no longer blanks {key}" - assert 'album_context["title"]' in body + assert album["artist"] == "Example Artist" # album-level, kept + assert album["year"] == "1983" # album-level, kept -def test_a_music_download_failure_dispatches_a_failure_hook(): - """Parity with the video worker, which dispatches failure/file when it raises.""" - import inspect +def test_season_context_leaves_a_non_music_context_untouched(): + """The music branch is gated on ``{album}``; a season hook must not lose its own keys.""" + folder = Path("/dl/Some Show/Season 1") + context = build_context( + episode(), + media_info=object(), + filepath=folder / "Some Show S01E05 Pilot.mkv", + service="EXAMPLE", + ) + context["disc"] = "sentinel" # only the music branch clears this - from unshackle.commands.dl import dl + season = season_context(context, folder) - body = inspect.getsource(dl.result) - body = body[body.index("def download_music_title") :] - assert body.count("music_failure(") == 3, "expected one definition and both failure exits" - assert body.index("def music_failure") < body.index("music_failure(e)") - assert "music_failure(error)" in body + assert season["title"] == "Some Show" + assert season["title_raw"] == "Some Show" + assert season["season"] == "1" + assert season["disc"] == "sentinel" + assert season["episode"] == "" + assert season["episode_name"] == "" + assert season["folder"] == str(folder) - release = SimpleNamespace(title="Power, Corruption & Lies", artist="New Order", year=1983) - context = build_context(release, service="EXAMPLE", error="HTTPError: 403") - assert context["title"] == "Power, Corruption & Lies" + +def test_a_failed_track_gets_a_track_scoped_failure_context(): + """Failure is per-track, like every other title type, and does not describe the album. + + The real dispatch passes no MediaInfo and no filepath, so the context is whatever the + Song itself carries, and it is never run through ``season_context``. + """ + context = build_context(song(track=11), None, service="EXAMPLE", error="HTTPError: 403") + + assert context["title"] == "Example Track" + assert context["title_raw"] == "Example Track" assert context["error"] == "HTTPError: 403" assert context["filepath"] == "" + assert context["season"] == "" def test_an_album_name_with_a_comma_stays_one_argument(): - """'Power, Corruption & Lies' must not split, which is the whole tokenize-first point.""" - argv = substitute(tokenize("upload.py --album={album}"), {"album": "Power, Corruption & Lies"}) - assert argv == ["upload.py", "--album=Power, Corruption & Lies"] + """'Example Album, Live & Remastered' must not split, which is the whole tokenize-first point.""" + argv = substitute(tokenize("upload.py --album={album}"), {"album": "Example Album, Live & Remastered"}) + assert argv == ["upload.py", "--album=Example Album, Live & Remastered"] if __name__ == "__main__": diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index d7de4b31..bc1c9a86 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -22,7 +22,7 @@ from http.cookiejar import CookieJar, MozillaCookieJar from itertools import product from pathlib import Path from threading import Lock -from typing import Any, Callable, Optional, Sequence, TypedDict, Union +from typing import Any, Callable, Collection, Optional, Sequence, TypedDict, Union from uuid import UUID import click @@ -30,10 +30,8 @@ import yaml from click.core import ParameterSource from langcodes import Language, tag_is_valid from pymediainfo import MediaInfo -from rich import box from rich.console import Group from rich.padding import Padding -from rich.panel import Panel from rich.progress import Progress, SpinnerColumn, TaskID, TextColumn, TimeRemainingColumn from rich.rule import Rule from rich.spinner import Spinner @@ -45,22 +43,12 @@ from unshackle.core import binaries, providers from unshackle.core.cdm import DecryptLabsRemoteCDM from unshackle.core.cdm.detect import is_playready_cdm, is_widevine_cdm from unshackle.core.config import config, resolve_cdm_name, resolve_decryption -from unshackle.core.console import GradientPulseBarColumn, SyncLive, console +from unshackle.core.console import GradientPulseBarColumn, SyncLive, console, listing_panel from unshackle.core.constants import DOWNLOAD_CANCELLED, DOWNLOAD_LICENCE_ONLY, AnyTrack, context_settings from unshackle.core.credential import Credential from unshackle.core.downloaders import format_speed, parse_speed_limit, set_speed_limit from unshackle.core.drm import DRM_T, ClearKeyCENC, MonaLisa, PlayReady, Widevine from unshackle.core.events import events -from unshackle.core.music import ( - MusicAudioIntegrityError, - MusicMetadataResult, - MusicPlanner, - MusicRenderer, - file_md5, - verify_music_audio, - write_music_manifest, - write_music_metadata, -) from unshackle.core.providers.anilist import parse_anilist_ref from unshackle.core.providers.tvdb import SEASON_TYPES, parse_int from unshackle.core.proxies import Basic, ExpressVPN, Gluetun, Hola, NordVPN, ProtonVPN, SurfsharkVPN, WindscribeVPN @@ -68,7 +56,7 @@ from unshackle.core.service import Service from unshackle.core.services import Services from unshackle.core.temp import with_task_temp from unshackle.core.title_cacher import get_account_hash -from unshackle.core.titles import Movie, Movies, Music, Series, Song, Title_T +from unshackle.core.titles import Movie, Movies, Series, Song, Title_T from unshackle.core.titles.episode import Episode from unshackle.core.tracks import Audio, Subtitle, Tracks, Video from unshackle.core.tracks.attachment import Attachment @@ -107,7 +95,7 @@ from unshackle.core.utils.click_types import ( SubtitleCodecChoice, ) from unshackle.core.utils.collections import ci_get, merge_dict -from unshackle.core.utils.post_scripts import build_context, dispatch +from unshackle.core.utils.post_scripts import build_context, dispatch, season_context from unshackle.core.utils.selector import select_multiple from unshackle.core.utils.subprocess import ffprobe from unshackle.core.vaults import Vaults @@ -201,6 +189,27 @@ def group_videos_by_variant(videos: list[Video], *, merge: bool) -> list[list[Vi return list(groups.values()) +def title_wanted(candidate: Any, wanted: Collection[str]) -> bool: + """Whether ``-w`` selects this title. A title type without selection keys is always kept. + + The download loop and the post-script counter both read this, so the counter cannot + fall out of step with the titles the loop skips. + """ + if not wanted or not isinstance(candidate, (Episode, Song)): + return True + return bool(candidate.matches_wanted(wanted)) + + +def post_script_group(candidate: Any) -> Any: + """Key a title by the folder its outputs share, for post-scripts in season mode.""" + if isinstance(candidate, Episode): + return ("episode", candidate.title, candidate.season) + if isinstance(candidate, Song): + artist = getattr(candidate, "album_artist", None) or getattr(candidate, "artist", "") + return ("album", artist, getattr(candidate, "album", "")) + return ("title", id(candidate)) + + def apply_service_dl_overrides(ctx: click.Context, service_dl_config: dict[str, Any], log: logging.Logger) -> None: """Apply ``services..dl`` config onto ``ctx.params``. Explicit CLI/env values win; defaults and global ``dl:`` default_map values are replaced.""" @@ -582,7 +591,7 @@ class dl: "--wanted", type=SEASON_RANGE, default=None, - help="Wanted episodes, e.g. `S01-S05,S07`, `S01E01-S02E03`, `S02-S02E03`, etc., defaults to all.", + help="Wanted episodes, e.g. `S01-S05,S07`, `S01E01-S02E03`, `S02-S02E03`, etc. Music uses track numbers, e.g. `1-5`, `1,3,7`, or `2x3` for disc 2 track 3. Defaults to all.", ) @click.option( "-l", @@ -1109,7 +1118,7 @@ class dl: if service_config_path.exists(): self.service_config = yaml.safe_load(service_config_path.read_text(encoding="utf8")) self.log.info("Service Config loaded") - # log key names only -- the full config carries service certificates, + # log key names only: the full config carries service certificates, # device fingerprints and endpoints that bloat the log and may be sensitive log_event( "load_service_config", @@ -1729,44 +1738,12 @@ class dl: if self.tvdb_order and isinstance(titles, Series): titles = self.apply_tvdb_order(titles, title_cacher, cache_title_id, cache_region, cache_account_hash) - music_mode = isinstance(titles, Music) - 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 = {} - music_post_script: dict[Path, dict[str, str]] = {} - - if music_titles: - music_renderer = MusicRenderer() - if music_collection_mode: - collection_label_getter = getattr(service, "get_music_collection_label", None) - collection_label = ( - collection_label_getter(music_titles) - if callable(collection_label_getter) - else f"Music Collection ({len(music_titles)} Releases)" - ) - if collection_label: - console.print(Padding(Rule(f"[rule.text]{collection_label}"), (1, 2))) - if list_: - for music_title in music_titles: - music_kind = MusicRenderer.display_kind(getattr(music_title, "kind", "") or "album") - console.print(Padding(Rule(f"[rule.text]{music_kind}: {music_title}"), (1, 2))) - current_plan = MusicPlanner(service).build(music_title) - music_plans[id(music_title)] = current_plan - music_renderable = music_renderer.render_plan(current_plan, verbose=True) - if music_renderable: - console.print(Padding(music_renderable, (0, 5))) - else: - console.print(Padding(Rule(f"[rule.text]{titles.__class__.__name__}: {titles}"), (1, 2))) - console.print(Padding(titles.tree(verbose=list_titles), (0, 5))) + console.print(Padding(Rule(f"[rule.text]{titles.__class__.__name__}: {titles}"), (1, 2))) + console.print(Padding(titles.tree(verbose=list_titles), (0, 5))) if list_titles: return - if music_titles and list_: - return - if select_titles and isinstance(titles, Movies) and len(titles) > 1: console.print(Padding(Rule("[rule.text]Select Titles"), (1, 2))) @@ -1917,544 +1894,13 @@ class dl: seen_ids: set[str] = set() return [t for t in selected if not (t.id in seen_ids or seen_ids.add(t.id))] - music_group_download = ( - bool(music_titles) - and getattr(service, "GROUP_AUDIO_DOWNLOADS", False) - and not no_mux - and not video_only - and not subs_only - and not chapters_only - and not no_audio - ) - if music_group_download: - - def download_music_title(titles: Music, plan: Any) -> bool: - album_post_script: dict[Path, dict[str, str]] = {} - - def music_failure(error: BaseException) -> None: - """The music twin of the video worker's failure hook, keyed to the release.""" - dispatch( - "failure", - "file", - build_context( - titles, - service=self.service, - ids=self.post_script_ids(), - error=f"{type(error).__name__}: {error}", - ), - postscript, - ) - - 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_renderer = MusicRenderer() - music_start_time = time.time() - - music_kind = MusicRenderer.display_kind(getattr(titles, "kind", "") or "album") - console.print(Padding(Rule(f"[rule.text]{music_kind}: {titles}"), (1, 2))) - music_header = music_renderer.render_plan_header(plan) - if music_header: - console.print(Padding(music_header, (0, 5))) - - def music_track_count(count: int) -> str: - return f"{count} track{'s' if count != 1 else ''}" - - def format_elapsed_seconds(elapsed: float) -> str: - elapsed_int = int(elapsed) - minutes, seconds = divmod(elapsed_int, 60) - hours, minutes = divmod(minutes, 60) - value = f"{minutes:d}m{seconds:d}s" - return f"{hours:d}h{value}" if hours else value - - def select_music_audio(song: Song) -> None: - if not audio_description: - song.tracks.select_audio(lambda x: not x.descriptive) - if acodec: - song.tracks.select_audio(lambda x: x.codec in acodec) - if not song.tracks.audio: - codec_names = ", ".join(c.name for c in acodec) - self.log.error(f"No audio tracks matching codecs for {song.name}: {codec_names}") - sys.exit(1) - if channels: - song.tracks.select_audio( - lambda x: bool(x.channels and math.ceil(x.channels) == math.ceil(channels)) - ) - if not song.tracks.audio: - self.log.error(f"There's no {channels} Audio Track for {song.name}...") - sys.exit(1) - if no_atmos: - song.tracks.audio = [x for x in song.tracks.audio if not x.atmos] - if not song.tracks.audio: - self.log.error(f"No non-Atmos audio tracks available for {song.name}...") - sys.exit(1) - if abitrate: - song.tracks.select_audio(lambda x: x.bitrate and x.bitrate // 1000 == abitrate) - if not song.tracks.audio: - self.log.error(f"There's no {abitrate}kbps Audio Track for {song.name}...") - sys.exit(1) - if abitrate_min is not None and abitrate_max is not None: - song.tracks.select_audio( - lambda x: x.bitrate and abitrate_min <= x.bitrate // 1000 <= abitrate_max - ) - if not song.tracks.audio: - self.log.error( - f"No Audio Track in {abitrate_min}-{abitrate_max}kbps range for {song.name}..." - ) - sys.exit(1) - - song_excl = [ - str(song.language) if t == "orig" else t for t in audio_excl if t != "orig" or song.language - ] - if song_excl: - drop = excluded_language_tags(song_excl, [t.language for t in song.tracks.audio], exact_lang) - song.tracks.select_audio(lambda x: str(x.language) not in drop) - if not song.tracks.audio: - self.log.error( - f"Every Audio Track for {song.name} was excluded by -{', -'.join(song_excl)}..." - ) - sys.exit(1) - - audio_languages = a_lang or lang - if audio_languages: - processed_lang = [] - s_orig_token: Optional[str] = None - for language in audio_languages: - if language == "orig": - if song.language: - orig_lang = str(song.language) - s_orig_token = orig_lang - if orig_lang not in processed_lang: - processed_lang.append(orig_lang) - else: - self.log.warning("Original language not available for music track, skipping 'orig'") - elif language not in processed_lang: - processed_lang.append(language) - if s_orig_token in audio_languages: - s_orig_token = None - - song.tracks.audio = select_best_audio( - song.tracks.audio, processed_lang, acodec, audio_description, exact_lang - ) - if not song.tracks.audio: - # empty only when 'orig' was the sole request and did not resolve - self.log.error( - f"There's no {as_requested(processed_lang, s_orig_token) or 'orig'} " - f"Audio Track for {song.name}..." - ) - sys.exit(1) - - self.log.debug("Getting Tracks") - tracks_label = "Getting Remote Tracks..." if self.is_remote else "Getting Tracks..." - with console.status(tracks_label, spinner="dots"): - for song in titles: - events.reset() - events.subscribe(events.Types.SEGMENT_DOWNLOADED, service.on_segment_downloaded) - events.subscribe(events.Types.TRACK_DOWNLOADED, service.on_track_downloaded) - events.subscribe(events.Types.TRACK_DECRYPTED, service.on_track_decrypted) - events.subscribe(events.Types.TRACK_REPACKED, service.on_track_repacked) - events.subscribe(events.Types.TRACK_MULTIPLEX, service.on_track_multiplex) - - song.tracks.add(service.get_tracks(song), warn_only=True) - song.tracks.chapters = service.get_chapters(song) - song.tracks.sort_audio( - by_language=resolve_sort_langs( - [*(config.audio.get("language_priority") or []), *(a_lang or lang)], - song.language, - ), - codec_priority=config.audio.get("codec_priority"), - exact_match=exact_lang, - ) - select_music_audio(song) - if not song.tracks.audio: - self.log.error(f"No audio tracks returned for {song.name}.") - sys.exit(1) - if len(song.tracks.audio) > 1: - # group audio mode is one-track-per-song by design - self.log.warning( - f"Group audio downloads take one track per song, keeping best for {song.name}" - ) - song.tracks.audio = song.tracks.audio[:1] - - music_tree = Tree( - f"[repr.number]{len(titles)}[/] {'Track' if len(titles) == 1 else 'Tracks'}", - guide_style="bright_black", - ) - for song in titles: - track = song.tracks.audio[0] - progress = Progress( - SpinnerColumn(finished_text=""), - GradientPulseBarColumn(), - " | ", - TimeRemainingColumn(compact=True, elapsed_when_finished=True), - " | ", - TextColumn("[progress.data.speed]{task.fields[downloaded]}"), - console=console, - speed_estimate_period=10, - ) - task = progress.add_task("", downloaded="-") - state = {"total": 100.0} - - def update_track_progress( - task_id: TaskID = task, - _state: dict[str, float] = state, - _progress: Progress = progress, - **kwargs: Any, - ) -> None: - if "total" in kwargs: - if kwargs["total"] is None: - # Progress.update() ignores total=None; an un-started task pulses - del kwargs["total"] - _progress.reset(task_id, start=False) - else: - _state["total"] = kwargs["total"] - _progress.start_task(task_id) - - downloaded_state = kwargs.get("downloaded") - if downloaded_state in {"Downloaded", "Decrypted", "[yellow]SKIPPED"}: - kwargs["completed"] = _state["total"] - kwargs["total"] = _state["total"] - _progress.start_task(task_id) - _progress.update(task_id=task_id, **kwargs) - - track_table = Table.grid() - track_table.add_row(music_renderer.song_line(song, titles)) - song_plan = music_song_plans.get(id(song)) - if song_plan and song_plan.selected: - track_table.add_row(music_renderer.option_line(song_plan.selected), style="text2") - else: - track_table.add_row(str(track)[6:], style="text2") - track_table.add_row(progress) - music_tree.add(track_table, guide_style="bright_black") - music_items.append((song, track, update_track_progress)) - - download_table = Table.grid() - download_table.add_row(music_tree) - - try: - with SyncLive(Padding(download_table, (1, 5)), console=console, refresh_per_second=20): - with ThreadPoolExecutor(downloads) as pool: - download_futures = [ - pool.submit( - track.download, - session=track.session or service.session, - no_proxy_download=no_proxy_download, - prepare_drm=partial( - partial(self.prepare_drm, table=download_table), - track=track, - title=song, - certificate=partial( - service.get_widevine_service_certificate, - title=song, - track=track, - ), - licence=partial( - service.get_playready_license - if is_playready_cdm(self.cdm) - else service.get_widevine_license, - title=song, - track=track, - ), - cdm_only=cdm_only, - vaults_only=vaults_only, - export=export_path, - service_session=service.session, - ), - cdm=self.cdm, - max_workers=workers, - adaptive_workers=adaptive_workers, - download_processes=download_processes, - progress=progress_call, - ) - for song, track, progress_call in music_items - ] - for download in futures.as_completed(download_futures): - download.result() - except KeyboardInterrupt: - console.print(Padding(":x: Download Cancelled...", (0, 5, 1, 5))) - return - except Exception as e: # noqa - console.print( - Padding( - Group( - ":x: Download Failed...", - f" {type(e).__name__}: {e}", - " An unexpected error occurred in one of the download workers.", - ), - (1, 5), - ) - ) - console.print_exception() - music_failure(e) - return - - if skip_dl: - console.log("Skipped downloads as --skip-dl was used...") - else: - dl_time = time_elapsed_since(music_start_time) - console.print(Padding(f"Track downloads finished in [progress.elapsed]{dl_time}[/]", (0, 5))) - - integrity_results = {} - media_infos = {} - integrity_start = time.time() - try: - with console.status("Verifying audio integrity...", spinner="dots"): - for song, track, _ in music_items: - if not track.path or not track.path.exists(): - continue - if track.needs_repack: - track.repackage() - events.emit(events.Types.TRACK_REPACKED, track=track) - # residual encrypted sample entry after repack => never decrypted - if has_encrypted_sample_entry(track.path): - self.log.warning( - f"Track {track.id} still has an encrypted sample entry after repacking, so " - "decryption likely failed and the muxed output may be unplayable." - ) - - media_info = MediaInfo.parse(track.path) - media_infos[id(track)] = media_info - integrity_results[id(track)] = verify_music_audio( - track.path, - song=song, - track=track, - media_info=media_info, - ) - except MusicAudioIntegrityError as error: - console.print(Padding(f"Audio integrity failed: {error}", (0, 5, 1, 5))) - music_failure(error) - return - integrity_time = format_elapsed_seconds(time.time() - integrity_start) - - source_md5 = {} - md5_elapsed = 0.0 - md5_start = time.time() - with console.status("Recording MD5 checksums...", spinner="dots"): - for _, track, _ in music_items: - if track.path and track.path.exists(): - source_md5[id(track)] = file_md5(track.path) - md5_elapsed += time.time() - md5_start - - metadata_results = {} - final_paths = {} - final_md5 = {} - manifest_records = [] - metadata_start = time.time() - metadata_warning = "" - used_final_paths: set[Path] = set() - with console.status("Writing music metadata...", spinner="dots"): - for song, track, _ in music_items: - if not track.path or not track.path.exists(): - continue - - media_info = media_infos.get(id(track)) or MediaInfo.parse(track.path) - final_dir = self.output_dir or config.directories.downloads - final_filename = song.get_filename(media_info, show_service=not no_source) - if not no_folder: - final_dir /= song.get_filename(media_info, show_service=not no_source, folder=True) - - final_dir.mkdir(parents=True, exist_ok=True) - final_path = final_dir / f"{final_filename}{track.path.suffix}" - sep = config.get_template_separator("songs") - if final_path in used_final_paths: - index = 2 - while final_path in used_final_paths: - final_path = final_dir / f"{final_filename.rstrip()}{sep}{index}{track.path.suffix}" - index += 1 - - try: - os.replace(track.path, final_path) - except OSError: - if final_path.exists(): - final_path.unlink() - shutil.move(track.path, final_path) - used_final_paths.add(final_path) - final_paths[id(track)] = final_path - self.completed_files.append(final_path) - - try: - metadata_results[id(track)] = write_music_metadata( - final_path, - song, - session=service.session, - source_md5=source_md5.get(id(track), ""), - ) - except Exception as error: - metadata_warning = f"{type(error).__name__}: {error}" - self.log.warning(f"Music metadata failed for {song.name}: {metadata_warning}") - metadata_results[id(track)] = MusicMetadataResult(skipped=True, reason=metadata_warning) - metadata_time = format_elapsed_seconds(time.time() - metadata_start) - - final_md5_start = time.time() - with console.status("Recording final MD5 checksums...", spinner="dots"): - for _, track, _ in music_items: - final_path = final_paths.get(id(track)) - if final_path and final_path.exists(): - final_md5[id(track)] = file_md5(final_path) - md5_elapsed += time.time() - final_md5_start - md5_time = format_elapsed_seconds(md5_elapsed) - - for song, track, _ in music_items: - final_path = final_paths.get(id(track)) - 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") - ) - manifest_records.append( - { - "track_number": song.track, - "disc_number": song.disc, - "title": song.name, - "artist": song.artist, - "album": song.album, - "file": str(final_path), - "source_md5": source_md5.get(id(track), ""), - "final_md5": final_md5.get(id(track), ""), - "integrity": integrity_result.to_dict() if integrity_result else {}, - "metadata": metadata_result.to_dict(), - } - ) - - song_context = build_context( - song, - media_infos.get(id(track)), - filepath=final_path, - service=self.service, - ids=self.post_script_ids(), - ) - dispatch("success", "file", song_context, postscript) - album_post_script[final_path.parent] = song_context - - if final_paths: - manifest_slug = ".".join( - part - for part in re.sub( - r"[^A-Za-z0-9._-]+", - ".", - ".".join( - str(part or "") - for part in ( - self.service, - getattr(titles, "artist", ""), - getattr(titles, "title", ""), - getattr(titles, "year", ""), - int(time.time()), - ) - ), - ) - .strip(".") - .split(".") - if part - ) - try: - write_music_manifest( - config.directories.logs / "music" / f"{manifest_slug or 'music'}.json", - release={ - "kind": getattr(titles, "kind", ""), - "title": getattr(titles, "title", ""), - "artist": getattr(titles, "artist", ""), - "year": getattr(titles, "year", None), - "service": self.service, - }, - tracks=manifest_records, - ) - except Exception as error: - self.log.warning(f"Music manifest write failed: {error}") - - album_time = time_elapsed_since(music_start_time) - release_label = MusicRenderer.display_kind(getattr(titles, "kind", "") or "music") - - integrity_count = len(integrity_results) - md5_count = len(final_md5) - metadata_written_count = sum(1 for result in metadata_results.values() if result.written) - console.print( - Padding( - f"Audio integrity verified for {music_track_count(integrity_count)} in [progress.elapsed]{integrity_time}[/]", - (0, 5), - ) - ) - console.print( - Padding( - f"MD5 checksum recorded for {music_track_count(md5_count)} in [progress.elapsed]{md5_time}[/]", - (0, 5), - ) - ) - if metadata_written_count: - console.print( - Padding( - f"Metadata written for {music_track_count(metadata_written_count)} in [progress.elapsed]{metadata_time}[/]", - (0, 5), - ) - ) - else: - reason = metadata_warning or next( - (result.reason for result in metadata_results.values() if result.reason), - "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)) - ) - - for folder, context in album_post_script.items(): - album_context = dict(context) - for key in ("filepath", "filename", "ext", "sidecars", "episode", "episode_name"): - album_context[key] = "" - for key in ("track_number", "disc", "isrc"): - if key in album_context: - album_context[key] = "" - album_context["title"] = album_context["title_raw"] = album_context.get("album", "") - album_context["folder"] = str(folder) - dispatch("success", "season", album_context, postscript) - music_post_script.update(album_post_script) - - return True - - for music_title in music_titles: - current_plan = music_plans.get(id(music_title)) or MusicPlanner(service).build(music_title) - if not download_music_title(music_title, current_plan): - return - - if music_post_script: - run_context = dict.fromkeys(next(iter(music_post_script.values())), "") - for folder in music_post_script: - run_context["folder"] = str(folder) - dispatch("success", "run", run_context, postscript) - - if not hasattr(service, "close"): - cookie_file = self.get_cookie_path(self.service, self.profile) - if cookie_file: - self.save_cookies(cookie_file, service.session.cookies) - - if hasattr(service, "close"): - service.close() - - dl_time = time_elapsed_since(start_time) - console.print(Padding(f"Processed all titles in [progress.elapsed]{dl_time}", (0, 5, 1, 5))) - return - - if music_collection_mode: - raise click.ClickException("Music collections require grouped audio downloads.") - base_selection = (v_lang, a_lang, s_lang, range_) - def post_script_group(candidate: Any) -> Any: - """Key a title by the folder its outputs share, for post-scripts in season mode.""" - if isinstance(candidate, Episode): - return ("episode", candidate.title, candidate.season) - if isinstance(candidate, (Song, Music)): - artist = getattr(candidate, "album_artist", None) or getattr(candidate, "artist", "") - return ("album", artist, getattr(candidate, "album", "")) - return ("title", id(candidate)) - def post_script_queued(candidate: Any) -> bool: """Mirror of the filters the loop below applies, so the season counter matches it.""" if isinstance(candidate, Episode) and latest_episode and latest_episode_id: return f"{candidate.season}x{candidate.number}" == latest_episode_id - if isinstance(candidate, Episode) and wanted: - return bool(candidate.matches_wanted(wanted)) - return True + return title_wanted(candidate, wanted) post_script_pending: dict[Any, int] = {} for candidate in titles: @@ -2471,7 +1917,7 @@ class dl: # If --latest-episode is set, only process the latest episode if f"{title.season}x{title.number}" != latest_episode_id: continue - elif isinstance(title, Episode) and wanted and not title.matches_wanted(wanted): + elif not title_wanted(title, wanted): continue if progress_sink: @@ -2486,9 +1932,7 @@ class dl: else: progress_sink({"title": getattr(title, "name", None) or str(title)}) - title_rule = ( - f"Track {title.track:02}: {title.name}" if music_mode and isinstance(title, Song) else str(title) - ) + title_rule = f"Track {title.track:02}: {title.name}" if isinstance(title, Song) else str(title) console.print(Padding(Rule(f"[rule.text]{title_rule}"), (1, 2))) temp_font_files = [] @@ -2695,12 +2139,7 @@ class dl: if list_: available_tracks, _ = title.tracks.tree() - console.print( - Padding( - Panel(available_tracks, title="Available Tracks", box=box.SQUARE, border_style="bright_black"), - (0, 5), - ) - ) + console.print(Padding(listing_panel(available_tracks, "Available Tracks"), (0, 5))) continue keep_videos = True @@ -3934,7 +3373,7 @@ class dl: ) else: - muxed_paths.append(title.tracks.audio[0].path) + muxed_paths.extend(track.path for track in title.tracks.audio if track.path and track.path.exists()) if no_mux: final_dir = self.output_dir or config.directories.downloads @@ -4032,6 +3471,7 @@ class dl: self.tvdb_id, self.anilist_id, self.anime_hint(title), + session=service.session, ) post_script_context = build_context( @@ -4055,14 +3495,10 @@ class dl: post_script_pending[group_key] = post_script_pending.get(group_key, 1) - 1 if post_script_pending[group_key] <= 0: for folder, context in post_script_last.get(group_key, {}).items(): - season_context = dict(context) - for key in ("filepath", "filename", "ext", "sidecars", "episode", "episode_name"): - season_context[key] = "" - season_context["folder"] = str(folder) - dispatch("success", "season", season_context, postscript) + dispatch("success", "season", season_context(context, folder), postscript) title_dl_time = time_elapsed_since(dl_start_time) - downloaded_label = "Track" if music_mode and isinstance(title, Song) else "Title" + downloaded_label = "Track" if isinstance(title, Song) else "Title" console.print( Padding( f":tada: {downloaded_label} downloaded in [progress.elapsed]{title_dl_time}[/]!", diff --git a/unshackle/core/api/download_manager.py b/unshackle/core/api/download_manager.py index ac9c9cad..746e5317 100644 --- a/unshackle/core/api/download_manager.py +++ b/unshackle/core/api/download_manager.py @@ -328,7 +328,8 @@ def perform_download( `params` holds the API's string form of the `dl` options and is normalized in place before dl.result() sees it: vcodec and range names become enums, `slow` accepts "MIN-MAX", a two-item list, or True (which means 60-120), and `wanted` accepts forms such as "S01E01", "S01-S03", - "s1e1", or "1x1" as well as the internal "SxE" form. + "s1e1", or "1x1" as well as the internal "SxE" form. Music takes track numbers in the same + field, such as "1-5" or "2x3" for disc 2 track 3. """ from contextlib import redirect_stderr, redirect_stdout @@ -408,7 +409,8 @@ def perform_download( from unshackle.core.utils.click_types import SeasonRange if isinstance(wanted_raw, str): - wanted_raw = [wanted_raw] + # the CLI splits a comma-separated value before parsing, so a raw string must too + wanted_raw = re.split(r"\s*[,;]\s*", wanted_raw) # the !? keeps a pre-parsed part exclusion from being re-fed through parse_tokens needs_conversion = any(not re.match(r"^!?\d+x\d+(\.\d+)?$", w) for w in wanted_raw) if needs_conversion: diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index 942e78f2..e875149e 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -18,7 +18,7 @@ from unshackle.core.constants import AUDIO_CODEC_MAP, DYNAMIC_RANGE_MAP, VIDEO_C from unshackle.core.providers.anilist import parse_anilist_ref from unshackle.core.proxies.resolve import initialize_proxy_providers, resolve_proxy from unshackle.core.services import Services -from unshackle.core.titles import Episode, Movie, Title_T +from unshackle.core.titles import Episode, Movie, Song, Title_T from unshackle.core.tracks import Audio, Subtitle, Tracks, Video from unshackle.core.utils.collections import ci_get from unshackle.core.utils.redact import REDACTED, URL_USERINFO_RE @@ -791,7 +791,9 @@ async def list_tracks_handler(data: Dict[str, Any], request: Optional[web.Reques if isinstance(wanted_param, list): wanted = season_range.parse_tokens(*wanted_param) else: - wanted = season_range.parse_tokens(wanted_param) + # convert(), not parse_tokens(), so a comma-separated string splits + # into tokens the way the CLI splits it + wanted = season_range.convert(wanted_param) log.debug( f"Parsed wanted '{sanitize_log(wanted_param)}' into {len(wanted)} episodes: {wanted[:10]}..." ) @@ -806,7 +808,7 @@ async def list_tracks_handler(data: Dict[str, Any], request: Optional[web.Reques if wanted: # Filter titles based on wanted episodes, similar to how dl.py does it - matching_titles = [] + matching_titles: list[Any] = [] log.debug(f"Filtering {len(titles_list)} titles with {len(wanted)} wanted episodes") for title in titles_list: if isinstance(title, Episode): @@ -816,6 +818,13 @@ async def list_tracks_handler(data: Dict[str, Any], request: Optional[web.Reques matching_titles.append(title) else: log.debug(f"Episode {episode_key} not in wanted list") + elif isinstance(title, Song): + song_key = f"{title.disc}x{title.track}" + if title.matches_wanted(wanted): + log.debug(f"Song {song_key} matches wanted list") + matching_titles.append(title) + else: + log.debug(f"Song {song_key} not in wanted list") else: matching_titles.append(title) @@ -824,7 +833,7 @@ async def list_tracks_handler(data: Dict[str, Any], request: Optional[web.Reques if not matching_titles: raise APIError( APIErrorCode.NO_CONTENT, - "No episodes found matching wanted criteria", + "No titles found matching wanted criteria", details={ "service": normalized_service, "title_id": title_id, diff --git a/unshackle/core/api/routes.py b/unshackle/core/api/routes.py index a7165c7b..e69819f7 100644 --- a/unshackle/core/api/routes.py +++ b/unshackle/core/api/routes.py @@ -563,7 +563,7 @@ async def list_tracks(request: web.Request) -> web.Response: description: Title identifier wanted: type: string - description: Specific episode/season (optional) + description: Specific episode/season, or music track ("1-5", "2x3" for disc 2 track 3) (optional) proxy: type: string description: Proxy configuration (optional) @@ -657,7 +657,7 @@ async def download(request: web.Request) -> web.Response: type: array items: type: string - description: Wanted episodes (e.g., ["S01E01", "S01E02"]) (default - all) + description: Wanted episodes (e.g., ["S01E01", "S01E02"]) or music tracks (e.g., ["1-5"], ["2x3"] for disc 2 track 3) (default - all) latest_episode: type: boolean description: Download only the single most recent episode (default - false) diff --git a/unshackle/core/config.py b/unshackle/core/config.py index 6baf329e..d0588d1c 100644 --- a/unshackle/core/config.py +++ b/unshackle/core/config.py @@ -268,7 +268,7 @@ class Config: Args: template_type: One of "movies", "series", or "songs". """ - template = self.output_template[template_type] + template = self.output_template.get(template_type) or "" between_vars = re.findall(r"\}([^{]*)\{", template) separator_text = "".join(between_vars) dot_count = separator_text.count(".") diff --git a/unshackle/core/console.py b/unshackle/core/console.py index 9ba1ae8c..6613c2a6 100644 --- a/unshackle/core/console.py +++ b/unshackle/core/console.py @@ -4,6 +4,7 @@ from datetime import datetime from types import ModuleType from typing import IO, Any, Callable, Iterable, List, Literal, Mapping, Optional, TextIO, Union +from rich import box from rich._log_render import FormatTimeCallable, LogRender from rich.color import Color, blend_rgb from rich.console import Console, ConsoleOptions, ConsoleRenderable, HighlighterType, RenderableType, RenderResult @@ -13,6 +14,7 @@ from rich.live import Live from rich.logging import RichHandler from rich.measure import Measurement from rich.padding import Padding, PaddingDimensions +from rich.panel import Panel from rich.progress import BarColumn, Task from rich.status import Status from rich.style import Style, StyleType @@ -427,6 +429,11 @@ console = ComfyConsole( ) +def listing_panel(renderable: RenderableType, title: str) -> Panel: + """Box a listing in the shared panel style, so every listing restyles from one place.""" + return Panel(renderable, title=title, box=box.SQUARE, border_style="bright_black") + + def prompt_user(prompt: str) -> str: """Ask the user for input on the shared console, themed and indented like the rest of the output.""" indent = " " * 5 @@ -442,5 +449,6 @@ __all__ = ( "GradientPulseBarColumn", "SyncLive", "console", + "listing_panel", "prompt_user", ) diff --git a/unshackle/core/music/__init__.py b/unshackle/core/music/__init__.py index 87189990..c0833702 100644 --- a/unshackle/core/music/__init__.py +++ b/unshackle/core/music/__init__.py @@ -1,8 +1,6 @@ -from .display import MusicHeaderInfo, TrackRow, render_album_header, render_artwork_preview, render_track_panel from .extract import ( build_music_from_songs, classify_release_kind, - dedupe_track_options, duration_seconds, first_number, first_text, @@ -10,40 +8,19 @@ from .extract import ( format_names, year_from_value, ) -from .hasher import file_md5 -from .integrity import MusicAudioIntegrityError, MusicAudioIntegrityResult, verify_music_audio -from .manifest import write_music_manifest -from .models import MusicDiscPlan, MusicDownloadPlan, MusicSongPlan, MusicTrackOption -from .planner import MusicPlanner from .renderer import MusicRenderer from .tagger import MusicMetadataResult, write_music_metadata __all__ = ( - "MusicAudioIntegrityError", - "MusicAudioIntegrityResult", - "MusicDiscPlan", - "MusicDownloadPlan", - "MusicHeaderInfo", "MusicMetadataResult", - "MusicPlanner", "MusicRenderer", - "MusicSongPlan", - "MusicTrackOption", - "TrackRow", "build_music_from_songs", "classify_release_kind", - "dedupe_track_options", "duration_seconds", - "file_md5", "first_number", "first_text", "format_duration", "format_names", - "render_album_header", - "render_artwork_preview", - "render_track_panel", - "verify_music_audio", - "write_music_manifest", "write_music_metadata", "year_from_value", ) diff --git a/unshackle/core/music/display.py b/unshackle/core/music/display.py deleted file mode 100644 index 60aa7945..00000000 --- a/unshackle/core/music/display.py +++ /dev/null @@ -1,174 +0,0 @@ -"""Shared rich-based rendering helpers for music releases. - -Data-in / renderable-out: services do their own quality and field extraction, -then hand plain data here. The helpers know nothing about how a ``quality_label`` -was derived; they only style and lay it out. Quality-schema parsing stays -per-service while panel/header/artwork rendering lives in one place. -""" - -from __future__ import annotations - -import re -from dataclasses import dataclass -from io import BytesIO -from typing import Any, Optional - -from rich import box -from rich.console import Group, RenderableType -from rich.padding import Padding -from rich.panel import Panel -from rich.table import Table -from rich.text import Text -from rich.tree import Tree - -from unshackle.core.titles.music import Song - - -@dataclass -class TrackRow: - """One row in the track tree, already formatted by the calling service. - - When ``note`` is set the row is treated as unavailable: the quality label is - styled red, ``note`` is appended muted, and the ``cd``/``hires`` badges are - suppressed. Otherwise the normal detail line is built and ``cd`` drives a - "CD" badge. - """ - - song: Song - quality_label: str - layout: str - duration_str: str - hires: bool = False - cd: bool = False - note: str = "" - - -@dataclass -class MusicHeaderInfo: - """Plain data holder for the release-header metadata grid.""" - - artist: str - album: str - year: str - track_count: int - quality_label: str - duration_str: str = "" - artist_label: str = "Artist" - - -# Quality strings look like "FLAC 16-bit/44.1kHz"; split the codec token off the -# front and bracket it so the detail line reads "[FLAC] | 16-bit/44.1kHz". -QUALITY_DETAIL_RE = re.compile(r"^(FLAC|OGG|AAC|MP3)\s+(.+)$", flags=re.IGNORECASE) - - -def format_track_detail_quality(quality_label: str) -> str: - """Bracket the leading codec token of a quality string for the detail line.""" - text = str(quality_label or "").strip() - match = QUALITY_DETAIL_RE.match(text) - if match: - return f"[{match.group(1).upper()}] | {match.group(2).strip()}" - return text - - -def render_artwork_preview( - session: Any, - artwork_url: str, - *, - width: int = 25, -) -> Optional[RenderableType]: - """Fetch artwork via ``session`` and render it as half-block coloured cells. - - Pillow (``PIL``) is an optional dependency; any error (missing PIL, network, - bad image, zero dimensions) soft-fails to ``None`` so callers skip the preview. - """ - if not artwork_url: - return None - try: - from PIL import Image - except Exception: - return None - try: - response = session.get(artwork_url, timeout=20) - response.raise_for_status() - image = Image.open(BytesIO(response.content)).convert("RGB") - if not image.width or not image.height: - return None - - height = max(1, int(width * image.height / image.width * 0.5)) - # Image.Resampling exists on Pillow >= 9.1; fall back gracefully on older. - resampling = getattr(getattr(Image, "Resampling", Image), "LANCZOS", 1) - image = image.resize((width, height * 2), resampling) - - lines = [] - for y in range(0, image.height, 2): - line = Text() - for x in range(image.width): - top = image.getpixel((x, y)) - bottom = image.getpixel((x, min(y + 1, image.height - 1))) - top_color = f"#{top[0]:02x}{top[1]:02x}{top[2]:02x}" - bottom_color = f"#{bottom[0]:02x}{bottom[1]:02x}{bottom[2]:02x}" - # Lower-half block fg=bottom/bg=top packs two pixel rows per cell. - line.append("▄", style=f"{bottom_color} on {top_color}") - lines.append(line) - return Group(*lines) - except Exception: - return None - - -def render_track_panel(rows: list[TrackRow], total: int) -> Panel: - """Render the per-track tree (one node + detail line each) in a Panel.""" - track_label = "Track" if total == 1 else "Tracks" - tree = Tree(f"[repr.number]{total}[/] {track_label}", guide_style="bright_black") - for row in rows: - title_line = Text(f"{row.song.track:02}", style="repr.number") - title_line.append(" ") - title_line.append(row.song.name, style="bold #009900") - node = tree.add(title_line, guide_style="bright_black") - - detail = Text() - if row.note: - detail.append(row.quality_label, style="red") - detail.append(" ") - detail.append(row.note, style="bright_black") - else: - detail.append(format_track_detail_quality(row.quality_label)) - if row.layout: - detail.append(" | ") - detail.append(row.layout) - if row.duration_str: - detail.append(" | ") - detail.append(row.duration_str) - if row.cd: - detail.append(" | ") - detail.append("CD", style="yellow1") - if row.hires: - detail.append(" | ") - detail.append("Hi-Res", style="gold1") - node.add(detail, guide_style="bright_black") - return Panel(tree, title="Available Tracks", box=box.SQUARE, border_style="bright_black") - - -def render_album_header( - info: MusicHeaderInfo, - artwork: Optional[RenderableType] = None, -) -> Optional[RenderableType]: - """Render the release metadata grid, optionally placing artwork beside it.""" - grid = Table.grid(padding=(0, 2)) - grid.add_column(style="orchid1", no_wrap=True) - grid.add_column() - grid.add_row(info.artist_label, Text(info.artist, style="bold #ff0000")) - grid.add_row("Collection", Text(info.album, style="blue")) - grid.add_row("Year", Text(info.year, style="blue")) - grid.add_row("Tracks", Text(str(info.track_count), style="blue")) - grid.add_row("Quality", Text(info.quality_label, style="blue")) - if info.duration_str: - grid.add_row("Length", Text(info.duration_str, style="blue")) - - if not artwork: - return grid - - header = Table.grid(expand=True, padding=(0, 2)) - header.add_column(no_wrap=True) - header.add_column(ratio=1) - header.add_row(artwork, Padding(grid, (3, 0, 0, 0))) - return header diff --git a/unshackle/core/music/extract.py b/unshackle/core/music/extract.py index f8bcaad0..c592bd4d 100644 --- a/unshackle/core/music/extract.py +++ b/unshackle/core/music/extract.py @@ -2,9 +2,9 @@ These functions consolidate the generic data-shaping logic that music services otherwise each duplicate: first-non-empty getters, duration/year/name -formatting, release-kind classification, track-option dedupe, and Song -> Music -assembly. They take plain data in and return plain data out (no ``self``), so -any music service can reuse them. +formatting, release-kind classification, and Song -> Music assembly. They take +plain data in and return plain data out (no ``self``), so any music service can +reuse them. """ from __future__ import annotations @@ -12,7 +12,6 @@ from __future__ import annotations import re from typing import Any, Optional -from unshackle.core.music.models import MusicTrackOption from unshackle.core.titles.music import Music, Song @@ -26,7 +25,7 @@ def first_text(*values: Any, default: str = "") -> str: if value in (None, "", [], {}): continue if isinstance(value, dict): - for key in ("name", "title", "display", "display_name", "description", "url"): + for key in ("name", "title", "display", "display_name", "description", "label", "value", "url"): nested = value.get(key) text = first_text(nested) if isinstance(nested, (dict, list)) else str(nested or "").strip() if text: @@ -111,7 +110,7 @@ def classify_release_kind(raw_kind: str, tracks_count: Optional[float]) -> str: Returns one of ``single | ep | album | compilation | live | download | playlist | other``. ``tracks_count`` disambiguates "single" (1 track) from an EP (more than 1 track) for sources that label EPs as singles; pass the - real track count (or ``None`` only when genuinely unknown) — it is required + real track count (or ``None`` only when genuinely unknown) - it is required so no caller silently mislabels a multi-track "single" as an EP by omission. """ key = re.sub(r"[^a-z0-9]+", "", str(raw_kind or "").lower()) @@ -135,26 +134,6 @@ def classify_release_kind(raw_kind: str, tracks_count: Optional[float]) -> str: return "album" -def dedupe_track_options(options: list[MusicTrackOption]) -> list[MusicTrackOption]: - """Drop duplicate track options keyed on codec/quality identity, preserving order.""" - seen: set[tuple[str, Optional[int], Optional[int], Optional[int], str, bool]] = set() - unique: list[MusicTrackOption] = [] - for option in options: - key = ( - str(option.codec or "").upper(), - option.bit_depth, - option.sample_rate, - option.bitrate, - option.quality_label, - option.explicit, - ) - if key in seen: - continue - seen.add(key) - unique.append(option) - return unique - - def build_music_from_songs( songs: list[Song], *, diff --git a/unshackle/core/music/hasher.py b/unshackle/core/music/hasher.py deleted file mode 100644 index 548f04bc..00000000 --- a/unshackle/core/music/hasher.py +++ /dev/null @@ -1,16 +0,0 @@ -from __future__ import annotations - -import hashlib -from pathlib import Path - - -def file_md5(path: Path, chunk_size: int = 1024 * 1024) -> str: - """Return the MD5 checksum for a local file.""" - digest = hashlib.md5() - with path.open("rb") as file: - for chunk in iter(lambda: file.read(chunk_size), b""): - digest.update(chunk) - return digest.hexdigest() - - -__all__ = ("file_md5",) diff --git a/unshackle/core/music/integrity.py b/unshackle/core/music/integrity.py deleted file mode 100644 index d38d3421..00000000 --- a/unshackle/core/music/integrity.py +++ /dev/null @@ -1,189 +0,0 @@ -from __future__ import annotations - -import subprocess -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any, Optional - -from pymediainfo import MediaInfo - -from unshackle.core import binaries - - -class MusicAudioIntegrityError(Exception): - """Raised when a downloaded music audio file cannot be trusted.""" - - -@dataclass -class MusicAudioIntegrityResult: - path: Path - size: int - codec: str = "" - duration: Optional[float] = None - bit_depth: Optional[int] = None - sample_rate: Optional[int] = None - channels: Optional[float] = None - flac_tested: bool = False - warnings: list[str] = field(default_factory=list) - - def to_dict(self) -> dict[str, Any]: - return { - "path": str(self.path), - "size": self.size, - "codec": self.codec, - "duration": self.duration, - "bit_depth": self.bit_depth, - "sample_rate": self.sample_rate, - "channels": self.channels, - "flac_tested": self.flac_tested, - "warnings": self.warnings, - } - - -def verify_music_audio( - path: Path, *, song: Any = None, track: Any = None, media_info: Optional[MediaInfo] = None -) -> MusicAudioIntegrityResult: - """Verify a downloaded music audio file after it is playable/decrypted.""" - path = Path(path) - if not path.exists(): - raise MusicAudioIntegrityError(f"Audio file is missing: {path}") - size = path.stat().st_size - if size <= 3: - raise MusicAudioIntegrityError(f"Audio file is empty: {path}") - - media_info = media_info or MediaInfo.parse(path) - audio_track = next(iter(media_info.audio_tracks or []), None) - if not audio_track: - raise MusicAudioIntegrityError(f"MediaInfo could not find an audio stream in: {path.name}") - - duration = duration_seconds(getattr(audio_track, "duration", None)) - expected_duration = _expected_duration(song, track) - warnings: list[str] = [] - if expected_duration and duration: - tolerance = max(3.0, expected_duration * 0.02) - if abs(duration - expected_duration) > tolerance: - raise MusicAudioIntegrityError( - f"{path.name} duration mismatch: expected {expected_duration:.0f}s, got {duration:.0f}s" - ) - - result = MusicAudioIntegrityResult( - path=path, - size=size, - codec=first_text( - getattr(audio_track, "format", None), - getattr(audio_track, "commercial_name", None), - getattr(audio_track, "codec_id", None), - ), - duration=duration, - bit_depth=first_int(getattr(audio_track, "bit_depth", None)), - sample_rate=first_int(getattr(audio_track, "sampling_rate", None)), - channels=first_float( - getattr(audio_track, "channel_s", None), - getattr(audio_track, "channel_s_original", None), - getattr(track, "channels", None), - ), - warnings=warnings, - ) - - expected_size = _expected_size(track) - if expected_size and result.size != expected_size: - result.warnings.append(f"Size differs from service metadata: expected {expected_size}, got {result.size}") - - if path.suffix.lower() == ".flac": - flac = binaries.find("flac") - if flac: - completed = subprocess.run( - [str(flac), "-t", "-s", str(path)], - check=False, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - encoding="utf-8", - errors="replace", - ) - if completed.returncode != 0: - detail = (completed.stderr or completed.stdout or "").strip() - raise MusicAudioIntegrityError(f"FLAC stream test failed for {path.name}: {detail}") - result.flac_tested = True - else: - result.warnings.append("flac binary not available; FLAC stream test skipped") - - return result - - -def _expected_duration(song: Any, track: Any) -> Optional[float]: - data = getattr(song, "data", None) - if isinstance(data, dict): - metadata = data.get("metadata") if isinstance(data.get("metadata"), dict) else {} - value = first_float(data.get("duration"), metadata.get("duration")) - if value: - return value - track_data = getattr(track, "data", None) - if isinstance(track_data, dict): - metadata = track_data.get("metadata") if isinstance(track_data.get("metadata"), dict) else {} - value = first_float(track_data.get("duration"), metadata.get("duration")) - if value: - return value - return None - - -def _expected_size(track: Any) -> Optional[int]: - data = getattr(track, "data", None) - if not isinstance(data, dict): - return None - sources = [ - data, - data.get("file_info") if isinstance(data.get("file_info"), dict) else {}, - data.get("audio_info") if isinstance(data.get("audio_info"), dict) else {}, - data.get("source_info") if isinstance(data.get("source_info"), dict) else {}, - data.get("stream_info") if isinstance(data.get("stream_info"), dict) else {}, - data.get("metadata") if isinstance(data.get("metadata"), dict) else {}, - ] - for source in sources: - value = first_int( - source.get("content_length"), - source.get("contentLength"), - source.get("file_size"), - source.get("filesize"), - source.get("size"), - ) - if value: - return value - return None - - -def duration_seconds(value: Any) -> Optional[float]: - number = first_float(value) - if number is None: - return None - if number > 1000: - return number / 1000 - return number - - -def first_text(*values: Any) -> str: - for value in values: - text = str(value or "").strip() - if text: - return text - return "" - - -def first_float(*values: Any) -> Optional[float]: - for value in values: - if value in (None, "", [], {}): - continue - text = str(value).strip().replace(" ", "") - try: - return float(text) - except (TypeError, ValueError): - continue - return None - - -def first_int(*values: Any) -> Optional[int]: - value = first_float(*values) - return int(value) if value is not None else None - - -__all__ = ("MusicAudioIntegrityError", "MusicAudioIntegrityResult", "verify_music_audio") diff --git a/unshackle/core/music/manifest.py b/unshackle/core/music/manifest.py deleted file mode 100644 index fee4d304..00000000 --- a/unshackle/core/music/manifest.py +++ /dev/null @@ -1,23 +0,0 @@ -from __future__ import annotations - -import json -from datetime import datetime, timezone -from pathlib import Path -from typing import Any - - -def write_music_manifest(path: Path, *, release: dict[str, Any], tracks: list[dict[str, Any]]) -> Path: - """Write a compact JSON manifest for music integrity, checksums, and metadata results.""" - path = Path(path) - path.parent.mkdir(parents=True, exist_ok=True) - payload = { - "schema": "unshackle.music.manifest.v1", - "generated_at": datetime.now(timezone.utc).isoformat(), - "release": release, - "tracks": tracks, - } - path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8") - return path - - -__all__ = ("write_music_manifest",) diff --git a/unshackle/core/music/models.py b/unshackle/core/music/models.py deleted file mode 100644 index 69682dc9..00000000 --- a/unshackle/core/music/models.py +++ /dev/null @@ -1,58 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from pathlib import Path -from typing import TYPE_CHECKING, Optional - -if TYPE_CHECKING: - from unshackle.core.titles.music import Song - - -@dataclass -class MusicTrackOption: - codec: str - bit_depth: Optional[int] = None - sample_rate: Optional[int] = None - bitrate: Optional[int] = None - channels: Optional[float] = None - lossless: bool = False - hires: bool = False - atmos: bool = False - explicit: bool = False - duration: Optional[int] = None - quality_label: str = "" - - -@dataclass -class MusicSongPlan: - song: Song - options: list[MusicTrackOption] = field(default_factory=list) - selected: Optional[MusicTrackOption] = None - output_path: Optional[Path] = None - fallback_used: bool = False - skip_reason: str = "" - - -@dataclass -class MusicDiscPlan: - disc_number: int - songs: list[MusicSongPlan] = field(default_factory=list) - - -@dataclass -class MusicDownloadPlan: - kind: str - title: str - artist: str - album_artist: str = "" - year: Optional[int] = None - released: str = "" - genre: str = "" - label: str = "" - artwork_url: Optional[str] = None - total_tracks: Optional[int] = None - total_discs: Optional[int] = None - total_duration: Optional[int] = None - discs: list[MusicDiscPlan] = field(default_factory=list) - quality_requested: str = "best" - fallback_mode: str = "next-best" diff --git a/unshackle/core/music/planner.py b/unshackle/core/music/planner.py deleted file mode 100644 index 0ae26320..00000000 --- a/unshackle/core/music/planner.py +++ /dev/null @@ -1,214 +0,0 @@ -from __future__ import annotations - -import re -from collections import defaultdict -from typing import Any - -from unshackle.core.music.models import MusicDiscPlan, MusicDownloadPlan, MusicSongPlan, MusicTrackOption -from unshackle.core.titles.music import Music, Song - - -class MusicPlanner: - """Build a service-neutral music list/download plan for native Music rendering.""" - - def __init__(self, service: Any): - self.service = service - - def build(self, music: Music) -> MusicDownloadPlan: - first_song = music[0] if music else None - first_data = first_song.data if first_song and isinstance(first_song.data, dict) else {} - first_metadata = first_data.get("metadata") if isinstance(first_data.get("metadata"), dict) else {} - plan = MusicDownloadPlan( - kind=getattr(music, "kind", "music"), - title=getattr(music, "title", None) or (first_song.album if first_song else ""), - artist=getattr(music, "artist", None) - or (first_song.album_artist or first_song.artist if first_song else ""), - album_artist=(first_song.album_artist if first_song else "") or "", - year=getattr(music, "year", None) or (first_song.year if first_song else None), - released=self.first_text( - getattr(music, "released", None), - getattr(music, "release_date", None), - first_data.get("release_date"), - first_data.get("released_at"), - first_metadata.get("release_date"), - first_metadata.get("released_at"), - ), - genre=(first_song.genre if first_song else "") or "", - label=(first_song.label if first_song else "") or "", - artwork_url=getattr(music, "artwork_url", None), - total_tracks=getattr(music, "total_tracks", None) or len(music), - total_discs=getattr(music, "total_discs", None) or self.max_value(music, "disc"), - total_duration=getattr(music, "total_duration", None) or self.sum_duration(music), - quality_requested=self.quality_requested(music), - ) - - selected_options: list[MusicTrackOption] = [] - discs: dict[int, list[MusicSongPlan]] = defaultdict(list) - for song in music: - options = self.get_options(song) - selected = options[0] if options else None - if selected: - selected_options.append(selected) - discs[song.disc].append( - MusicSongPlan( - song=song, - options=options, - selected=selected, - ) - ) - - for disc_number in sorted(discs): - plan.discs.append(MusicDiscPlan(disc_number=disc_number, songs=discs[disc_number])) - - quality = self.quality_summary_from_options(selected_options) - if quality: - plan.quality_requested = quality - - return plan - - def get_options(self, song: Song) -> list[MusicTrackOption]: - provider = getattr(self.service, "get_music_track_options", None) - if callable(provider): - options = provider(song) - if options: - return options - option = self.option_from_song(song) - return [option] if option else [] - - @staticmethod - def option_from_song(song: Song) -> MusicTrackOption | None: - data = song.data if isinstance(song.data, dict) else {} - metadata = data.get("metadata") if isinstance(data.get("metadata"), dict) else {} - quality = MusicPlanner.first_text(data.get("quality"), metadata.get("quality"), metadata.get("quality_label")) - duration = MusicPlanner.first_number(data.get("duration"), metadata.get("duration")) - if not quality and duration is None: - return None - - codec = MusicPlanner.codec_from_quality(quality) - bit_depth, sample_rate = MusicPlanner.quality_numbers(quality) - bitrate = MusicPlanner.bitrate_from_quality(quality) - hires = bool((bit_depth and bit_depth > 16) or (sample_rate and sample_rate > 48000)) - lossless = codec in {"FLAC", "ALAC", "WAV", "AIFF"} or "lossless" in quality.lower() - atmos = "atmos" in quality.lower() or bool(data.get("atmos") or metadata.get("atmos")) - explicit = bool(getattr(song, "explicit", None) or data.get("explicit") or metadata.get("explicit")) - - return MusicTrackOption( - codec=codec, - bit_depth=bit_depth, - sample_rate=sample_rate, - bitrate=bitrate, - channels=MusicPlanner.first_number(data.get("channels"), metadata.get("channels")), - lossless=lossless, - hires=hires, - atmos=atmos, - explicit=explicit, - duration=int(duration) if duration is not None else None, - quality_label=quality, - ) - - def quality_requested(self, music: Music) -> str: - service_quality = getattr(self.service, "quality", None) - if service_quality: - quality_map = { - 27: "Hi-Res Lossless", - 7: "Hi-Res Lossless", - 6: "Lossless", - 5: "MP3", - } - return quality_map.get(service_quality, str(service_quality)) - - first_song = music[0] if music else None - if not first_song: - return "" - data = first_song.data if isinstance(first_song.data, dict) else {} - return self.first_text(data.get("quality")) - - @staticmethod - def first_text(*values: Any) -> str: - for value in values: - if value in (None, ""): - continue - text = str(value).strip() - if text: - return text - return "" - - @staticmethod - def first_number(*values: Any) -> float | None: - for value in values: - if value in (None, ""): - continue - try: - return float(value) - except (TypeError, ValueError): - continue - return None - - @staticmethod - def codec_from_quality(value: str) -> str: - lowered = value.lower() - for codec in ("flac", "alac", "aac", "mp3", "wav", "aiff"): - if codec in lowered: - return codec.upper() - return "" - - @staticmethod - def quality_numbers(value: str) -> tuple[int | None, int | None]: - bit_depth = None - sample_rate = None - bit_match = re.search(r"(?P\d+)\s*[- ]?bit", value.lower()) - if bit_match: - bit_depth = int(bit_match.group("bits")) - sample_match = re.search(r"(?P\d+(?:\.\d+)?)\s*k(?:hz)?", value.lower()) - if sample_match: - sample_rate = int(float(sample_match.group("rate")) * 1000) - return bit_depth, sample_rate - - @staticmethod - def bitrate_from_quality(value: str) -> int | None: - match = re.search(r"(?P\d+)\s*kb/s", value.lower()) - if not match: - return None - return int(match.group("rate")) * 1000 - - @staticmethod - def quality_summary_from_options(options: list[MusicTrackOption]) -> str: - if not options: - return "" - - codecs = {str(option.codec or "").upper() for option in options if option.codec} - labels = [option.quality_label for option in options if option.quality_label] - - if any(option.atmos for option in options): - return "Dolby Atmos" - if any( - option.hires and (option.lossless or str(option.codec or "").upper() in {"FLAC", "ALAC", "WAV", "AIFF"}) - for option in options - ): - return "Hi-Res Lossless" - if any( - option.lossless or str(option.codec or "").upper() in {"FLAC", "ALAC", "WAV", "AIFF"} for option in options - ): - return "Lossless" - if "AAC" in codecs: - return "AAC" - if "MP3" in codecs: - return "MP3" - return MusicPlanner.first_text(*labels) - - @staticmethod - def sum_duration(music: Music) -> int | None: - total = 0 - for song in music: - data = song.data if isinstance(song.data, dict) else {} - value = MusicPlanner.first_number(data.get("duration")) - total += int(value or 0) - return total or None - - @staticmethod - def max_value(music: Music, attr: str) -> int | None: - values = [getattr(song, attr, 0) or 0 for song in music] - return max(values, default=0) or None - - -__all__ = ("MusicPlanner",) diff --git a/unshackle/core/music/renderer.py b/unshackle/core/music/renderer.py index af9d9faf..a93d7ce4 100644 --- a/unshackle/core/music/renderer.py +++ b/unshackle/core/music/renderer.py @@ -3,33 +3,28 @@ from __future__ import annotations import re from typing import Any, Optional -from rich import box from rich.console import Group, RenderableType -from rich.markup import escape from rich.panel import Panel from rich.table import Table from rich.text import Text from rich.tree import Tree -from unshackle.core.music.models import MusicDownloadPlan, MusicTrackOption +from unshackle.core.console import listing_panel +from unshackle.core.music.extract import first_text, format_duration from unshackle.core.titles.music import Music, Song class MusicRenderer: - """Render native Music title containers for the CLI.""" + """Render a Music release for the CLI title listing. - COMPACT_TRACK_LIMIT = 8 + The album header and per-track lines are specific to music. The box around + the tracklist comes from ``listing_panel``, so it restyles along with the + track listing that core prints for every other title type. + """ - def render(self, music: Music, *, verbose: bool = False) -> RenderableType: + def render(self, music: Music) -> RenderableType: header = self.render_header(music) - tracks = self.render_tracks(music, verbose=verbose) - if header: - return Group(header, Text(""), tracks) - return tracks - - def render_plan(self, plan: MusicDownloadPlan, *, verbose: bool = True) -> RenderableType: - header = self.render_plan_header(plan) - tracks = self.render_plan_tracks(plan, verbose=verbose) + tracks = self.render_tracks(music) if header: return Group(header, Text(""), tracks) return tracks @@ -60,7 +55,7 @@ class MusicRenderer: ) length = self.format_total_duration(getattr(music, "total_duration", None) or self.sum_duration(music)) quality = self.quality_summary( - self.first_text( + first_text( getattr(music, "quality", None), data.get("quality"), metadata.get("quality"), @@ -70,119 +65,44 @@ class MusicRenderer: hires=self.as_bool(self.first_value(data.get("hires"), metadata.get("hires"))), ) - grid = self.metadata_grid() + grid = Table.grid(padding=(0, 2)) + grid.add_column(style="bright_black", no_wrap=True) + grid.add_column() - grid.add_row(Text("Title", style="bright_black"), Text(str(title))) - grid.add_row(Text("Artist", style="bright_black"), Text(str(artist))) - grid.add_row(Text("Type", style="bright_black"), self.kind_text(kind, explicit=explicit)) + # values are service data, so they stay Text and never get parsed as markup + grid.add_row("Title", Text(str(title))) + grid.add_row("Artist", Text(str(artist))) + grid.add_row("Type", self.kind_text(kind, explicit=explicit)) if released: - grid.add_row(Text("Released", style="bright_black"), Text(released)) + grid.add_row("Released", Text(released)) if year: - grid.add_row(Text("Year", style="bright_black"), Text(str(year))) - grid.add_row(Text("Tracks", style="bright_black"), Text(str(total_tracks))) + grid.add_row("Year", Text(str(year))) + grid.add_row("Tracks", Text(str(total_tracks))) if total_discs and total_discs > 1: - grid.add_row(Text("Discs", style="bright_black"), Text(str(total_discs))) + grid.add_row("Discs", Text(str(total_discs))) if length: - grid.add_row(Text("Length", style="bright_black"), Text(length)) + grid.add_row("Length", Text(length)) if quality: - grid.add_row(Text("Quality", style="bright_black"), Text(quality)) + grid.add_row("Quality", Text(quality)) if first_song.genre: - grid.add_row(Text("Genre", style="bright_black"), Text(first_song.genre)) + grid.add_row("Genre", Text(first_song.genre)) if first_song.label: - grid.add_row(Text("Label", style="bright_black"), Text(first_song.label)) + grid.add_row("Label", Text(first_song.label)) return grid - def render_tracks(self, music: Music, *, verbose: bool = False) -> Panel: + def render_tracks(self, music: Music) -> Panel: total = len(music) track_label = "Track" if total == 1 else "Tracks" tree = Tree(f"[repr.number]{total}[/] {track_label}", guide_style="bright_black") - visible_songs = list(music) - if not verbose and len(visible_songs) > self.COMPACT_TRACK_LIMIT: - visible_songs = visible_songs[: self.COMPACT_TRACK_LIMIT] - - for song in visible_songs: - node = tree.add(self.song_line(song, music), guide_style="bright_black") + for song in music: + node = tree.add(self.song_line(song, music)) option = self.option_from_song(song) if option: - node.add(option, guide_style="bright_black") + node.add(option) - hidden = total - len(visible_songs) - if hidden > 0: - suffix = "s" if hidden != 1 else "" - tree.add(f"[bright_black]... {hidden} more track{suffix}[/]", guide_style="bright_black") - - return Panel(tree, title="Available Tracks", box=box.SQUARE, border_style="bright_black") - - def render_plan_header(self, plan: MusicDownloadPlan) -> Optional[Table]: - title = plan.title - artist = plan.artist or plan.album_artist - kind = self.display_kind(plan.kind) - explicit = any( - bool(getattr(song_plan.song, "explicit", None) or (song_plan.selected and song_plan.selected.explicit)) - for disc in plan.discs - for song_plan in disc.songs - ) - released = self.format_release_date(getattr(plan, "released", None) or getattr(plan, "release_date", None)) - length = self.format_total_duration(plan.total_duration) - quality = self.quality_summary(plan.quality_requested) - - grid = self.metadata_grid() - if title: - grid.add_row(Text("Title", style="bright_black"), Text(str(title))) - if artist: - grid.add_row(Text("Artist", style="bright_black"), Text(str(artist))) - grid.add_row(Text("Type", style="bright_black"), self.kind_text(kind, explicit=explicit)) - if released: - grid.add_row(Text("Released", style="bright_black"), Text(released)) - if plan.year: - grid.add_row(Text("Year", style="bright_black"), Text(str(plan.year))) - if plan.total_tracks: - grid.add_row(Text("Tracks", style="bright_black"), Text(str(plan.total_tracks))) - if plan.total_discs and plan.total_discs > 1: - grid.add_row(Text("Discs", style="bright_black"), Text(str(plan.total_discs))) - if length: - grid.add_row(Text("Length", style="bright_black"), Text(length)) - if quality: - grid.add_row(Text("Quality", style="bright_black"), Text(quality)) - if plan.genre: - grid.add_row(Text("Genre", style="bright_black"), Text(str(plan.genre))) - if plan.label: - grid.add_row(Text("Label", style="bright_black"), Text(str(plan.label))) - return grid - - def render_plan_tracks(self, plan: MusicDownloadPlan, *, verbose: bool = True) -> Panel: - songs = [song_plan for disc in plan.discs for song_plan in disc.songs] - total = len(songs) - track_label = "Track" if total == 1 else "Tracks" - tree = Tree(f"[repr.number]{total}[/] {track_label}", guide_style="bright_black") - - visible_songs = songs - if not verbose and len(visible_songs) > self.COMPACT_TRACK_LIMIT: - visible_songs = visible_songs[: self.COMPACT_TRACK_LIMIT] - - for song_plan in visible_songs: - node = tree.add(self.song_line(song_plan.song, plan), guide_style="bright_black") - if song_plan.skip_reason: - node.add(f"[yellow]Skipped:[/] {escape(song_plan.skip_reason)}", guide_style="bright_black") - continue - for option in song_plan.options: - node.add(self.option_line(option), guide_style="bright_black") - - hidden = total - len(visible_songs) - if hidden > 0: - suffix = "s" if hidden != 1 else "" - tree.add(f"[bright_black]... {hidden} more track{suffix}[/]", guide_style="bright_black") - - return Panel(tree, title="Available Tracks", box=box.SQUARE, border_style="bright_black") - - @staticmethod - def metadata_grid() -> Table: - grid = Table.grid(padding=(0, 2)) - grid.add_column(style="orchid1", no_wrap=True) - grid.add_column() - return grid + return listing_panel(tree, "Tracklist") @staticmethod def display_kind(kind: Any) -> str: @@ -205,7 +125,7 @@ class MusicRenderer: return labels[key] return text.replace("_", " ").replace("-", " ").title() - def song_line(self, song: Song, music: Music | MusicDownloadPlan) -> Text: + def song_line(self, song: Song, music: Music) -> Text: number = f"{song.disc}.{song.track:02}" if song.disc > 1 else f"{song.track:02}" line = Text() line.append(number, style="repr.number") @@ -217,20 +137,22 @@ class MusicRenderer: line.append(f" - {song.artist}", style="bright_black") return line - def option_from_song(self, song: Song) -> Text | str: + def option_from_song(self, song: Song) -> Text: data = song.data if isinstance(song.data, dict) else {} metadata = data.get("metadata") if isinstance(data.get("metadata"), dict) else {} - quality = self.first_text(data.get("quality"), metadata.get("quality"), metadata.get("quality_label")) - duration = self.format_duration(self.first_value(data.get("duration"), metadata.get("duration"))) - reason = self.first_text( + quality = first_text(data.get("quality"), metadata.get("quality"), metadata.get("quality_label")) + raw_duration = self.first_value(data.get("duration"), metadata.get("duration")) + # an already-formatted duration ("3:45") does not parse as seconds, so show it as given + duration = format_duration(raw_duration) or first_text(raw_duration) + reason = first_text( data.get("unavailable_reason"), data.get("skip_reason"), metadata.get("unavailable_reason"), metadata.get("skip_reason"), ) if reason: - return f"[yellow]Skipped:[/] {escape(reason)}" + return Text.assemble(("Skipped:", "yellow"), f" {reason}") badges = [] if song.explicit: @@ -246,35 +168,9 @@ class MusicRenderer: if duration: details.append(duration) if not details and not badges: - return "" + return Text() return self.format_option_text(details, badges) - def option_line(self, option: MusicTrackOption) -> Text: - parts = [] - codec = str(option.codec or "").strip() - if codec: - parts.append(f"[{codec}]") - if option.quality_label: - parts.extend(self.split_quality_label(option.quality_label, codec)) - elif option.bit_depth and option.sample_rate: - parts.append(f"{option.bit_depth}-bit/{self.format_sample_rate(option.sample_rate)}") - elif option.bitrate: - parts.append(f"{int(option.bitrate / 1000)} kb/s") - if option.channels: - parts.append(self.format_channels(option.channels)) - if option.duration: - parts.append(self.format_duration(option.duration)) - badges = [] - if option.explicit: - badges.append(("E", "bold bright_red")) - if option.atmos: - badges.append(("Atmos", "magenta")) - if self.is_cd_option(option): - badges.append(("CD", "yellow1")) - if option.hires: - badges.append(("Hi-Res", "gold1")) - return self.format_option_text(parts, badges) - @staticmethod def kind_text(kind: str, *, explicit: bool = False) -> Text: text = Text(str(kind)) @@ -300,76 +196,6 @@ class MusicRenderer: first = False return text - @staticmethod - def format_option_parts(parts: list[str]) -> str: - if not parts: - return "" - return " | ".join(escape(part) for part in parts if part) - - @staticmethod - def split_quality_label(value: str, codec: str = "") -> list[str]: - text = str(value or "").strip() - if not text: - return [] - if codec and text.lower().startswith(codec.lower()): - text = text[len(codec) :].strip() - return [text] if text else [] - - @staticmethod - def is_cd_option(option: MusicTrackOption) -> bool: - codec = str(option.codec or "").upper() - if codec not in {"FLAC", "ALAC", "WAV", "AIFF"}: - return False - if option.bit_depth == 16 and option.sample_rate in {44100, 44100.0}: - return True - return "16-bit/44.1" in str(option.quality_label or "").lower() - - @staticmethod - def format_sample_rate(value: Any) -> str: - try: - sample_rate = float(value) - except (TypeError, ValueError): - return str(value).strip() - if sample_rate >= 1000: - sample_rate /= 1000 - if sample_rate.is_integer(): - return f"{int(sample_rate)} kHz" - return f"{sample_rate:g} kHz" - - @staticmethod - def format_channels(value: Any) -> str: - try: - channels = float(value) - except (TypeError, ValueError): - return str(value).strip() - if channels == 1: - return "Mono" - if channels == 2: - return "Stereo" - if channels.is_integer(): - return f"{int(channels)}.0" - return f"{channels:g}" - - @staticmethod - def first_text(*values: Any) -> str: - for value in values: - if value is None: - continue - if isinstance(value, dict): - for key in ("label", "name", "title", "display_name", "value"): - nested = value.get(key) - if nested: - return str(nested).strip() - elif isinstance(value, (list, tuple)): - text = MusicRenderer.first_text(*value) - if text: - return text - else: - text = str(value).strip() - if text: - return text - return "" - @staticmethod def first_value(*values: Any) -> Any: for value in values: @@ -385,20 +211,6 @@ class MusicRenderer: return value.strip().lower() in {"1", "true", "yes", "y"} return bool(value) - @staticmethod - def format_duration(value: Any) -> str: - if value in (None, ""): - return "" - try: - seconds = int(float(value)) - except (TypeError, ValueError): - return str(value).strip() - minutes, seconds = divmod(seconds, 60) - hours, minutes = divmod(minutes, 60) - if hours: - return f"{hours}:{minutes:02}:{seconds:02}" - return f"{minutes}:{seconds:02}" - @staticmethod def format_total_duration(value: Any) -> str: if value in (None, ""): diff --git a/unshackle/core/music/tagger.py b/unshackle/core/music/tagger.py index 84b1e9e4..ca8b6237 100644 --- a/unshackle/core/music/tagger.py +++ b/unshackle/core/music/tagger.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +from base64 import b64encode from dataclasses import dataclass from io import BytesIO from pathlib import Path @@ -30,15 +31,18 @@ try: TRCK, TSRC, TXXX, + USLT, ID3NoHeaderError, ) from mutagen.mp3 import MP3 from mutagen.mp4 import MP4, MP4Cover + from mutagen.oggvorbis import OggVorbis except ImportError: # pragma: no cover - optional tagging dependency FLAC = Picture = None - MP3 = MP4 = MP4Cover = None + MP3 = MP4 = MP4Cover = OggVorbis = None ID3NoHeaderError = Exception APIC = COMM = TALB = TCOM = TCON = TCOP = TDRC = TIT2 = TPE1 = TPE2 = TPOS = TRCK = TSRC = TXXX = None + USLT = None log = logging.getLogger("MUSIC_TAGGER") @@ -61,10 +65,10 @@ class MusicMetadataResult: def write_music_metadata(path: Path, song: Any, *, session: Any = None, source_md5: str = "") -> MusicMetadataResult: - """Write normalized music metadata for FLAC, MP3, and M4A/MP4 audio files.""" + """Write normalized music metadata for FLAC, Ogg, MP3, and M4A/MP4 audio files.""" path = Path(path) extension = path.suffix.lower() - if extension not in {".flac", ".mp3", ".m4a", ".mp4"}: + if extension not in {".flac", ".mp3", ".m4a", ".mp4", ".ogg"}: return MusicMetadataResult(skipped=True, reason=f"Unsupported music metadata container: {extension}") if extension == ".flac" and FLAC is None: @@ -73,6 +77,8 @@ def write_music_metadata(path: Path, song: Any, *, session: Any = None, source_m return MusicMetadataResult(skipped=True, reason="install mutagen to write MP3 tags") if extension in {".m4a", ".mp4"} and MP4 is None: return MusicMetadataResult(skipped=True, reason="install mutagen to write MP4/M4A tags") + if extension == ".ogg" and OggVorbis is None: + return MusicMetadataResult(skipped=True, reason="install mutagen to write Ogg tags") tags = build_tag_values(song, source_md5=source_md5) artwork_url = first_text(getattr(song, "artwork_url", None), _metadata(song).get("artwork_url")) @@ -80,6 +86,8 @@ def write_music_metadata(path: Path, song: Any, *, session: Any = None, source_m if extension == ".flac": write_flac_tags(path, tags, cover_data, mime_type) + elif extension == ".ogg": + write_ogg_tags(path, tags, cover_data, mime_type) elif extension == ".mp3": write_mp3_tags(path, tags, cover_data, mime_type) else: @@ -116,6 +124,7 @@ def build_tag_values(song: Any, *, source_md5: str = "") -> dict[str, str]: "COPYRIGHT": string_tag(getattr(song, "copyright", None) or metadata.get("copyright")), "LABEL": first_text(getattr(song, "label", None), metadata.get("label")), "COMMENT": first_text(metadata.get("comment"), metadata.get("quality")), + "LYRICS": first_text(getattr(song, "lyrics", None), metadata.get("lyrics")), "SOURCE": first_text(metadata.get("source"), metadata.get("service")), "ENCODEDBY": "Unshackle", "UNSHACKLE_SOURCE_MD5": source_md5, @@ -145,24 +154,43 @@ def write_flac_tags(path: Path, tags: dict[str, str], cover_data: Optional[bytes audio = FLAC(path) for key, value in tags.items(): audio[key] = [value] - if cover_data and Picture is not None: - picture = Picture() - picture.type = 3 - picture.mime = mime_type or "image/jpeg" - picture.desc = "Cover" - picture.data = cover_data - if Image is not None: - try: - with Image.open(BytesIO(cover_data)) as image: - picture.width, picture.height = image.size - picture.depth = len(image.getbands()) * 8 - except Exception: - pass + picture = build_picture(cover_data, mime_type) + if picture is not None: audio.clear_pictures() audio.add_picture(picture) audio.save() +def build_picture(cover_data: Optional[bytes], mime_type: str) -> Optional[Any]: + """Build a FLAC Picture block, which Ogg reuses base64-encoded.""" + if not cover_data or Picture is None: + return None + picture = Picture() + picture.type = 3 + picture.mime = mime_type or "image/jpeg" + picture.desc = "Cover" + picture.data = cover_data + if Image is not None: + try: + with Image.open(BytesIO(cover_data)) as image: + picture.width, picture.height = image.size + picture.depth = len(image.getbands()) * 8 + except Exception: + pass + return picture + + +def write_ogg_tags(path: Path, tags: dict[str, str], cover_data: Optional[bytes], mime_type: str) -> None: + audio = OggVorbis(path) + for key, value in tags.items(): + audio[key] = [value] + picture = build_picture(cover_data, mime_type) + if picture is not None: + # Ogg carries no picture block of its own, so the FLAC block goes in base64 + audio["METADATA_BLOCK_PICTURE"] = [b64encode(picture.write()).decode("ascii")] + audio.save() + + def write_mp3_tags(path: Path, tags: dict[str, str], cover_data: Optional[bytes], mime_type: str) -> None: try: audio = MP3(path) @@ -191,11 +219,13 @@ def write_mp3_tags(path: Path, tags: dict[str, str], cover_data: Optional[bytes] audio.tags.setall(frame_id, [frame_class(encoding=3, text=[value])]) if tags.get("COMMENT") and COMM is not None: audio.tags.setall("COMM", [COMM(encoding=3, lang="eng", desc="", text=[tags["COMMENT"]])]) + if tags.get("LYRICS") and USLT is not None: + audio.tags.setall("USLT", [USLT(encoding=3, lang="eng", desc="", text=tags["LYRICS"])]) if cover_data and APIC is not None: audio.tags.delall("APIC") audio.tags.add(APIC(encoding=3, mime=mime_type or "image/jpeg", type=3, desc="Cover", data=cover_data)) - custom_keys = set(tags) - set(frame_map) - {"COMMENT"} + custom_keys = set(tags) - set(frame_map) - {"COMMENT", "LYRICS"} for key in sorted(custom_keys): if TXXX is not None and tags[key]: audio.tags.delall(f"TXXX:{key}") @@ -223,6 +253,8 @@ def write_mp4_tags(path: Path, tags: dict[str, str], cover_data: Optional[bytes] audio["\xa9cmt"] = [tags["COMMENT"]] if tags.get("COPYRIGHT"): audio["cprt"] = [tags["COPYRIGHT"]] + if tags.get("LYRICS"): + audio["\xa9lyr"] = [tags["LYRICS"]] track_number, track_total = split_number_pair(tags.get("TRACKNUMBER", "")) if track_number: @@ -244,6 +276,7 @@ def write_mp4_tags(path: Path, tags: dict[str, str], cover_data: Optional[bytes] "COMPOSER", "COMMENT", "COPYRIGHT", + "LYRICS", "TRACKNUMBER", "DISCNUMBER", } @@ -272,7 +305,12 @@ def download_cover(session: Any, artwork_url: str) -> tuple[Optional[bytes], str def _metadata(song: Any) -> dict[str, Any]: data = getattr(song, "data", None) - return data if isinstance(data, dict) else {} + if not isinstance(data, dict): + return {} + # the renderer reads a nested "metadata" sub-dict as well as the top level, so a + # service that follows it would otherwise lose every tag read through this helper + nested = data.get("metadata") + return {**data, **nested} if isinstance(nested, dict) else data def first_text(*values: Any) -> str: diff --git a/unshackle/core/titles/episode.py b/unshackle/core/titles/episode.py index 860a9be5..1ec5110c 100644 --- a/unshackle/core/titles/episode.py +++ b/unshackle/core/titles/episode.py @@ -6,6 +6,7 @@ from typing import Any, Collection, Iterable, Optional, Union from langcodes import Language from pymediainfo import MediaInfo +from rich.console import RenderableType from rich.tree import Tree from sortedcontainers import SortedKeyList @@ -272,7 +273,7 @@ class Series(SortedKeyList, ABC): return super().__str__() return self[0].title + (f" ({self[0].year})" if self[0].year else "") - def tree(self, verbose: bool = False) -> Tree: + def tree(self, verbose: bool = False) -> RenderableType: seasons = Counter(x.season for x in self) num_seasons = len(seasons) sum(seasons.values()) diff --git a/unshackle/core/titles/movie.py b/unshackle/core/titles/movie.py index 6d6508c5..429e1ee7 100644 --- a/unshackle/core/titles/movie.py +++ b/unshackle/core/titles/movie.py @@ -4,6 +4,7 @@ from typing import Any, Iterable, Optional, Union from langcodes import Language from pymediainfo import MediaInfo +from rich.console import RenderableType from rich.tree import Tree from sortedcontainers import SortedKeyList @@ -90,7 +91,7 @@ class Movies(SortedKeyList, ABC): # TODO: Assumes there's only one movie return self[0].name + (f" ({self[0].year})" if self[0].year else "") - def tree(self, verbose: bool = False) -> Tree: + def tree(self, verbose: bool = False) -> RenderableType: num_movies = len(self) tree = Tree(f"{num_movies} Movie{['s', ''][num_movies == 1]}", guide_style="bright_black") if verbose: diff --git a/unshackle/core/titles/music.py b/unshackle/core/titles/music.py index 1fe20526..3dbba4b2 100644 --- a/unshackle/core/titles/music.py +++ b/unshackle/core/titles/music.py @@ -1,9 +1,10 @@ import re from abc import ABC -from typing import Any, Iterable, Optional, Union +from typing import Any, Collection, Iterable, Optional, Union from langcodes import Language from pymediainfo import MediaInfo +from rich.console import RenderableType from sortedcontainers import SortedKeyList from unshackle.core.config import config @@ -21,8 +22,8 @@ class Song(Title): artist: str, album: str, track: int, - disc: int, - year: int, + disc: int = 1, + year: Optional[int] = None, language: Optional[Union[str, Language]] = None, data: Optional[Any] = None, album_artist: Optional[str] = None, @@ -60,14 +61,10 @@ class Song(Title): if not isinstance(track, int): raise TypeError(f"Expected track to be an int, not {track!r}") - if not disc: - raise ValueError("Song disc must be provided") if not isinstance(disc, int): raise TypeError(f"Expected disc to be an int, not {disc!r}") - if not year: - raise ValueError("Song year must be provided") - if not isinstance(year, int): + if year is not None and not isinstance(year, int): raise TypeError(f"Expected year to be an int, not {year!r}") if album_artist is not None and not isinstance(album_artist, str): raise TypeError(f"Expected album_artist to be a str, not {album_artist!r}") @@ -111,7 +108,7 @@ class Song(Title): raise ValueError(f"Song track cannot be {track}") if disc <= 0: raise ValueError(f"Song disc cannot be {disc}") - if year <= 0: + if year is not None and year <= 0: raise ValueError(f"Song year cannot be {year}") if not release_type: raise ValueError("Song release_type must be provided") @@ -141,9 +138,18 @@ class Song(Title): def __str__(self) -> str: return "{artist} - {album} ({year}) / {track:02}. {name}".format( - artist=self.artist, album=self.album, year=self.year, track=self.track, name=self.name + artist=self.artist, album=self.album, year=self.year or "?", track=self.track, name=self.name ).strip() + def matches_wanted(self, wanted: Collection[str]) -> bool: + """Whether a parsed ``-w`` key set selects this song. + + A song shares the episode key space, its disc reading as the season and its track + as the episode, so ``-w 1-5`` takes tracks 1 to 5 and ``-w 2x3`` takes disc 2 + track 3. A song has no parts and no air date, so it answers to one key only. + """ + return f"{self.disc}x{self.track}" in wanted + def build_template_context(self, media_info: MediaInfo, show_service: bool = True) -> dict: """Build template context dictionary from MediaInfo.""" context = self.build_base_template_context(media_info, show_service) @@ -262,10 +268,11 @@ class Music(SortedKeyList, ABC): year = self.year or first_song.year or "?" return f"{artist} - {title} ({year})" - def tree(self, verbose: bool = False) -> Any: + def tree(self, verbose: bool = False) -> RenderableType: + # verbose is the Titles_T contract; a release always renders its full tracklist from unshackle.core.music.renderer import MusicRenderer - return MusicRenderer().render(self, verbose=verbose) + return MusicRenderer().render(self) class Album(Music): diff --git a/unshackle/core/titles/song.py b/unshackle/core/titles/song.py index 8a7766b9..724983ea 100644 --- a/unshackle/core/titles/song.py +++ b/unshackle/core/titles/song.py @@ -1,137 +1,5 @@ -import re -from abc import ABC -from typing import Any, Iterable, Optional, Union - -from langcodes import Language -from pymediainfo import MediaInfo -from rich.tree import Tree -from sortedcontainers import SortedKeyList - -from unshackle.core.config import config -from unshackle.core.titles.title import Title -from unshackle.core.utilities import sanitize_filename -from unshackle.core.utils.template_formatter import TemplateFormatter, detect_spacer - - -class Song(Title): - def __init__( - self, - id_: Any, - service: type, - name: str, - artist: str, - album: str, - track: int, - disc: int, - year: int, - language: Optional[Union[str, Language]] = None, - data: Optional[Any] = None, - ) -> None: - super().__init__(id_, service, language, data) - - if not name: - raise ValueError("Song name must be provided") - if not isinstance(name, str): - raise TypeError(f"Expected name to be a str, not {name!r}") - - if not artist: - raise ValueError("Song artist must be provided") - if not isinstance(artist, str): - raise TypeError(f"Expected artist to be a str, not {artist!r}") - - if not album: - raise ValueError("Song album must be provided") - if not isinstance(album, str): - raise TypeError(f"Expected album to be a str, not {album!r}") - - if not track: - raise ValueError("Song track must be provided") - if not isinstance(track, int): - raise TypeError(f"Expected track to be an int, not {track!r}") - - if not disc: - raise ValueError("Song disc must be provided") - if not isinstance(disc, int): - raise TypeError(f"Expected disc to be an int, not {disc!r}") - - if not year: - raise ValueError("Song year must be provided") - if not isinstance(year, int): - raise TypeError(f"Expected year to be an int, not {year!r}") - - name = name.strip() - artist = artist.strip() - album = album.strip() - - if track <= 0: - raise ValueError(f"Song track cannot be {track}") - if disc <= 0: - raise ValueError(f"Song disc cannot be {disc}") - if year <= 0: - raise ValueError(f"Song year cannot be {year}") - - self.name = name - self.artist = artist - self.album = album - self.track = track - self.disc = disc - self.year = year - - def __str__(self) -> str: - return "{artist} - {album} ({year}) / {track:02}. {name}".format( - artist=self.artist, album=self.album, year=self.year, track=self.track, name=self.name - ).strip() - - def build_template_context(self, media_info: MediaInfo, show_service: bool = True) -> dict: - """Build template context dictionary from MediaInfo.""" - context = self.build_base_template_context(media_info, show_service) - context["title"] = self.name.replace("$", "S") - context["year"] = self.year or "" - context["track_number"] = f"{self.track:02}" - context["artist"] = self.artist.replace("$", "S") - context["album"] = self.album.replace("$", "S") - context["disc"] = f"{self.disc:02}" if self.disc > 1 else "" - return context - - def get_filename(self, media_info: MediaInfo, folder: bool = False, show_service: bool = True) -> str: - if folder: - template = config.get_folder_template("songs") - if template: - context = self.build_template_context(media_info, show_service) - spacer = detect_spacer(template) # one style for the whole path - segments = [ - TemplateFormatter(seg, spacer).format(context) - for seg in re.split(r"[\\/]", template) - if seg.strip() - ] - return "/".join(s for s in segments if s) - name = f"{self.artist} - {self.album}" - if self.year: - name += f" ({self.year})" - return sanitize_filename(name, " ") - - formatter = TemplateFormatter(config.output_template["songs"]) - context = self.build_template_context(media_info, show_service) - return formatter.format(context) - - -class Album(SortedKeyList, ABC): - def __init__(self, iterable: Optional[Iterable] = None): - super().__init__(iterable, key=lambda x: (x.album, x.disc, x.track, x.year or 0)) - - def __str__(self) -> str: - if not self: - return super().__str__() - return f"{self[0].artist} - {self[0].album} ({self[0].year or '?'})" - - def tree(self, verbose: bool = False) -> Tree: - num_songs = len(self) - tree = Tree(f"{num_songs} Song{['s', ''][num_songs == 1]}", guide_style="bright_black") - if verbose: - for song in self: - tree.add(f"[bold]Track {song.track:02}.[/] [bright_black]({song.name})", guide_style="bright_black") - - return tree +"""Deprecated. Import from unshackle.core.titles.music instead.""" +from unshackle.core.titles.music import Album, Song # noqa: F401 __all__ = ("Song", "Album") diff --git a/unshackle/core/tracks/audio.py b/unshackle/core/tracks/audio.py index 115de576..ec35c040 100644 --- a/unshackle/core/tracks/audio.py +++ b/unshackle/core/tracks/audio.py @@ -86,7 +86,7 @@ class Audio(Track): codec: An Audio.Codec enum representing the audio codec. If not specified, MediaInfo will be used to retrieve the codec once the track has been downloaded. - bitrate: A number or float representing the average bandwidth in bytes/s. + bitrate: A number or float representing the average bandwidth in bits/s. Float values are rounded up to the nearest integer. channels: A number, float, or string representing the number of audio channels. Strings may represent numbers or floats. Expanded layouts like 7.1.1 is diff --git a/unshackle/core/utils/click_types.py b/unshackle/core/utils/click_types.py index d8a7e3ed..939ceb33 100644 --- a/unshackle/core/utils/click_types.py +++ b/unshackle/core/utils/click_types.py @@ -164,6 +164,11 @@ class SeasonRange(click.ParamType): MAX_DATE_SPAN = 1000 DATE_TOKEN = re.compile(r"^(?P\d{4}-\d{2}-\d{2})(:(?P\d{4}-\d{2}-\d{2}))?$") + TOKEN = re.compile( + r"^(?:S(?P\d+)(E(?P\d+)(\.(?P\d+))?)?" + r"|((?P\d+)x)?(?P\d+))$", + re.IGNORECASE, + ) def parse_date_token(self, token: str, match: re.Match) -> list[str]: """Expand an ISO date token or ':'-separated date range into ISO day keys.""" @@ -179,6 +184,25 @@ class SeasonRange(click.ParamType): self.fail(f"Invalid range, a date range cannot span more than {self.MAX_DATE_SPAN} days: {token}") return [(left + timedelta(days=i)).isoformat() for i in range(span)] + def token_sides(self, match: re.Match) -> tuple[int, Optional[int], Optional[int]]: + """Read one side of a token as (season, episode, part), episode/part None when absent. + + A music disc x track token shares this key space, disc reading as the season and + track as the episode. An omitted disc is disc 1, not every disc: a bare number is + the number the tracklist shows for a single-disc release, and widening it would + silently add tracks on a multi-disc release. + """ + if match.group("season") is not None: + episode = match.group("episode") + part = match.group("part") + return ( + int(match.group("season")), + int(episode) if episode is not None else None, + int(part) if part is not None else None, + ) + disc = match.group("disc") + return int(disc) if disc is not None else 1, int(match.group("track")), None + def parse_tokens(self, *tokens: str) -> list[str]: """ Parse multiple tokens or ranged tokens as '{s}x{e}' strings. @@ -191,6 +215,10 @@ class SeasonRange(click.ParamType): Dated content is addressed by ISO air date. A date range uses ':' only, because a date's own '-' separators are not a range separator. + A music release uses the same keys, its disc reading as the season and its track + as the episode. A track is addressed as '{d}x{t}', or by its number alone, which + is a track on disc 1. + Supports exclusioning by putting a `-` before the token. Example: @@ -209,6 +237,10 @@ class SeasonRange(click.ParamType): ["2026-08-11"] >>> sr.parse_tokens("2026-08-01:2026-08-03", "-2026-08-02") ["2026-08-01", "2026-08-03"] + >>> sr.parse_tokens("3") + ["1x3"] + >>> sr.parse_tokens("1-3", "2x1") + ["1x1", "1x2", "1x3", "2x1"] """ if len(tokens) == 0: return [] @@ -223,10 +255,7 @@ class SeasonRange(click.ParamType): if date_match: (computed if not exclude else exclusions).extend(self.parse_date_token(token, date_match)) continue - parsed = [ - re.match(r"^S(?P\d+)(E(?P\d+)(\.(?P\d+))?)?$", x, re.IGNORECASE) - for x in re.split(r"[:-]", token) - ] + parsed = [self.TOKEN.match(x) for x in re.split(r"[:-]", token)] if len(parsed) > 2: self.fail(f"Invalid token, only a left and right range is acceptable: {token}") if len(parsed) == 1: @@ -235,17 +264,10 @@ class SeasonRange(click.ParamType): parsed.append(parsed[0]) if any(x is None for x in parsed): self.fail(f"Invalid token, syntax error occurred: {token}") - left, right = parsed[0], parsed[1] - from_season = int(left.group("season")) # type: ignore[union-attr] - from_episode_raw = left.group("episode") # type: ignore[union-attr] - from_episode = int(from_episode_raw) if from_episode_raw is not None else self.MIN_EPISODE - from_part_raw = left.group("part") # type: ignore[union-attr] - from_part = int(from_part_raw) if from_part_raw is not None else None - to_season = int(right.group("season")) # type: ignore[union-attr] - to_episode_raw = right.group("episode") # type: ignore[union-attr] - to_episode = int(to_episode_raw) if to_episode_raw is not None else self.MAX_EPISODE - to_part_raw = right.group("part") # type: ignore[union-attr] - to_part = int(to_part_raw) if to_part_raw is not None else None + from_season, from_episode_raw, from_part = self.token_sides(parsed[0]) # type: ignore[arg-type] + from_episode = from_episode_raw if from_episode_raw is not None else self.MIN_EPISODE + to_season, to_episode_raw, to_part = self.token_sides(parsed[1]) # type: ignore[arg-type] + to_episode = to_episode_raw if to_episode_raw is not None else self.MAX_EPISODE if from_season > to_season: self.fail(f"Invalid range, left side season cannot be bigger than right side season: {token}") if from_season == to_season and from_episode > to_episode: diff --git a/unshackle/core/utils/post_scripts.py b/unshackle/core/utils/post_scripts.py index 47ce5cc9..ffb4d369 100644 --- a/unshackle/core/utils/post_scripts.py +++ b/unshackle/core/utils/post_scripts.py @@ -138,6 +138,19 @@ def build_context( return context +def season_context(context: dict[str, str], folder: Path) -> dict[str, str]: + """Blank the file-level variables so a season/album hook describes the folder.""" + out = dict(context) + for key in ("filepath", "filename", "ext", "sidecars", "episode", "episode_name"): + out[key] = "" + if out.get("album"): # music: the album stands in for the season + for key in ("track_number", "disc", "isrc"): + if key in out: + out[key] = "" + out["title"] = out["title_raw"] = out["album"] + return out | {"folder": str(folder)} + + def _entries(event: str, mode: str, overrides: Sequence[str] = ()) -> Iterator[str]: """Commands configured for this event and mode; --postscript replaces the config list.""" if overrides: diff --git a/unshackle/core/utils/tags.py b/unshackle/core/utils/tags.py index 79b206a1..93ba2d1f 100644 --- a/unshackle/core/utils/tags.py +++ b/unshackle/core/utils/tags.py @@ -5,11 +5,12 @@ import subprocess import tempfile import time from pathlib import Path -from typing import Optional, Union +from typing import Any, Optional, Union from xml.sax.saxutils import escape from unshackle.core import binaries from unshackle.core.config import config +from unshackle.core.music.tagger import write_music_metadata from unshackle.core.providers import ( ExternalIds, fuzzy_match, @@ -18,6 +19,7 @@ from unshackle.core.providers import ( ) from unshackle.core.titles.episode import Episode from unshackle.core.titles.movie import Movie +from unshackle.core.titles.music import Song from unshackle.core.titles.title import Title from unshackle.core.utils.subprocess import log_tool_run @@ -87,8 +89,20 @@ def tag_file( tvdb_id: Optional[int] = None, anilist_id: Optional[Union[int, str]] = None, anime: bool = False, + session: Any = None, ) -> None: log.debug("Tagging file %s with title %r", path, title) + + if isinstance(title, Song): + try: + music_result = write_music_metadata(path, title, session=session) + except Exception as e: # the file already moved; a tagging fault must warn, not abort the run + log.warning("Music tagging failed for %s: %s", path.name, e) + return + if music_result.skipped and music_result.reason: + log.warning("Music metadata skipped for %s: %s", path.name, music_result.reason) + return + custom_tags: dict[str, str] = {} if config.tag and config.tag_group_name: diff --git a/unshackle/services/EXAMPLE/__init__.py b/unshackle/services/EXAMPLE/__init__.py index e283c5e4..5e9ead8b 100644 --- a/unshackle/services/EXAMPLE/__init__.py +++ b/unshackle/services/EXAMPLE/__init__.py @@ -18,7 +18,7 @@ from unshackle.core.credential import Credential from unshackle.core.manifests import DASH # also: HLS, ISM - see get_tracks() alternates from unshackle.core.search_result import SearchResult from unshackle.core.service import Service -from unshackle.core.titles import Album, Episode, Movie, Movies, Series, Song, Title_T, Titles_T +from unshackle.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T from unshackle.core.tracks import Attachment, Chapter, Chapters, Subtitle, Tracks, Video from unshackle.core.utilities import is_close_match @@ -28,7 +28,8 @@ class EXAMPLE(Service): Reference service for domain.com - a deliberately exhaustive showcase of EVERYTHING an unshackle service can touch. It is NOT meant to run against a real API; it exists so a new service author can see one canonical example of - every framework feature in one place. + every framework feature in one place. Music is the one exception: it has its + own reference service, MUSIC_EXAMPLE. Version: 2.0.0 Author: sp4rk.y @@ -53,7 +54,7 @@ class EXAMPLE(Service): __init__ TrackRequest read/override, CDM-aware codec gating authenticate cookies AND credentials, JWT decode, token cache+refresh search SearchResult generator - get_titles Movies / Series / Album (music) + data passthrough + get_titles Movies / Series + data passthrough get_tracks DASH variant fan-out (default) + HLS/ISM alternates fetch_dash_manifest range override, HDR10+ flip, DV-composite, Atmos, descriptive audio, channel fixups, cover-art attachment, @@ -229,26 +230,6 @@ class EXAMPLE(Service): # This drives `-l best/all` original-audio selection and the filename language token. original_lang = Language.find(metadata["languages"][0]) - # MUSIC - Album of Song titles. Showcases the music branch of the title system. - if program_type == "album": - return Album( - [ - Song( - id_=tr["id"], - service=self.__class__, - name=tr["title"], - artist=metadata["artist"], - album=metadata["title"], - track=tr["trackNumber"], - disc=tr.get("discNumber", 1), - year=metadata["releaseYear"], - language=original_lang, - data=tr, - ) - for tr in metadata["tracks"] - ] - ) - # MOVIE if self.movie or program_type == "movie": return Movies( @@ -497,7 +478,7 @@ class EXAMPLE(Service): self, *, challenge: bytes, title: Title_T, track: AnyTrack ) -> Optional[Union[bytes, str, dict]]: # DASH org.w3.clearkey: `challenge` is the W3C JSON license request; return the - # JWK Set response. Omit this method entirely when the manifest carries a Laurl — + # JWK Set response. Omit this method entirely when the manifest carries a Laurl: # the framework then POSTs the challenge there with no service code at all. license_url = self.config["endpoints"].get("clearkey_license") if not license_url: diff --git a/unshackle/services/EXAMPLE/config.yaml b/unshackle/services/EXAMPLE/config.yaml index c9b824f8..c0fa8735 100644 --- a/unshackle/services/EXAMPLE/config.yaml +++ b/unshackle/services/EXAMPLE/config.yaml @@ -1,5 +1,5 @@ # Reference config for the EXAMPLE showcase service. -# Access in code as self.config["endpoints"]["..."] — never hardcode URLs. +# Access in code as self.config["endpoints"]["..."]; never hardcode URLs in code. endpoints: login: https://api.domain.com/v1/login