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.
This commit is contained in:
imSp4rky
2026-08-18 15:05:30 -06:00
parent a4a385e5aa
commit 68c7d95173
43 changed files with 1082 additions and 2132 deletions
+20 -9
View File
@@ -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.
+9 -2
View File
@@ -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. |
+1 -1
View File
@@ -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 |
+41 -6
View File
@@ -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_<id>.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`
+1 -1
View File
@@ -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. |
+1 -1
View File
@@ -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. |
+80 -10
View File
@@ -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 "<command>"` 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. |
+42 -4
View File
@@ -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
<output directory> / <folder template> / <filename template><extension>
@@ -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:
+18 -6
View File
@@ -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
-213
View File
@@ -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
-14
View File
@@ -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(
+40 -6
View File
@@ -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") == "."
+198
View File
@@ -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"
+126
View File
@@ -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
+120
View File
@@ -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]
+2 -3
View File
@@ -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):
+121 -88
View File
@@ -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__":
+37 -601
View File
@@ -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.<TAG>.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}[/]!",
+4 -2
View File
@@ -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:
+13 -4
View File
@@ -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,
+2 -2
View File
@@ -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)
+1 -1
View File
@@ -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(".")
+8
View File
@@ -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",
)
-23
View File
@@ -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",
)
-174
View File
@@ -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
+5 -26
View File
@@ -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],
*,
-16
View File
@@ -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",)
-189
View File
@@ -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")
-23
View File
@@ -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",)
-58
View File
@@ -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"
-214
View File
@@ -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<bits>\d+)\s*[- ]?bit", value.lower())
if bit_match:
bit_depth = int(bit_match.group("bits"))
sample_match = re.search(r"(?P<rate>\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<rate>\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",)
+39 -227
View File
@@ -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, ""):
+56 -18
View File
@@ -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:
+2 -1
View File
@@ -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())
+2 -1
View File
@@ -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:
+19 -12
View File
@@ -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):
+2 -134
View File
@@ -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")
+1 -1
View File
@@ -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
+37 -15
View File
@@ -164,6 +164,11 @@ class SeasonRange(click.ParamType):
MAX_DATE_SPAN = 1000
DATE_TOKEN = re.compile(r"^(?P<left>\d{4}-\d{2}-\d{2})(:(?P<right>\d{4}-\d{2}-\d{2}))?$")
TOKEN = re.compile(
r"^(?:S(?P<season>\d+)(E(?P<episode>\d+)(\.(?P<part>\d+))?)?"
r"|((?P<disc>\d+)x)?(?P<track>\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<season>\d+)(E(?P<episode>\d+)(\.(?P<part>\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:
+13
View File
@@ -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:
+15 -1
View File
@@ -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:
+5 -24
View File
@@ -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:
+1 -1
View File
@@ -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