# `src/core/file_manager.py` `FileManager`: the application's service layer. It owns the list of pooled movies (`filelist`), the pool index, the library configuration and the global config, and implements every operation the GUI triggers — import, rename/move/edit, multi-version grouping, duplicate detection, series import/rename/removal, miniDLNA/Filmotéka scope, ČSFD/TMDb lookups and the consistency checks. The GUI never touches the filesystem directly. Source: [`src/core/file_manager.py`](../../src/core/file_manager.py) ## Classes ### `FileManager` *class · line 71* ```python class FileManager: ``` The service layer behind the GUI: pool and library configuration, the movie list, and every file operation. See the method reference below and `docs/architecture.md`. #### `FileManager.__init__` *method · line 72* ```python __init__(tagmanager: TagManager) ``` Load the global config and prepare an empty file list. The pool index is opened lazily once a pool directory is known. #### `FileManager.pool_dir` *property · line 84* ```python pool_dir -> Path | None ``` The pool folder (`pool_dir` from the global config), or `None` if not set. #### `FileManager.movies_dir` *property · line 89* ```python movies_dir -> Path | None ``` The `Filmy` folder inside the pool (the default movie library), or `None`. #### `FileManager.series_dir` *property · line 94* ```python series_dir -> Path | None ``` The `Seriály` folder inside the pool (the default series library), or `None`. #### `FileManager._open_index` *method · line 102* ```python _open_index(pool: Path) -> PoolIndex ``` (Re)load the pool index and migrate any legacy global settings. #### `FileManager._ensure_index` *method · line 108* ```python _ensure_index() -> PoolIndex | None ``` Return the pool index, opening it lazily when a pool is configured. #### `FileManager._migrate_global_settings` *method · line 114* ```python _migrate_global_settings() -> None ``` Move library settings from the old global config into the index once. Earlier versions stored `tag_schema` / `copyasis_folders` in the global `.!gtag`. They now belong to the pool; seed the index from the old values (if present and not already migrated) and drop them from the global config so it carries app data only. #### `FileManager._get_setting` *method · line 134* ```python _get_setting(key: str) ``` Read a library setting from the index (falls back to the default). #### `FileManager._set_setting` *method · line 142* ```python _set_setting(key: str, value) -> None ``` Persist a library setting into the index (needs a configured pool). #### `FileManager.libraries` *property · line 150* ```python libraries -> list[dict] ``` All pool libraries as `[{"name", "kind"}, …]` (source of truth). For pools predating the unified model this is synthesised from the legacy `movie_libraries` (→ kind `movie`) + `copyasis_folders` (→ `series`). #### `FileManager.set_libraries` *method · line 173* ```python set_libraries(specs: list[dict]) -> None ``` Replace the library list (list of `{"name", "kind"}`) and persist it. #### `FileManager.libraries_of_kind` *method · line 185* ```python libraries_of_kind(*kinds: str) -> list[str] ``` Names of libraries matching any of `kinds`. #### `FileManager.minidlna_libraries` *property · line 190* ```python minidlna_libraries -> list[str] | None ``` Libraries included in miniDLNA generation, or `None` meaning all of them. Stored in the pool index; names that no longer exist as libraries are dropped when read. #### `FileManager.set_minidlna_libraries` *method · line 205* ```python set_minidlna_libraries(names: list[str] | None) -> None ``` Restrict miniDLNA generation to `names`; `None` resets to "all". #### `FileManager._set_setting_raw` *method · line 210* ```python _set_setting_raw(key: str, value) -> None ``` Persist an arbitrary pool-index setting (no legacy-default fallback). #### `FileManager.minidlna_scope` *method · line 217* ```python minidlna_scope() -> tuple[list[File], list[str], list] ``` Return what miniDLNA generation should include as `(movies, mirrored library names, mixed-library series)`. Narrowed to `minidlna_libraries` when that is set, otherwise the whole pool. Libraries left out here have their generated links swept as obsolete on the next run. #### `FileManager.minidlna_prune_targets` *method · line 254* ```python minidlna_prune_targets(mirror_libs: list[str], mixed_series: list, mixed_series_bucket: str) -> tuple[list[str], set[str]] ``` What miniDLNA output to remove for mirrors that fell out of scope. `mirror_libs` / `mixed_series` come from `minidlna_scope` (the *current* run's selection); `mixed_series_bucket` is the shared output folder mixed-library series hardcopy into (e.g. `"Seriály"`) — the same name a real `series`/`homevideo` library may also use, so the two sources are reconciled together rather than one blindly wiping the other. Returns `(stale_libraries, valid_bucket_children)`: - `stale_libraries` — mirrored-library output folders (`output/`) to delete **entirely**: every `mirror_as_is_libraries` name no longer in `mirror_libs`, except one that shares the bucket's name (reconciled via the second element instead). - `valid_bucket_children` — basenames that must remain directly under `output/` this run: the in-scope mixed-library series' own folder names, plus — when a real library named `mixed_series_bucket` is itself in scope — that library's current subfolders. Anything else found there is stale. #### `FileManager.mirror_as_is_libraries` *property · line 290* ```python mirror_as_is_libraries -> list[str] ``` Libraries hardcopied 1:1 into miniDLNA — `series` and `homevideo`. Neither has genre tags to build a tree from (homevideo has no TMDb id either), so both mirror whole into `output/` instead of going through the tag tree. A superset of `copyasis_folders` (which the library organizer UI still calls "copy-as-is" and keeps scoped to kind `series` only). #### `FileManager.copyasis_folders` *property · line 302* ```python copyasis_folders -> list[str] ``` Names of pool subfolders mirrored 1:1 (copy-as-is) — the series libraries. #### `FileManager.set_copyasis_folders` *method · line 306* ```python set_copyasis_folders(names: list[str]) -> None ``` Set the series (copy-as-is) libraries, keeping the other kinds. #### `FileManager.movie_libraries` *property · line 313* ```python movie_libraries -> list[str] ``` Pool folders holding single-file movies (kind `movie`). #### `FileManager.libraries_accepting_movies` *property · line 318* ```python libraries_accepting_movies -> list[str] ``` Libraries a movie can live in / be assigned to — kind movie or mixed. #### `FileManager.set_movie_libraries` *method · line 322* ```python set_movie_libraries(names: list[str]) -> None ``` Set the movie libraries, keeping the other kinds; never empty. #### `FileManager.orphan_pool_folders` *method · line 328* ```python orphan_pool_folders() -> list[tuple[str, bool]] ``` Top-level pool folders that aren't a configured library. Returns `(name, is_empty)` pairs. Only these are candidates for cleanup; non-empty ones are reported but never auto-deleted (they may hold media). #### `FileManager.remove_empty_pool_folder` *method · line 344* ```python remove_empty_pool_folder(name: str) -> bool ``` Delete an **empty** orphan folder in the pool root. Returns True if removed. #### `FileManager.library_of` *method · line 356* ```python library_of(file_obj: File) -> str | None ``` Which library (top-level pool folder) a pooled movie lives in. #### `FileManager.tag_schema` *property · line 367* ```python tag_schema -> list[dict] ``` Tag categories + ČSFD/Filmotéka rules (see config.DEFAULT_TAG_SCHEMA). #### `FileManager.set_tag_schema` *method · line 371* ```python set_tag_schema(schema: list[dict]) -> None ``` Set the tag schema and persist it. #### `FileManager.filmoteka_category_roots` *method · line 375* ```python filmoteka_category_roots() -> dict[str, str] ``` Category → output root-folder map derived from the tag schema. Categories with `filmoteka_root` set to None are filterable but get no folders in the generated tree. #### `FileManager.filmoteka_category_transforms` *method · line 387* ```python filmoteka_category_transforms() -> dict[str, str] ``` Category → folder transform-name map (for grouping, e.g. rating bands). Tags keep their exact value; this transform only shapes the folder name in the generated tree. Categories without a transform are omitted. #### `FileManager.filmoteka_category_filename_templates` *method · line 399* ```python filmoteka_category_filename_templates() -> dict[str, str] ``` Category → hardlink-name template map (applied inside that category). #### `FileManager.filmoteka_dir` *property · line 408* ```python filmoteka_dir -> Path | None ``` The Filmotéka output folder (the generated hardlink tree), or `None`. #### `FileManager.set_pool_dir` *method · line 412* ```python set_pool_dir(pool_dir: Path) -> None ``` Set the managed pool root and create every library's top-level folder. #### `FileManager.set_filmoteka_dir` *method · line 421* ```python set_filmoteka_dir(filmoteka_dir: Path) -> None ``` Set the Filmotéka output folder (generated hardlink tree). #### `FileManager.load_pool_movies` *method · line 426* ```python load_pool_movies() -> None ``` Reload the movie list from every movie library, via the pool index. Scans each configured movie library (`movie_libraries`) recursively — a movie may sit directly in the library or inside its own per-movie folder. Curator's own metadata and the sidecars Jellyfin writes (NFO, artwork, subtitles) are skipped. #### `FileManager.check_data_consistency` *method · line 470* ```python check_data_consistency() -> dict ``` Compare the pool index against the actual files in the movie libraries. Detects drift such as a movie file deleted directly (metadata left behind) or a file dropped into the pool without importing (no metadata). Returns a dict with: ok — False if the pool isn't set (with 'error') missing — indexed media files that no longer exist on disk untracked — pool files (pool-relative) with no index entry index_count / disk_count — totals for the summary #### `FileManager.verify_against_jellyfin` *method · line 523* ```python verify_against_jellyfin(jf_movies: list) -> dict ``` Compare the pool against what Jellyfin currently sees. Matches each pooled movie to a Jellyfin item by **TMDb id** (from the filename), falling back to (title, year). Reports what is out of sync. `jf_movies` is a list of `jellyfin.JellyfinMovie`. Returns a dict: matched — how many pooled movies Jellyfin has pool_only — pooled filenames Jellyfin does not see (rescan/rematch) jellyfin_only — Jellyfin titles with no matching pooled movie pool_total / jellyfin_total — totals for the summary #### `FileManager.scan_video_integrity` *method · line 568* ```python scan_video_integrity(files: list[File], deep: bool=True, on_progress: Callable[[int, int, File], None] | None=None, should_cancel: Callable[[], bool] | None=None) -> dict ``` Check a list of movies for corrupted/unreadable video data. Runs `check_video_integrity` on each file (deep = full ffmpeg decode, otherwise a quick ffprobe probe). `on_progress(done, total, file)` is called after each file and `should_cancel()` is polled before each — so a GUI can show progress and stop between files. Returns a dict with: ok — False if ffmpeg/ffprobe isn't available (with 'error') problems — IntegrityResult list for files that failed (bad or unable-to-check), in scan order checked — how many files were actually processed cancelled — True if the scan stopped early #### `FileManager.statistics` *method · line 611* ```python statistics() -> dict ``` Aggregate stats over the loaded pool movies for the stats view. Returns a dict with totals and, per tag category, the tag counts sorted by frequency (descending). #### `FileManager._identity_stem` *method · line 651* ```python _identity_stem(file_obj: File) -> str ``` The movie-identity stem `file_obj` belongs to. A version inside a movie folder belongs to the folder's name (its own filename carries the version label, which is not part of identity); a flat file belongs to its own stem minus any `.3D.` tag. #### `FileManager.pooled_with_stem` *method · line 663* ```python pooled_with_stem(title: str) -> list[File] ``` Pooled files of the movie identity `title` (case-insensitive) — a flat file, every version of a grouped movie, or a 3D-tagged copy of either. A film already in the pool must be found however it's stored, or importing it again silently creates a second, root-level copy. #### `FileManager._evict` *method · line 671* ```python _evict(file_obj: File) -> None ``` Delete a pooled movie: its metadata, the file, and the list entry. #### `FileManager.import_movie` *method · line 679* ```python import_movie(source: Path, title: str, move: bool=False, on_conflict: str='suffix', year: int | str | None=None, tmdb_id: int | str | None=None, library: str | None=None, csfd_url: str | None=None, format_3d: str | None=None, version_label: str | None=None) -> File | None ``` Bring a video file into a movie library and index its metadata. Lands in `pool//` (`library` defaults to the first movie library, "Filmy"). The pooled filename follows the naming convention `Title (YYYY) [tmdbid-N].ext` when a `year` is given (and a `tmdb_id` when known — the form Jellyfin matches on); without a year it falls back to `Title.ext`. The GUI import always supplies a year, so a library built through the app is uniformly `Title (YYYY) [tmdbid-N].ext`. `format_3d` (one of `naming.THREE_D_FORMATS`) appends the `.3D.` tag right away, when the file is already a 3D rip. `on_conflict="version"` adds the file as another version of a film already in the pool instead (its popisek is `version_label`, else the `format_3d` tag, else plain/default) — the file is never placed beside that film as a second movie. By default the original is **copied** (non-destructive). With `move=True` the source file is moved into the pool instead, leaving nothing behind. `on_conflict` decides what happens when a pooled movie of the same name already exists: - `"suffix"` (default): keep both, the new file gets a `_N` suffix. - `"replace"`: evict the existing same-named movie(s) (file + metadata) and import the new one under the plain name. - `"skip"`: do not import; return `None`. Raises: ValueError: a `year` was given but is not a four-digit number. #### `FileManager.rename_movie` *method · line 778* ```python rename_movie(file_obj: File, new_title: str) -> File ``` Rename a pooled movie's file to `.` and reindex it. Renames the physical file in pool/Filmy (keeping its extension), moves the metadata to the new key, and syncs `title`/`filename`. The extension is preserved; `new_title` is the bare name without it. Raises: ValueError: empty name, a name containing a path separator, or `file_obj` is one version of a multi-version movie (renaming just one file would orphan its siblings). FileExistsError: another pooled file already uses that name. #### `FileManager._canonical_name_for` *method · line 819* ```python _canonical_name_for(file_obj: File, *, include_3d: bool=True) -> str | None ``` The `Title (YYYY) [tmdbid-N]` stem this movie should have, or `None`. Uses the clean title and the year from the movie's metadata (`name_context`: cache → `Rok` tag → the current filename). A `(YYYY)` that leaked into the title is stripped so it isn't doubled, and an existing `[tmdbid-N]` / `.3D.` on the current filename is preserved (`include_3d=False` drops the 3D tag — used for a movie-group **folder** name, where 3D becomes a per-version label instead). Returns `None` when no four-digit year is known. #### `FileManager.rename_to_canonical` *method · line 848* ```python rename_to_canonical(file_obj: File) -> File | None ``` Rename a pooled movie to `Title (YYYY).ext` from its metadata. The year comes from the movie's metadata (NFO cache, falling back to a `Rok` tag or the existing filename). `file_obj.title` keeps the title (without the year). Does not fire `on_files_changed` — the caller refreshes once after a batch. A movie with multiple versions is skipped (`None`) — renaming one version alone would orphan its siblings; use the version tools in *Upravit film* instead. Returns: The file when renamed (or already canonical); `None` when no four-digit year is known (or the movie has multiple versions), so no canonical name can be built. Raises: FileExistsError: another pooled file already uses that name. #### `FileManager.rename_all_to_canonical` *method · line 888* ```python rename_all_to_canonical(files: list[File], on_progress: Callable[[int, int, File], None] | None=None, should_cancel: Callable[[], bool] | None=None) -> dict ``` Rename each movie to `Title (YYYY).ext` from its metadata. Skips files already canonical and those without a known year. Reports conflicts (target name taken) without aborting the rest. Returns a dict with: renamed — (old_name, new_name) pairs actually renamed skipped_no_year — filenames with no four-digit year available conflicts — (old_name, message) where the target name was taken unchanged — count already in canonical form cancelled — True if stopped early #### `FileManager.move_to_library` *method · line 937* ```python move_to_library(file_obj: File, new_library: str) -> File ``` Move a pooled movie into another library (top-level pool folder). Moves the file into `pool//` and re-indexes it under the new key. Raises: ValueError: the target isn't a known movie library, or `file_obj` is one version of a multi-version movie (moving just one file would orphan its siblings). FileExistsError: a file of that name already sits in the target. #### `FileManager.apply_movie_edits` *method · line 976* ```python apply_movie_edits(file_obj: File, *, title: str, year: int | str | None=None, tmdb_id: int | str | None=None, library: str | None=None, date: str | None=None, attributes: dict | None=None, tags: list[str] | None=None, csfd_url: str | None=None, format_3d: str | None=None) -> File ``` Apply all editable parameters of a movie at once (rename/move + metadata). Renames the file to `Title (rok) [tmdbid].3D..ext` (year/3D permitting), moves it into `library`, and sets `title` / `date` / `attributes` / `tags` (a full `["Category/Name", …]` list; NFO-tag provenance is kept for the ones that remain). No-ops that don't change the path skip the move. `format_3d` follows `naming.canonical_pool_stem`: omitted (`None`) keeps whatever 3D tag the file already has, an empty string clears it, one of `naming.THREE_D_FORMATS` sets it. Raises: FileExistsError: the target name is already taken. #### `FileManager.is_movie_group_member` *method · line 1078* ```python is_movie_group_member(file_obj: File) -> bool ``` True when `file_obj` is one version inside its own per-movie folder. Recognised in `movie` and `mixed` libraries. A `mixed` library's subfolder can be either a movie's version folder or a series — both use the identical `Title (rok) [tmdbid]/` shape — so there it also checks the folder holds no `Season NN`/`Specials` subfolder (the shape Curator always gives a series; a movie's version folder never has one, its files sit flat inside it — see `series.looks_like_series_folder`). #### `FileManager.list_movie_versions` *method · line 1101* ```python list_movie_versions(file_obj: File) -> list[File] ``` Every version sharing `file_obj`'s movie-group folder, name-sorted. A single-element list (just `file_obj`) when it isn't grouped. #### `FileManager.primary_version` *method · line 1118* ```python primary_version(versions: list[File]) -> File ``` Which version represents the movie in the table. Metadata is shared by the whole movie record now, so this is purely a display choice: the plain/unlabelled file first, then the one labelled `movie.DEFAULT_VERSION_LABEL`, then alphabetically. Unrelated to which version Jellyfin plays by default (its own, alphabetical rule). #### `FileManager.visible_movies` *method · line 1135* ```python visible_movies() -> list[File] ``` `filelist` collapsed to one row per movie for the table. Ungrouped files pass through unchanged; a movie-group folder contributes only its `primary_version` — the rest are reachable via *Upravit film → Verze*, not as separate rows. #### `FileManager.add_movie_version` *method · line 1155* ```python add_movie_version(file_obj: File, source: Path, label: str, *, move: bool=False, existing_label: str | None=None) -> File ``` Add `source` as another playable version of `file_obj`'s movie. Promotes a flat movie to its own per-movie folder on the first extra version — the pre-existing file is moved in as ` - {existing_label}.ext` (only on this first promotion) and `source` lands as ` - {label}.ext`. A movie already grouped just gets one more file in the same folder. Shared metadata stays wherever it already was; the new version starts with none of its own (attach subtitles / a 3D formát to it separately if needed). An empty `label` (a plain "2D" version with nothing else to call it) falls back to `movie.DEFAULT_VERSION_LABEL` ("Výchozí") — Jellyfin's own examples label every grouped file, so an unlabelled one still needs *something*, just not something the user has to type. Likewise `existing_label` left unset (`None`) is derived from the pre-existing file's own name: its current `.3D.` tag if it has one — so promoting a movie that was flat-and-3D-only never silently drops that on the floor — else the same "Výchozí" default. Raises: RuntimeError: no pool, or `file_obj` has no valid title+year yet. FileExistsError: a version of that name already exists. #### `FileManager.rename_movie_version` *method · line 1228* ```python rename_movie_version(file_obj: File, new_label: str) -> File ``` Rename one version's label within its movie's version folder. Only meaningful once a movie is grouped — a flat (single-version) movie has no on-disk label to rename; give it a second version first (its sole version keeps showing as "Výchozí" in *Upravit film* either way, that's just a placeholder for the table). An empty `new_label` falls back to `movie.DEFAULT_VERSION_LABEL` ("Výchozí"), same as `add_movie_version`. Raises: ValueError: `file_obj` isn't part of a version group. FileExistsError: another version already uses that label. #### `FileManager.normalize_default_version_names` *method · line 1263* ```python normalize_default_version_names() -> list[tuple[str, str]] ``` Give a grouped movie's default version its bare name. Pool files written before the default version went unlabelled still read ` - Výchozí.ext`; this renames each to `.ext` (subtitles and metadata follow). A folder whose bare name is already taken is left alone. Returns the `(old, new)` filenames renamed. #### `FileManager.remove_movie_version` *method · line 1292* ```python remove_movie_version(file_obj: File) -> None ``` Remove one version (+ its subtitles) from a movie's version group. When exactly one version is left afterward, the movie is demoted back to a flat file — moved out to the library root under the bare canonical stem, or `stem.3D.` when that sole survivor's own popisek was a 3D formát (so a movie that ends up 3D-only again keeps saying so, instead of silently becoming a plain file) — and the now-empty folder is removed. Metadata needs no rescuing: it lives on the movie record, not on any one version, so removing even the version shown in the table leaves it untouched. Raises: ValueError: `file_obj` isn't part of a version group (nothing to remove it *from* — a lone movie has no "versions" to drop). #### `FileManager.remove_movie` *method · line 1341* ```python remove_movie(file_obj: File) -> None ``` Delete a whole movie from the pool — every version, subtitle and record. For a flat movie that is just its file (+ subtitles); for a grouped one it is the entire per-movie folder, so removing the row shown in the table never leaves the other versions behind as a half-empty group. #### `FileManager._movie_units` *method · line 1362* ```python _movie_units() -> list[list[File]] ``` `filelist` grouped per movie: one list per flat file / version folder. #### `FileManager.find_duplicates` *method · line 1370* ```python find_duplicates() -> list[dict] ``` Movies that show up in the pool more than once. Each movie counts once however many versions it has (a version folder is one movie, not a duplicate of itself). Two movies are flagged when they share * the same TMDb id — e.g. `X.mkv` and `X.3D.HSBS.mkv` lying flat side by side instead of grouped, or the same film in two libraries; * the same title + year — typically one copy paired with TMDb and one not (or paired with a different id); * a byte-identical file size — almost certainly the same file copied. Overlapping hits are merged into one group. Returns a list of `{"movies": [File, …], "reasons": [str, …]}` — `movies` holds the `primary_version` of each movie — sorted by title. #### `FileManager.merge_as_version` *method · line 1452* ```python merge_as_version(keep: File, other: File, label: str, *, keep_label: str | None=None) -> File ``` Fold the duplicate movie `other` into `keep` as one more version. `other`'s file is *moved* into `keep`'s version folder (promoting `keep` from flat when needed, with `keep_label` — left `None`, the usual case, it's derived from `keep`'s own current 3D formát if it has one, same as `add_movie_version`'s `existing_label`); its subtitles follow. `keep`'s metadata wins — `other` only contributes tags and a ČSFD link `keep` doesn't have yet — and `other`'s own record entry is dropped so it doesn't linger as a phantom row. An empty `label` for `other` is likewise fine — it falls back to "Výchozí" (`add_movie_version`). Raises: ValueError: `other` is itself a multi-version movie, or both are the same movie folder / file. RuntimeError / FileExistsError: see `add_movie_version`. #### `FileManager.list_subtitles` *method · line 1506* ```python list_subtitles(file_obj: File) -> list ``` External subtitle sidecars currently sitting next to a movie. #### `FileManager.attach_subtitle` *method · line 1510* ```python attach_subtitle(file_obj: File, source: Path, *, language: str | None=None, flag: str | None=None, move: bool=False) -> Path ``` Copy (or move) a picked subtitle file next to `file_obj`, correctly named. Raises: ValueError: `source` isn't a recognised subtitle format. #### `FileManager.remove_subtitle` *method · line 1522* ```python remove_subtitle(subtitle_path: Path) -> None ``` Delete one attached subtitle file. #### `FileManager.refresh_tags_from_tmdb` *method · line 1526* ```python refresh_tags_from_tmdb(files: list[File] | None=None, on_progress: Callable[[int, int, File], None] | None=None, should_cancel: Callable[[], bool] | None=None) -> dict ``` (Re)derive each movie's tags from **TMDb** (by the tmdbid in its name). Fetches genres / year / country / rating from TMDb per the tag schema and regenerates the auto tags (`File.apply_nfo`, which is source-agnostic); user-added tags are kept. Movies with no tmdbid in the filename are skipped. Returns `{ok, updated, without_id, errors}`. #### `FileManager.resolve_via_csfd` *method · line 1567* ```python resolve_via_csfd(*, csfd_url: str | None=None, query: str | None=None, session: object | None=None) -> dict ``` ČSFD → TMDb bridge for one movie. With `csfd_url` set, reads that ČSFD film; otherwise searches ČSFD for `query` (a cleaned filename) and takes the top hit. The ČSFD original title + year then drive a TMDb search — far more reliable than a raw Czech-title search. Pass a shared `requests.Session` to reuse the Anubis auth cookie across a batch. Returns `{"csfd_url", "film": CsfdFilm | None, "tmdb": TmdbResult | None}`. Best-effort: a network / anti-bot / no-match failure is a `None` in the result, not an exception. #### `FileManager.match_pool_to_tmdb` *method · line 1604* ```python match_pool_to_tmdb(files: list[File] | None=None, on_progress: Callable[[int, int, File], None] | None=None, should_cancel: Callable[[], bool] | None=None) -> list ``` Propose a TMDb id for every movie that doesn't have one yet. Searches TMDb by each movie's title + year and classifies the result (see `migrate.classify`). A movie that carries a **ČSFD link** (`File.csfd_url`) is first resolved through ČSFD so the search runs on the *original* title + year — the pure Czech-title search picks the wrong film for remakes/localized names. Movies whose filename already carries a `[tmdbid-N]` are skipped. Returns a list of `migrate.MatchProposal`. #### `FileManager.assign_tmdb_id` *method · line 1663* ```python assign_tmdb_id(file_obj: File, tmdb_id: int | str, year: int | str | None=None) -> File ``` Attach a TMDb id to a movie and rename it to the canonical convention. `year` (typically from the TMDb match) fills a missing local year so the file becomes `Title (rok) [tmdbid].ext`. Title/library/tags/date are kept. #### `FileManager.refresh_tags_from_nfo` *method · line 1680* ```python refresh_tags_from_nfo(files: list[File] | None=None) -> dict ``` (Re)derive each movie's auto tags from its NFO (written by Jellyfin). Reads the `.nfo` next to each movie and regenerates the genre / year / country / rating tags per the tag schema (`File.apply_nfo`); user-added tags are preserved. Movies without an NFO yet are left untouched. Returns `{updated, without_nfo}`. #### `FileManager.series_libraries` *property · line 1709* ```python series_libraries -> list[str] ``` Pool folders that can hold series — kind `series` or `mixed`. #### `FileManager.load_series` *method · line 1713* ```python load_series() -> list ``` Scan every series library into a list of `series.Series`. A `mixed` library also skips a canonical-shaped subfolder that doesn't look like a series (see `series.scan_series`'s `skip_movie_folders`) — that's a movie's version folder instead, read by `load_pool_movies`. A plain `series`-kind library never holds movies, so it keeps reading every subfolder as a series regardless of shape (a raw, not-yet-renamed import may have loose files sitting at the series root, with no season folder yet). #### `FileManager.import_episodes` *method · line 1737* ```python import_episodes(sources: list[Path], title: str, year: int | str | None=None, tmdb_id: int | str | None=None, library: str | None=None, move: bool=False, on_conflict: str='suffix', codes: Mapping[Path, tuple[int, int] | tuple[int, int, int | None]] | None=None) -> dict ``` Import episode files into `/Series (rok) [tmdbid]/Season NN/`. Each source's season/episode/part comes from `codes` (an explicit `{path: (season, episode)}` or `{path: (season, episode, part)}` override set at import time) when present, otherwise it is parsed from the filename. It is then copied (or moved) in as `Series SxxEyy.ext` (`.ptN` appended when it's just one part of that episode). Files with neither an override nor a recognisable `SxxEyy` are skipped and reported. Returns `{imported, skipped_no_code, errors}`. #### `FileManager.rename_episodes_to_canonical` *method · line 1807* ```python rename_episodes_to_canonical(series) -> dict ``` Rename a series' episodes to `SxxEyy - {series title}.ext` in its season. `series` is a `series.Series`. Episodes with no recognisable code are skipped; a target name already taken is reported as a conflict. Returns `{renamed, skipped, conflicts}`. #### `FileManager.rename_all_series_to_canonical` *method · line 1842* ```python rename_all_series_to_canonical(on_progress: Callable[[int, int, str], None] | None=None, should_cancel: Callable[[], bool] | None=None) -> dict ``` Canonicalise every series' episode filenames across the pool. Iterates all series (`series` + `mixed` libraries); per series it renames each episode to `SxxEyy - {series title}.ext` in the right `Season NN` / `Specials` folder (`rename_episodes_to_canonical`) and then removes any season folder left empty by the moves. Episodes with no recognisable `SxxEyy` are left in place and listed. Returns `{series, renamed:[(series, old, new)], skipped_no_code:[(series, name)], conflicts:[(series, name, msg)], empty_dirs_removed:[str], cancelled}`. #### `FileManager.apply_series_edits` *method · line 1891* ```python apply_series_edits(series, *, title: str, year: int | str | None=None, tmdb_id: int | str | None=None, library: str | None=None) -> dict ``` Rename/move a series folder and re-title its episodes to match. Renames the series folder to `Title (rok) [tmdbid]` (moving it into `library` if changed), then re-derives the series and renames every episode to `NewTitle SxxEyy.ext` in the right Season folder. Returns `{folder, episodes}` (`episodes` = rename_episodes result). Raises: FileExistsError: the target series folder already exists. #### `FileManager.remove_series` *method · line 1927* ```python remove_series(series) -> None ``` Delete a whole series folder from the pool (every season and episode). Series carry no index metadata (they are scanned from disk), so removing one is just deleting its folder. Refuses a folder that is not inside the pool as a safety check. Raises: ValueError: the series folder is outside the pool. #### `FileManager.remove_episodes` *method · line 1948* ```python remove_episodes(episodes: list) -> None ``` Delete specific episode files (+ their subtitles) from the pool. `episodes` is a list of `series.Episode` — series carry no index metadata (they are scanned live from disk), so this is just deleting files. A `Season NN`/`Specials` folder left completely empty by the removal is cleaned up too; one that still holds something else (a stray sidecar, another episode) is left alone. #### `FileManager.remove_season` *method · line 1973* ```python remove_season(series, season: int) -> None ``` Delete every episode of one season of `series` (+ the season folder). `season` is a season number as reported by `series.seasons()` (`-1` = episodes with no recognisable season — there is no single folder for those, just the files themselves get removed). Raises: ValueError: `series` has no episodes in that season. #### `FileManager.assign_tag_to_file_objects` *method · line 1988* ```python assign_tag_to_file_objects(files_objs: list[File], tag) ``` Attach a tag (a `Tag` or a `category/name` string) to every file in the list and register the tag with the tag manager. #### `FileManager.remove_tag_from_file_objects` *method · line 2005* ```python remove_tag_from_file_objects(files_objs: list[File], tag) ``` Detach a tag (a `Tag` or a `category/name` string) from every file in the list. #### `FileManager.filter_files_by_tags` *method · line 2024* ```python filter_files_by_tags(tags: Iterable) ``` Return only the files that carry **all** the given tags (AND filter). With no tags, the whole file list is returned. Tags may be `Tag` objects or `category/name` strings. ## Functions ### `_version_filename` *function · line 57* ```python _version_filename(stem: str, label: str, suffix: str) -> str ``` Filename of one version of a movie folder. The default version ("Výchozí", or no label at all) carries no label — it's just ``, the same name the movie had flat. Any other version is ` -