# `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 ` (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.