228 lines
6.1 KiB
Markdown
228 lines
6.1 KiB
Markdown
# `src/core/series.py`
|
|
|
|
Series naming + scanning for the Jellyfin TV layout.
|
|
|
|
Jellyfin wants series laid out as::
|
|
|
|
Seriály/Series (year) [tmdbid-N]/
|
|
tvshow.nfo (Jellyfin writes it)
|
|
Season 01/
|
|
Series (year) S01E01.ext
|
|
Specials/ (season 0)
|
|
|
|
Episodes are matched by `SxxEyy`. These helpers parse the episode number out of
|
|
a raw filename, build the canonical episode/season/series names, and scan a
|
|
series library into a `Series → Season → Episode` tree with a naming status.
|
|
|
|
Source: [`src/core/series.py`](../../src/core/series.py)
|
|
|
|
## Classes
|
|
|
|
### `Episode`
|
|
|
|
*class · line 49*
|
|
|
|
```python
|
|
class Episode:
|
|
```
|
|
|
|
One episode file found in a series folder: its path, its season and episode numbers (`None` when not recognised) and its part number when the episode is split across several files.
|
|
|
|
#### `Episode.code`
|
|
|
|
*property · line 58*
|
|
|
|
```python
|
|
code -> str
|
|
```
|
|
|
|
Human-readable episode code, e.g. `S01E02`, with `.ptN` appended for a part (`S01E02.pt3`). `?` when the season or episode is unknown.
|
|
|
|
### `Series`
|
|
|
|
*class · line 66*
|
|
|
|
```python
|
|
class Series:
|
|
```
|
|
|
|
One series folder with its title, year, TMDb id, library and the episodes found below it.
|
|
|
|
#### `Series.seasons`
|
|
|
|
*method · line 74*
|
|
|
|
```python
|
|
seasons() -> dict[int, list[Episode]]
|
|
```
|
|
|
|
Episodes grouped by season number (`-1` = unrecognised).
|
|
|
|
## Functions
|
|
|
|
### `parse_episode`
|
|
|
|
*function · line 84*
|
|
|
|
```python
|
|
parse_episode(name: str) -> Optional[tuple[int, int]]
|
|
```
|
|
|
|
Extract `(season, episode)` from a filename, or `None`.
|
|
|
|
### `parse_episode_part`
|
|
|
|
*function · line 94*
|
|
|
|
```python
|
|
parse_episode_part(name: str) -> Optional[int]
|
|
```
|
|
|
|
Part number implied by a Jellyfin stacking suffix in a filename, or `None`.
|
|
|
|
Looks for *cd*, *dvd*, *part*, *pt*, *disc* or *disk* + a number sitting
|
|
right before the extension (`Show S01E02.pt2.mkv`,
|
|
`Show S01E02-part-2.mkv`, …) — the same tokens Jellyfin itself scans for.
|
|
|
|
### `parse_episode_code`
|
|
|
|
*function · line 105*
|
|
|
|
```python
|
|
parse_episode_code(text: str) -> Optional[tuple[int, int, Optional[int]]]
|
|
```
|
|
|
|
Parse a compact user-typed code: `SxxEyy`, optionally + a part.
|
|
|
|
Accepts everything `parse_episode` does, plus a trailing part marker
|
|
right after it — `S01E02P3`, `S01E02 pt3`, `S01E02.part.3`,
|
|
`S01E02 cd2` — for a file that is only *one part* of that episode.
|
|
Returns `(season, episode, part)` with `part` `None` when absent, or
|
|
`None` when no `SxxEyy` is found at all.
|
|
|
|
### `collect_episode_files`
|
|
|
|
*function · line 126*
|
|
|
|
```python
|
|
collect_episode_files(folder: Path) -> list[Path]
|
|
```
|
|
|
|
Every video file under `folder` (recursively), sidecars excluded, sorted.
|
|
|
|
Lets the episode-import dialog take a whole `Series/Season NN/` tree (or any
|
|
nested layout) in one pick instead of file-by-file. Season/episode numbers
|
|
are still read per file from its name by `parse_episode`.
|
|
|
|
### `season_folder_name`
|
|
|
|
*function · line 142*
|
|
|
|
```python
|
|
season_folder_name(season: int) -> str
|
|
```
|
|
|
|
Folder name for a season — `Specials` for 0, else `Season NN`.
|
|
|
|
### `is_season_folder`
|
|
|
|
*function · line 147*
|
|
|
|
```python
|
|
is_season_folder(name: str) -> bool
|
|
```
|
|
|
|
True for a season folder name — `Specials` or `Season <n>` (any padding).
|
|
|
|
### `looks_like_series_folder`
|
|
|
|
*function · line 152*
|
|
|
|
```python
|
|
looks_like_series_folder(folder: Path) -> bool
|
|
```
|
|
|
|
True when `folder` holds at least one `Season NN`/`Specials` subfolder.
|
|
|
|
That's the shape Curator always gives a series (episodes always land in a
|
|
season folder, never loose at the series root) — a movie's version folder
|
|
never has one, its files sit flat inside it. This is how a `mixed`
|
|
library tells the two apart even though both use the identical outer
|
|
`Title (rok) [tmdbid]/` name: `FileManager.is_movie_group_member`
|
|
and `scan_series` both check it before deciding which one a
|
|
canonical-shaped subfolder is.
|
|
|
|
### `sanitize_title`
|
|
|
|
*function · line 173*
|
|
|
|
```python
|
|
sanitize_title(title: str) -> str
|
|
```
|
|
|
|
A series/episode title made safe to sit in a filename (spaces collapsed).
|
|
|
|
### `episode_filename`
|
|
|
|
*function · line 179*
|
|
|
|
```python
|
|
episode_filename(season: int, episode: int, series_title: str, ext: str, *, part: Optional[int]=None) -> str
|
|
```
|
|
|
|
Canonical episode filename `SxxEyy - {series title}.ext`.
|
|
|
|
The release string is deliberately **not** kept — identity is the `SxxEyy`
|
|
code, and Jellyfin fetches the episode title/metadata from TMDb (matched by
|
|
the code + the series folder's `[tmdbid-N]`). An empty title collapses to
|
|
`SxxEyy.ext`. `part` marks one physical part of that episode — an old
|
|
recording split across several files — with Jellyfin's own `.ptN`
|
|
stacking suffix right before the extension.
|
|
|
|
### `series_folder_stem`
|
|
|
|
*function · line 200*
|
|
|
|
```python
|
|
series_folder_stem(title: str, year: object=None, tmdb_id: object=None) -> str
|
|
```
|
|
|
|
Series folder name: `Title (year) [tmdbid-N]` (year/id optional).
|
|
|
|
Reuses the movie convention when a year is present; otherwise falls back to
|
|
`Title [tmdbid-N]` or just `Title` so a year-less series still works.
|
|
|
|
### `is_canonical_episode`
|
|
|
|
*function · line 216*
|
|
|
|
```python
|
|
is_canonical_episode(path: Path, series_title: str) -> bool
|
|
```
|
|
|
|
True when `path` is already `SxxEyy - {series title}.ext` in its season.
|
|
|
|
### `scan_series`
|
|
|
|
*function · line 229*
|
|
|
|
```python
|
|
scan_series(lib_dir: Path, library: str, *, skip_movie_folders: bool=False) -> list[Series]
|
|
```
|
|
|
|
Scan one series library into a list of `Series`.
|
|
|
|
Each immediate subfolder of `lib_dir` is a series; its name yields the
|
|
title/year/tmdbid. Video files anywhere below it are episodes (season/episode
|
|
parsed from the filename). Non-video sidecars are skipped.
|
|
|
|
`skip_movie_folders=True` (a `mixed` library, where a movie's version
|
|
folder and a series folder both use the identical
|
|
`Title (rok) [tmdbid]/` shape) additionally skips a canonical-shaped
|
|
folder with no `Season NN`/`Specials` inside it — that's a movie's
|
|
version folder instead, left for `FileManager.load_pool_movies` to
|
|
read. Left `False` (the default, a plain `series`-kind library, which
|
|
never holds movies) every subfolder is read as a series regardless of
|
|
shape — a raw, not-yet-renamed import may have loose episode files
|
|
sitting at the series root, with no season folder yet.
|