Migrate from Poetry and pyenv to uv

This commit is contained in:
2026-10-12 15:48:26 +02:00
parent c399a6aed3
commit c69f5f9166
93 changed files with 13068 additions and 2401 deletions
+227
View File
@@ -0,0 +1,227 @@
# `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.