feat(titles): add optional Episode.part, selected with -w s1e1.2

Opt-in and additive: an episode without a part behaves exactly as before.
This commit is contained in:
imSp4rky
2026-08-04 17:38:30 -06:00
parent 10bfaf6a73
commit 041ba5293b
17 changed files with 946 additions and 44 deletions
+51 -2
View File
@@ -304,11 +304,14 @@ Common constructor fields:
```python
Episode(id_, service, title, season, number, name=None, year=None,
language=None, data=None, description=None, air_date=None)
language=None, data=None, description=None, air_date=None,
part=None)
```
Pass `air_date` for daily/sports content to name by date instead of
`SxxExx`. Return a `Series([...])` of episodes.
`SxxExx`. Return a `Series([...])` of episodes. Pass `part` only when the
service splits one episode into several videos, see
[Split episodes](#split-episodes).
=== "Song"
@@ -328,6 +331,52 @@ Common constructor fields:
framework path to reuse cached results and gracefully fall back to stale data
when the API errors. It also applies the per-service `title_map` config.
#### Split episodes {#split-episodes}
Some services split one logical episode into several separately playable videos, for
example a variety show that ships episode 1 as *Episode 1 (Part 1)*, *(Part 2)* and
*(Part 3)*. `Episode.number` stays an `int`. The optional `part` carries the index.
```python
Episode(..., season=1, number=1, part=1, name="The Reckoning")
Episode(..., season=1, number=1, part=2, name="The Reckoning")
```
1. `part` counts from 1, contiguous. `part=0` is rejected, and `-w` can only address
parts up to 99.
2. Every part of one episode shares the same `season` and `number`.
3. Each part is a separate `Episode` with its own unique `id_`.
4. Unsplit episode: leave `part` unset. Never pass `part=1` for a whole episode.
5. Do not put the part in `name` or `number`. Pass a clean `name` (or `None`) and set
`part`.
6. `part` is a split *episode*, not a split *season*. A service listing *Season 1 Part 2*
describes a group of episodes. Keep numbering those in sequence, the way the shipped
services do.
`part` accepts an `int` or a digit string. A value that is not an integer raises
`TypeError`. A value of `0` or less raises `ValueError`.
!!! warning "The framework never infers a part from the episode name"
Rule 6 is the reason. Shipped services already use the word "Part" to mean a *season*
part, so name-sniffing would misread every one of them. The part has to arrive
structurally, as the `part` argument.
!!! tip "Clean the part out of the name"
`Episode` discards any `name` that starts with `Episode <number>`, so a raw
`"Episode 1 (Part 2): The Reckoning"` leaves you with no episode name at all. Strip
the prefix before you pass it, or pass `None`.
Once `part` is set, the filename gains a part token (`S01E01.Part.2`, see
[Output & naming](../guide/output-and-naming.md#split-episodes)), `-w S01E01.2` selects a
single part while a bare `-w S01E01` selects them all, and the `--list-titles` tree labels
the episode `01.2`. The REST API reports it as an extra `part` key on the serialized
title, described in [Endpoints](rest-api/endpoints.md#post-apilist-titles).
!!! note "Opt-in and additive"
Leaving `part` unset costs you nothing: filenames, sort order, `-w` matching and the
API JSON are identical to a plain episode, so a service that does not split episodes
has nothing to adopt.
### `get_tracks`: required
Given one title, return a `Tracks` object holding `Video`, `Audio`, and
+9 -2
View File
@@ -223,6 +223,12 @@ 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`.
!!! info "`part` on a split episode"
A service that splits one episode into several separately playable videos reports each
one as its own episode. Those titles share a `season` and `number` and add a `part`
integer counting from 1. No other title carries the key, so a client that ignores it
is unaffected. See [Split episodes](../creating-a-service.md#split-episodes).
| Status | Error code | Meaning |
| --- | --- | --- |
| `200` | - | Titles returned. |
@@ -248,9 +254,10 @@ List the video, audio, and subtitle tracks for a title. For series, you can scop
| `wanted` | string | no | Episode/season range (e.g. `"S01E01-S01E03"`). |
| `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`). |
| `profile`, `proxy`, `no_proxy`, `cdm_type` | - | no | As on `list-titles`. |
When both `season` and `episode` are given, they are combined into a `"{season}x{episode}"` selector.
When both `season` and `episode` are given, they are combined into a `"{season}x{episode}"` selector. Adding `part` narrows that to `"{season}x{episode}.{part}"`, one part of a [split episode](../creating-a-service.md#split-episodes); without it you get every part of the episode. `part` is read only when `season` and `episode` are both given, and `wanted` takes precedence over all three. To scope a part by range, put it in `wanted` instead (`"S01E01.2"`).
=== "Single title / movie: Response `200`"
@@ -361,7 +368,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. |
| `wanted` | string[] | `[]` | Episode/season selectors. Accepts the part form, `"S01E01.2"`. |
| `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. |