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