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`