mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-14 04:55:07 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
||||
@@ -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
@@ -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. |
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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") == "."
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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}[/]!",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(".")
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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],
|
||||
*,
|
||||
|
||||
@@ -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",)
|
||||
@@ -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")
|
||||
@@ -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",)
|
||||
@@ -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"
|
||||
@@ -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",)
|
||||
@@ -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, ""):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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
|
||||
|
||||
Reference in New Issue
Block a user