Migrate from Poetry and pyenv to uv
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# `src/_version.py`
|
||||
|
||||
Auto-generated — do not edit manually.
|
||||
|
||||
Source: [`src/_version.py`](../../src/_version.py)
|
||||
@@ -0,0 +1,34 @@
|
||||
# `src/constants.py`
|
||||
|
||||
Application-level constants for Curator (build/runtime metadata).
|
||||
|
||||
This module derives the app version and debug flag. The version is loaded from
|
||||
`pyproject.toml` (preferred) with a generated `src/_version.py` fallback for
|
||||
frozen/PyInstaller builds. Debug mode is controlled via `.env` (`ENV_DEBUG`).
|
||||
|
||||
It is the single source of truth for app metadata used across the GUI and build
|
||||
tooling (`APP_NAME`, `APP_VERSION`/`VERSION`, `APP_TITLE`, `APP_VIEWPORT`).
|
||||
|
||||
Source: [`src/constants.py`](../../src/constants.py)
|
||||
|
||||
## Functions
|
||||
|
||||
### `get_version`
|
||||
|
||||
*function · line 26*
|
||||
|
||||
```python
|
||||
get_version() -> str
|
||||
```
|
||||
|
||||
Return the project version (pyproject → _version.py → unknown).
|
||||
|
||||
### `get_debug_mode`
|
||||
|
||||
*function · line 55*
|
||||
|
||||
```python
|
||||
get_debug_mode() -> bool
|
||||
```
|
||||
|
||||
Return True when ENV_DEBUG is set to a truthy value in the environment.
|
||||
@@ -0,0 +1,57 @@
|
||||
# `src/core/config.py`
|
||||
|
||||
Configuration for Curator.
|
||||
|
||||
Two stores, by responsibility:
|
||||
|
||||
1. Global config (`.Curator.!gtag` next to Curator.py) — **application data
|
||||
only**: window state, MRU folders, and the pointers to where the pool and the
|
||||
Filmotéka output live (`pool_dir` / `filmoteka_dir`). Those pointers must
|
||||
stay here because the pool index is found *through* them.
|
||||
2. Pool index (`<pool>/.Curator.!index`, see `pool_index.py`) — everything
|
||||
describing a particular library: per-movie metadata **and** library-level
|
||||
settings (`tag_schema`, `copyasis_folders`). It travels with the pool.
|
||||
|
||||
Source: [`src/core/config.py`](../../src/core/config.py)
|
||||
|
||||
## Functions
|
||||
|
||||
### `load_global_config`
|
||||
|
||||
*function · line 68*
|
||||
|
||||
```python
|
||||
load_global_config() -> dict
|
||||
```
|
||||
|
||||
Load global application config
|
||||
|
||||
### `save_global_config`
|
||||
|
||||
*function · line 84*
|
||||
|
||||
```python
|
||||
save_global_config(cfg: dict)
|
||||
```
|
||||
|
||||
Save global application config
|
||||
|
||||
### `load_config`
|
||||
|
||||
*function · line 94*
|
||||
|
||||
```python
|
||||
load_config()
|
||||
```
|
||||
|
||||
Legacy function - returns global config
|
||||
|
||||
### `save_config`
|
||||
|
||||
*function · line 99*
|
||||
|
||||
```python
|
||||
save_config(cfg: dict)
|
||||
```
|
||||
|
||||
Legacy function - saves global config
|
||||
@@ -0,0 +1,251 @@
|
||||
# `src/core/csfd.py`
|
||||
|
||||
ČSFD.cz scraper — a *title bridge* to TMDb.
|
||||
|
||||
Curator identifies movies by **TMDb id**. A direct TMDb search on a Czech
|
||||
distribution title is unreliable (remakes, localized names, "weird" hits).
|
||||
ČSFD, on the other hand, indexes Czech release names well **and** lists a
|
||||
film's *original* title. So the bridge is::
|
||||
|
||||
filename → ČSFD search → ČSFD film page → original title + year
|
||||
→ TMDb search → tmdbid
|
||||
|
||||
This module only scrapes ČSFD; the TMDb half lives in
|
||||
`src.core.file_manager` (`FileManager.resolve_via_csfd`). Nothing
|
||||
here feeds tags/genres — those still come from TMDb via Jellyfin/NFO.
|
||||
|
||||
ČSFD serves an **Anubis** proof-of-work wall in front of every page: a plain
|
||||
request gets a 200 with a JS challenge instead of the movie. We detect it,
|
||||
solve the SHA-256 PoW the way the bundled worker JS does, and replay the
|
||||
request through a shared `requests.Session` (so a batch pays the PoW
|
||||
cost only once).
|
||||
|
||||
Source: [`src/core/csfd.py`](../../src/core/csfd.py)
|
||||
|
||||
## Classes
|
||||
|
||||
### `CsfdError`
|
||||
|
||||
*class · line 62*
|
||||
|
||||
```python
|
||||
class CsfdError(RuntimeError):
|
||||
```
|
||||
|
||||
A ČSFD lookup could not be completed (deps missing, network, anti-bot).
|
||||
|
||||
### `CsfdFilm`
|
||||
|
||||
*class · line 67*
|
||||
|
||||
```python
|
||||
class CsfdFilm:
|
||||
```
|
||||
|
||||
The slice of a ČSFD film page Curator needs to reach a TMDb id.
|
||||
|
||||
#### `CsfdFilm.tmdb_query`
|
||||
|
||||
*property · line 77*
|
||||
|
||||
```python
|
||||
tmdb_query -> tuple[str, Optional[str]]
|
||||
```
|
||||
|
||||
`(title, year)` best suited for a TMDb search — original title first.
|
||||
|
||||
## Functions
|
||||
|
||||
### `dependencies_available`
|
||||
|
||||
*function · line 82*
|
||||
|
||||
```python
|
||||
dependencies_available() -> bool
|
||||
```
|
||||
|
||||
True when `requests` + `beautifulsoup4` are importable.
|
||||
|
||||
### `_check_dependencies`
|
||||
|
||||
*function · line 87*
|
||||
|
||||
```python
|
||||
_check_dependencies() -> None
|
||||
```
|
||||
|
||||
Raise `CsfdError` when the optional `requests` / `beautifulsoup4` packages are missing (they are needed only for ČSFD scraping).
|
||||
|
||||
### `_extract_csfd_id`
|
||||
|
||||
*function · line 95*
|
||||
|
||||
```python
|
||||
_extract_csfd_id(url: str) -> Optional[int]
|
||||
```
|
||||
|
||||
Pull the numeric film id out of a ČSFD URL, or `None`.
|
||||
|
||||
### `_extract_json_blob`
|
||||
|
||||
*function · line 105*
|
||||
|
||||
```python
|
||||
_extract_json_blob(html: str, element_id: str)
|
||||
```
|
||||
|
||||
Return the parsed JSON from an Anubis `<script id=...>` blob, or None.
|
||||
|
||||
### `_solve_anubis_pow`
|
||||
|
||||
*function · line 120*
|
||||
|
||||
```python
|
||||
_solve_anubis_pow(random_data: str, difficulty: int) -> tuple[str, int, int]
|
||||
```
|
||||
|
||||
Brute-force the Anubis proof-of-work.
|
||||
|
||||
Mirrors the bundled `sha256-purejs` worker: find the smallest `nonce`
|
||||
such that `sha256(random_data + str(nonce))` has `difficulty` leading
|
||||
zero nibbles. Returns `(hash_hex, nonce, elapsed_ms)`.
|
||||
|
||||
### `_solve_anubis_challenge`
|
||||
|
||||
*function · line 143*
|
||||
|
||||
```python
|
||||
_solve_anubis_challenge(session, html: str, url: str)
|
||||
```
|
||||
|
||||
Solve the Anubis challenge in `html` and return the real page response.
|
||||
|
||||
### `_get_page`
|
||||
|
||||
*function · line 180*
|
||||
|
||||
```python
|
||||
_get_page(session, url: str)
|
||||
```
|
||||
|
||||
GET `url` through `session`, transparently clearing an Anubis wall.
|
||||
|
||||
### `_parse_json_ld`
|
||||
|
||||
*function · line 193*
|
||||
|
||||
```python
|
||||
_parse_json_ld(soup) -> tuple[str, Optional[str]]
|
||||
```
|
||||
|
||||
`(name, year)` from the film page's JSON-LD (name is the Czech title).
|
||||
|
||||
### `_parse_origin`
|
||||
|
||||
*function · line 215*
|
||||
|
||||
```python
|
||||
_parse_origin(soup) -> tuple[Optional[str], Optional[str]]
|
||||
```
|
||||
|
||||
`(origin_country, year)` from the `.origin` line.
|
||||
|
||||
ČSFD separates values with inline bullet `<span>` elements (no commas),
|
||||
e.g. `USA • 1999 • 136 min`. For a co-production only the first country
|
||||
is returned — enough to match it against a name's flag.
|
||||
|
||||
### `_parse_original_title`
|
||||
|
||||
*function · line 245*
|
||||
|
||||
```python
|
||||
_parse_original_title(soup, origin_country: Optional[str]) -> Optional[str]
|
||||
```
|
||||
|
||||
The film's original title from `.film-names` — or `None` to use the CZ title.
|
||||
|
||||
Each `<li>` carries a country/language flag (`img.flag` `title`) and a
|
||||
title. The original title is the entry whose flag matches the **country of
|
||||
origin** (USA → "The Shawshank Redemption"). ČSFD does **not** repeat the
|
||||
Czech title here when it equals the `<h1>`, so a Czech film has only
|
||||
foreign/festival alt-titles in this list (`[angličtina] Cosy Dens`,
|
||||
`[Slovinsko] Samotarji`) — picking one of those would be wrong, so we
|
||||
return `None` and let the caller fall back to the Czech `<h1>` title.
|
||||
|
||||
### `fetch_film`
|
||||
|
||||
*function · line 276*
|
||||
|
||||
```python
|
||||
fetch_film(url: str, session=None) -> CsfdFilm
|
||||
```
|
||||
|
||||
Fetch a ČSFD film page → `CsfdFilm` (Czech + original title, year).
|
||||
|
||||
Pass a shared `requests.Session` to reuse the Anubis auth cookie across a
|
||||
batch. Raises `CsfdError` on a network / anti-bot / parse failure.
|
||||
|
||||
### `_films_section`
|
||||
|
||||
*function · line 311*
|
||||
|
||||
```python
|
||||
_films_section(soup)
|
||||
```
|
||||
|
||||
The `<section>` holding the *Filmy* results, or the whole document.
|
||||
|
||||
ČSFD's search page groups hits into `Filmy` / `Seriály` / `Tvůrci` /
|
||||
`Uživatelé` sections; `a.film-title-name` matches in all of them, so
|
||||
without scoping a bare-title search happily returns a series episode.
|
||||
|
||||
### `_is_film_href`
|
||||
|
||||
*function · line 325*
|
||||
|
||||
```python
|
||||
_is_film_href(href: str) -> bool
|
||||
```
|
||||
|
||||
True for a film URL `/film/<id>-slug/` — a series episode has two segments
|
||||
(`/film/<series>/<episode>/`) and is rejected.
|
||||
|
||||
### `search_films`
|
||||
|
||||
*function · line 332*
|
||||
|
||||
```python
|
||||
search_films(query: str, limit: int=5, session=None) -> list[CsfdFilm]
|
||||
```
|
||||
|
||||
Search ČSFD for `query`; film hits only (no series/episodes), best first.
|
||||
|
||||
### `find_csfd_url`
|
||||
|
||||
*function · line 365*
|
||||
|
||||
```python
|
||||
find_csfd_url(query: str, session=None) -> Optional[str]
|
||||
```
|
||||
|
||||
Return the first ČSFD film URL matching `query`, or `None`.
|
||||
|
||||
ČSFD's full-text search treats a trailing year as a search term (not a
|
||||
filter) and can miss on `"Title 2006"` while `"Title"` hits — so a
|
||||
yearful query with no result is retried without the year.
|
||||
|
||||
### `clean_filename_to_query`
|
||||
|
||||
*function · line 397*
|
||||
|
||||
```python
|
||||
clean_filename_to_query(filename: str) -> str
|
||||
```
|
||||
|
||||
Turn a (possibly release-named) filename into a ČSFD search query.
|
||||
|
||||
Strips the path/extension, splits on common separators and keeps the words
|
||||
before the first release marker (year, resolution, codec, source, …). The
|
||||
detected year is appended back as a disambiguator::
|
||||
|
||||
"Matrix.1999.1080p.BluRay.x264-GROUP.mkv" -> "Matrix 1999"
|
||||
@@ -0,0 +1,227 @@
|
||||
# `src/core/file.py`
|
||||
|
||||
The `File` class: one playable movie file as the UI sees it. A `File` is a *view* onto one media entry of a movie record in the pool index — metadata (title, tags, ČSFD link, attributes, date) lives on the shared record, so every version of a film reads the same values. See `docs/architecture.md`.
|
||||
|
||||
Source: [`src/core/file.py`](../../src/core/file.py)
|
||||
|
||||
## Classes
|
||||
|
||||
### `File`
|
||||
|
||||
*class · line 5*
|
||||
|
||||
```python
|
||||
class File:
|
||||
```
|
||||
|
||||
One movie file in the pool, as the UI sees it. Holds the path, the list of tags and the metadata accessors; metadata itself is stored on the movie record in the pool index and the `File` reads it through it.
|
||||
|
||||
#### `File.__init__`
|
||||
|
||||
*method · line 6*
|
||||
|
||||
```python
|
||||
__init__(file_path: Path, tagmanager=None, index=None) -> None
|
||||
```
|
||||
|
||||
Create a file object for `file_path`. Metadata is loaded from the pool index when `index` is given; without an index the object is in-memory only.
|
||||
|
||||
#### `File._identity_from_path`
|
||||
|
||||
*method · line 41*
|
||||
|
||||
```python
|
||||
_identity_from_path() -> tuple[str, str, str | None, int | None, str]
|
||||
```
|
||||
|
||||
`(key, title, year, tmdb_id, label)` implied by this file's location.
|
||||
|
||||
A file inside its own `Title (rok) [tmdbid]` folder is one version of
|
||||
that movie — identity comes from the **folder**, and the rest of its own
|
||||
stem is the version label. A flat file carries both in its own name.
|
||||
|
||||
#### `File.get_metadata`
|
||||
|
||||
*method · line 67*
|
||||
|
||||
```python
|
||||
get_metadata() -> None
|
||||
```
|
||||
|
||||
Load this file's metadata from the movie record that owns it.
|
||||
|
||||
Since the pool index became identity-keyed (see `src.core.movie`),
|
||||
a File is a *view* on one media entry of a movie record — every version
|
||||
of the same film therefore reads the same title/tags/ČSFD/… A file the
|
||||
index doesn't know yet joins the movie of the same identity when there
|
||||
is one (that's how a newly added version inherits the shared metadata
|
||||
instead of blanking it), otherwise it starts a new record.
|
||||
|
||||
#### `File._init_new_metadata`
|
||||
|
||||
*method · line 95*
|
||||
|
||||
```python
|
||||
_init_new_metadata() -> None
|
||||
```
|
||||
|
||||
Reset all metadata fields to their defaults for a file the index doesn't know yet.
|
||||
|
||||
#### `File._build_record`
|
||||
|
||||
*method · line 107*
|
||||
|
||||
```python
|
||||
_build_record() -> dict
|
||||
```
|
||||
|
||||
Serialise this file's metadata into the dictionary stored in the pool index (tags as full paths, auto-derived tags, date, title, ČSFD link, …).
|
||||
|
||||
#### `File._apply_record`
|
||||
|
||||
*method · line 126*
|
||||
|
||||
```python
|
||||
_apply_record(data: dict) -> None
|
||||
```
|
||||
|
||||
Load metadata from an index record dictionary into this object's fields (the inverse of `_build_record`). Missing keys fall back to defaults.
|
||||
|
||||
#### `File.save_metadata`
|
||||
|
||||
*method · line 152*
|
||||
|
||||
```python
|
||||
save_metadata()
|
||||
```
|
||||
|
||||
Persist metadata into this file's movie record (no-op without index).
|
||||
|
||||
Writes the shared fields onto the movie record and makes sure the
|
||||
record lists this file among its media. A file the index has never seen
|
||||
starts a new record under its own identity key.
|
||||
|
||||
#### `File._library_name`
|
||||
|
||||
*method · line 182*
|
||||
|
||||
```python
|
||||
_library_name() -> str
|
||||
```
|
||||
|
||||
Top-level pool folder this file sits in (its library), or `""`.
|
||||
|
||||
#### `File.added_timestamp`
|
||||
|
||||
*method · line 190*
|
||||
|
||||
```python
|
||||
added_timestamp() -> float
|
||||
```
|
||||
|
||||
Epoch seconds for 'date added' — the stored `added` or the file mtime.
|
||||
|
||||
Used to order the "recently added" folder (newer = larger). The fallback
|
||||
is **mtime**, not ctime: creating the Filmotéka hardlinks bumps the source
|
||||
inode's ctime, which would make ctime-based ordering meaningless, whereas
|
||||
mtime (preserved from the source on import) stays stable.
|
||||
|
||||
#### `File.name_context`
|
||||
|
||||
*method · line 209*
|
||||
|
||||
```python
|
||||
name_context() -> dict
|
||||
```
|
||||
|
||||
Fields for a miniDLNA filename template (see config tag schema).
|
||||
|
||||
Provides title / year / rating / ext / stem / filename from the movie's
|
||||
metadata (year & rating come from the NFO cache, year falling back to a
|
||||
`Rok` tag, then the canonical filename). Missing values render empty.
|
||||
|
||||
#### `File.set_attribute`
|
||||
|
||||
*method · line 242*
|
||||
|
||||
```python
|
||||
set_attribute(key: str, value: str | None) -> None
|
||||
```
|
||||
|
||||
Set a free-form attribute (e.g. 'collection_sort'); None/'' removes it.
|
||||
|
||||
#### `File.delete_metadata`
|
||||
|
||||
*method · line 253*
|
||||
|
||||
```python
|
||||
delete_metadata() -> None
|
||||
```
|
||||
|
||||
Drop this file from its movie record; the last one takes the record.
|
||||
|
||||
Removing one version of a multi-version movie leaves the movie (and its
|
||||
metadata) in place — only when its last media entry goes does the whole
|
||||
record go with it.
|
||||
|
||||
#### `File.relocate`
|
||||
|
||||
*method · line 273*
|
||||
|
||||
```python
|
||||
relocate(new_path: Path) -> None
|
||||
```
|
||||
|
||||
Point this File at a new path, moving its media entry along.
|
||||
|
||||
The physical file must already have been moved/renamed by the caller;
|
||||
this only repoints the movie record's media entry (and the File), so a
|
||||
rename never detaches a file from its movie.
|
||||
|
||||
#### `File.set_date`
|
||||
|
||||
*method · line 294*
|
||||
|
||||
```python
|
||||
set_date(date_str: str | None)
|
||||
```
|
||||
|
||||
Set the date the movie was added (`YYYY-MM-DD`) or clear it with `None` / an empty string, then save the metadata.
|
||||
|
||||
#### `File.apply_nfo`
|
||||
|
||||
*method · line 303*
|
||||
|
||||
```python
|
||||
apply_nfo(nfo, schema: list[dict] | None=None) -> dict
|
||||
```
|
||||
|
||||
Regenerate the auto (NFO-derived) tags from the movie's NFO.
|
||||
|
||||
Reads genres/year/countries/rating from `nfo` (`nfo.NfoData`) per the
|
||||
tag schema's source fields and (re)creates those tags; user-added tags are
|
||||
kept. Also caches year/rating (for folder grouping) and fills the title
|
||||
when missing. `nfo` of `None` (no NFO yet) is a no-op.
|
||||
|
||||
Returns:
|
||||
dict with 'success' and 'tags_added'.
|
||||
|
||||
#### `File.add_tag`
|
||||
|
||||
*method · line 347*
|
||||
|
||||
```python
|
||||
add_tag(tag)
|
||||
```
|
||||
|
||||
Attach a tag to this file. Accepts a `Tag` or a `category/name` string; a string without a category goes to the `default` category. Saves on change.
|
||||
|
||||
#### `File.remove_tag`
|
||||
|
||||
*method · line 364*
|
||||
|
||||
```python
|
||||
remove_tag(tag)
|
||||
```
|
||||
|
||||
Detach a tag, given as a `Tag` or a `category/name` string. Saves on change.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,440 @@
|
||||
# `src/core/hardlink_manager.py`
|
||||
|
||||
Hardlink Manager for Curator
|
||||
|
||||
Creates directory structure based on file tags and creates hardlinks
|
||||
to organize files without duplicating them on disk.
|
||||
|
||||
Example:
|
||||
A file with tags "žánr/Komedie", "žánr/Akční", "rok/1988" will create:
|
||||
|
||||
output/
|
||||
├── žánr/
|
||||
│ ├── Komedie/
|
||||
│ │ └── film.mkv (hardlink)
|
||||
│ └── Akční/
|
||||
│ └── film.mkv (hardlink)
|
||||
└── rok/
|
||||
└── 1988/
|
||||
└── film.mkv (hardlink)
|
||||
|
||||
Source: [`src/core/hardlink_manager.py`](../../src/core/hardlink_manager.py)
|
||||
|
||||
## Classes
|
||||
|
||||
### `_SafeDict`
|
||||
|
||||
*class · line 30*
|
||||
|
||||
```python
|
||||
class _SafeDict(dict):
|
||||
```
|
||||
|
||||
dict for str.format_map that leaves unknown fields as an empty string.
|
||||
|
||||
### `HardlinkManager`
|
||||
|
||||
*class · line 37*
|
||||
|
||||
```python
|
||||
class HardlinkManager:
|
||||
```
|
||||
|
||||
Manager for creating hardlink-based directory structures from tagged files.
|
||||
|
||||
The output layout is driven by a *category → root folder* mapping
|
||||
(`category_roots`). Each tag is placed at
|
||||
`output/<root>/<tag_name>/<file>`; an empty root means the tag's own
|
||||
folders sit directly at the output root (e.g. genre folders next to the
|
||||
"Dle roku" / "Dle země původu" folders). The legacy `categories` list
|
||||
(folder == category name) is still accepted and treated as the identity
|
||||
mapping `{cat: cat}`.
|
||||
|
||||
#### `HardlinkManager.__init__`
|
||||
|
||||
*method · line 49*
|
||||
|
||||
```python
|
||||
__init__(output_dir: Path)
|
||||
```
|
||||
|
||||
Initialize HardlinkManager.
|
||||
|
||||
Args:
|
||||
output_dir: Base directory where the tag-based structure will be created
|
||||
|
||||
#### `HardlinkManager._resolve_roots`
|
||||
|
||||
*method · line 60*
|
||||
|
||||
```python
|
||||
_resolve_roots(categories: Optional[List[str]], category_roots: Optional[Dict[str, str]]) -> Optional[Dict[str, str]]
|
||||
```
|
||||
|
||||
Normalize the two filter styles into a category → root-folder map.
|
||||
|
||||
`None` means "all categories", folder == category name.
|
||||
|
||||
#### `HardlinkManager._clean_title`
|
||||
|
||||
*method · line 75*
|
||||
|
||||
```python
|
||||
_clean_title(file_obj: File) -> str
|
||||
```
|
||||
|
||||
The movie's clean title (no trailing `(YYYY)`), path-safe.
|
||||
|
||||
#### `HardlinkManager._default_link_name`
|
||||
|
||||
*method · line 85*
|
||||
|
||||
```python
|
||||
_default_link_name(file_obj: File) -> str
|
||||
```
|
||||
|
||||
Filmotéka hardlink name: the clean title + extension (no year).
|
||||
|
||||
#### `HardlinkManager._year_link_name`
|
||||
|
||||
*method · line 89*
|
||||
|
||||
```python
|
||||
_year_link_name(file_obj: File) -> Optional[str]
|
||||
```
|
||||
|
||||
`Title (YYYY).ext` used to disambiguate a name collision, or None.
|
||||
|
||||
#### `HardlinkManager._render_template`
|
||||
|
||||
*method · line 96*
|
||||
|
||||
```python
|
||||
_render_template(file_obj: File, template: str) -> str
|
||||
```
|
||||
|
||||
Render a per-category filename template; fall back to the clean name.
|
||||
|
||||
#### `HardlinkManager._plan_links`
|
||||
|
||||
*method · line 105*
|
||||
|
||||
```python
|
||||
_plan_links(files: List[File], roots: Optional[Dict[str, str]], transforms: Optional[Dict[str, str]], templates: Optional[Dict[str, str]]) -> List[Tuple[File, Path]]
|
||||
```
|
||||
|
||||
Resolve the tag tree to a collision-free `(file, link_path)` plan.
|
||||
|
||||
The default hardlink name is the movie's **clean title** (no year). When
|
||||
two *different* movies would land on the same name in the same folder,
|
||||
the colliding ones fall back to the year-qualified `Title (YYYY).ext` to
|
||||
tell them apart; a residual clash (same title *and* year) gets a stable
|
||||
numeric suffix so every path is unique. A per-category
|
||||
`filename_template` overrides the default inside that category's folders.
|
||||
|
||||
Both link creation and obsolete detection consume this one plan, so they
|
||||
can never disagree on a name (which is what caused hardlink churn before).
|
||||
|
||||
#### `HardlinkManager._folder_value`
|
||||
|
||||
*method · line 172*
|
||||
|
||||
```python
|
||||
_folder_value(tag, transforms: Optional[Dict[str, str]]) -> str
|
||||
```
|
||||
|
||||
Folder name for a tag — its value run through the category transform.
|
||||
|
||||
The tag keeps its exact value; the grouping (e.g. rating → ten-point
|
||||
band) only happens here, when naming the folder.
|
||||
|
||||
#### `HardlinkManager._target_dir`
|
||||
|
||||
*method · line 183*
|
||||
|
||||
```python
|
||||
_target_dir(tag, roots: Optional[Dict[str, str]], transforms: Optional[Dict[str, str]]=None) -> Optional[Path]
|
||||
```
|
||||
|
||||
Output directory for a tag, or None if its category is excluded.
|
||||
|
||||
#### `HardlinkManager._managed_top_dirs`
|
||||
|
||||
*method · line 197*
|
||||
|
||||
```python
|
||||
_managed_top_dirs(files: List[File], roots: Optional[Dict[str, str]], transforms: Optional[Dict[str, str]]=None, reserved_subfolders: Optional[Set[str]]=None) -> Optional[Set[str]]
|
||||
```
|
||||
|
||||
Top-level output folders owned by the tag tree (None = all of them).
|
||||
|
||||
A category with a non-empty root owns that root folder. A category placed
|
||||
at the output root (empty root, e.g. genres) owns its own folders at the
|
||||
root — and, so a genre whose last movie dropped the tag still gets its
|
||||
now-stale folder cleaned, *every* root-level folder is treated as owned
|
||||
except the grouping roots and any `reserved_subfolders` (copy-as-is
|
||||
mirrors, "Tipy dne", …). The obsolete check still only removes links to
|
||||
current pool files, so reserved/foreign folders are doubly protected.
|
||||
|
||||
#### `HardlinkManager.create_structure_for_files`
|
||||
|
||||
*method · line 236*
|
||||
|
||||
```python
|
||||
create_structure_for_files(files: List[File], categories: Optional[List[str]]=None, dry_run: bool=False, category_roots: Optional[Dict[str, str]]=None, category_transforms: Optional[Dict[str, str]]=None, category_filename_templates: Optional[Dict[str, str]]=None) -> Tuple[int, int]
|
||||
```
|
||||
|
||||
Create hardlink structure for given files based on their tags.
|
||||
|
||||
Args:
|
||||
files: List of File objects to process
|
||||
categories: Optional list of categories to include (None = all)
|
||||
dry_run: If True, only simulate without creating actual links
|
||||
category_roots: Optional category → root-folder map (see class doc);
|
||||
overrides `categories` when given.
|
||||
category_transforms: Optional category → transform-name map applied to
|
||||
the tag value when naming its folder (e.g. rating → decade band).
|
||||
category_filename_templates: Optional category → hardlink-name template
|
||||
applied only inside that category's folders.
|
||||
|
||||
Returns:
|
||||
Tuple of (successful_links, failed_links)
|
||||
|
||||
#### `HardlinkManager.mirror_as_is`
|
||||
|
||||
*method · line 294*
|
||||
|
||||
```python
|
||||
mirror_as_is(source_dir: Path, subfolder: str | None=None, dry_run: bool=False) -> Tuple[int, int]
|
||||
```
|
||||
|
||||
Mirror a "copy-as-is" folder 1:1 into the output as a hardlinked clone.
|
||||
|
||||
Recreates the exact directory hierarchy of `source_dir` under
|
||||
`output_dir/subfolder` (or directly under `output_dir` when
|
||||
`subfolder` is None) and hardlinks every file. Curator metadata files
|
||||
(`.!tag` / `.!ftag` / `.!gtag` / `.!index`) are skipped.
|
||||
|
||||
Used for Seriály: the pool structure is the source of truth and is cloned
|
||||
verbatim instead of being rebuilt from tags.
|
||||
|
||||
Returns:
|
||||
Tuple of (successful_links, failed_links)
|
||||
|
||||
#### `HardlinkManager.remove_mirror`
|
||||
|
||||
*method · line 345*
|
||||
|
||||
```python
|
||||
remove_mirror(name: str) -> bool
|
||||
```
|
||||
|
||||
Remove `output_dir/name` entirely (file or folder tree).
|
||||
|
||||
Used to drop a whole mirrored library (or one entry of the shared
|
||||
mixed-library-series bucket) when it falls out of the miniDLNA
|
||||
generation's scope. Mirrors are pure hardlinks, so deleting the folder
|
||||
loses nothing — it's fully regenerable from the pool. `name` may
|
||||
contain "/" to address one entry inside a bucket folder.
|
||||
|
||||
Returns whether anything was actually removed.
|
||||
|
||||
#### `HardlinkManager._fill_special_folder`
|
||||
|
||||
*method · line 365*
|
||||
|
||||
```python
|
||||
_fill_special_folder(chosen: List[File], subfolder: str, dry_run: bool) -> Tuple[int, int]
|
||||
```
|
||||
|
||||
Empty `subfolder` and hardlink `chosen` files into it.
|
||||
|
||||
#### `HardlinkManager.generate_recently_added`
|
||||
|
||||
*method · line 405*
|
||||
|
||||
```python
|
||||
generate_recently_added(files: List[File], count: int=10, subfolder: str='- Nově přidané', dry_run: bool=False) -> Tuple[int, int]
|
||||
```
|
||||
|
||||
(Re)fill `subfolder` with the `count` most recently added movies.
|
||||
|
||||
Ordering is by `File.added_timestamp()` (import time, ctime fallback),
|
||||
newest first. Returns (created_links, failed_links).
|
||||
|
||||
#### `HardlinkManager.generate_random_tips`
|
||||
|
||||
*method · line 420*
|
||||
|
||||
```python
|
||||
generate_random_tips(files: List[File], count: int=10, subfolder: str='Tipy dne', dry_run: bool=False) -> Tuple[int, int]
|
||||
```
|
||||
|
||||
(Re)fill `subfolder` with `count` random files as hardlinks.
|
||||
|
||||
The folder is emptied first so each run picks a fresh random selection.
|
||||
`files` should be the pool movies only (copy-as-is mirrors excluded).
|
||||
|
||||
Returns:
|
||||
Tuple of (created_links, failed_links)
|
||||
|
||||
#### `HardlinkManager._is_same_file`
|
||||
|
||||
*method · line 438*
|
||||
|
||||
```python
|
||||
_is_same_file(path1: Path, path2: Path) -> bool
|
||||
```
|
||||
|
||||
Check if two paths point to the same file (same inode).
|
||||
|
||||
#### `HardlinkManager._get_unique_name`
|
||||
|
||||
*method · line 445*
|
||||
|
||||
```python
|
||||
_get_unique_name(path: Path) -> Path
|
||||
```
|
||||
|
||||
Get a unique filename by adding a numeric suffix.
|
||||
|
||||
#### `HardlinkManager.remove_created_links`
|
||||
|
||||
*method · line 459*
|
||||
|
||||
```python
|
||||
remove_created_links() -> int
|
||||
```
|
||||
|
||||
Remove all hardlinks created by the last operation.
|
||||
|
||||
Returns:
|
||||
Number of links removed
|
||||
|
||||
#### `HardlinkManager._remove_empty_parents`
|
||||
|
||||
*method · line 481*
|
||||
|
||||
```python
|
||||
_remove_empty_parents(path: Path) -> None
|
||||
```
|
||||
|
||||
Remove empty parent directories up to output_dir.
|
||||
|
||||
#### `HardlinkManager.get_preview`
|
||||
|
||||
*method · line 492*
|
||||
|
||||
```python
|
||||
get_preview(files: List[File], categories: Optional[List[str]]=None, category_roots: Optional[Dict[str, str]]=None, category_transforms: Optional[Dict[str, str]]=None, category_filename_templates: Optional[Dict[str, str]]=None) -> List[Tuple[Path, Path]]
|
||||
```
|
||||
|
||||
Get a preview of what links would be created.
|
||||
|
||||
Args:
|
||||
files: List of File objects
|
||||
categories: Optional list of categories to include
|
||||
category_roots: Optional category → root-folder map (overrides
|
||||
`categories` when given).
|
||||
category_transforms: Optional category → transform-name map for folders.
|
||||
category_filename_templates: Optional category → hardlink-name template.
|
||||
|
||||
Returns:
|
||||
List of tuples (source_path, target_path)
|
||||
|
||||
#### `HardlinkManager.find_obsolete_links`
|
||||
|
||||
*method · line 519*
|
||||
|
||||
```python
|
||||
find_obsolete_links(files: List[File], categories: Optional[List[str]]=None, category_roots: Optional[Dict[str, str]]=None, category_transforms: Optional[Dict[str, str]]=None, category_filename_templates: Optional[Dict[str, str]]=None, reserved_subfolders: Optional[Set[str]]=None) -> List[Tuple[Path, Path]]
|
||||
```
|
||||
|
||||
Find hardlinks in the output directory that no longer match file tags.
|
||||
|
||||
Scans the managed parts of the output directory for hardlinks that point
|
||||
to source files but whose path no longer matches the file's current tags.
|
||||
Only the tag-tree's own top-level folders are scanned, so copy-as-is
|
||||
mirrors (e.g. Seriály) are left untouched.
|
||||
|
||||
Args:
|
||||
files: List of File objects (source files)
|
||||
categories: Optional list of categories to check (None = all)
|
||||
category_roots: Optional category → root-folder map (overrides
|
||||
`categories` when given).
|
||||
category_transforms: Optional category → transform-name map for folders.
|
||||
|
||||
Returns:
|
||||
List of tuples (link_path, source_path) for obsolete links
|
||||
|
||||
#### `HardlinkManager.remove_obsolete_links`
|
||||
|
||||
*method · line 608*
|
||||
|
||||
```python
|
||||
remove_obsolete_links(files: List[File], categories: Optional[List[str]]=None, dry_run: bool=False, category_roots: Optional[Dict[str, str]]=None, category_transforms: Optional[Dict[str, str]]=None, category_filename_templates: Optional[Dict[str, str]]=None, reserved_subfolders: Optional[Set[str]]=None) -> Tuple[int, List[Path]]
|
||||
```
|
||||
|
||||
Remove hardlinks that no longer match file tags.
|
||||
|
||||
Args:
|
||||
files: List of File objects
|
||||
categories: Optional list of categories to check
|
||||
dry_run: If True, only return what would be removed
|
||||
category_roots: Optional category → root-folder map (overrides
|
||||
`categories` when given).
|
||||
category_transforms: Optional category → transform-name map for folders.
|
||||
category_filename_templates: Optional category → hardlink-name template.
|
||||
reserved_subfolders: Output top-level folders to never touch (mirrors,
|
||||
"Tipy dne", …).
|
||||
|
||||
Returns:
|
||||
Tuple of (removed_count, list_of_removed_paths)
|
||||
|
||||
#### `HardlinkManager.sync_structure`
|
||||
|
||||
*method · line 655*
|
||||
|
||||
```python
|
||||
sync_structure(files: List[File], categories: Optional[List[str]]=None, dry_run: bool=False, category_roots: Optional[Dict[str, str]]=None, category_transforms: Optional[Dict[str, str]]=None, category_filename_templates: Optional[Dict[str, str]]=None, reserved_subfolders: Optional[Set[str]]=None) -> Tuple[int, int, int, int]
|
||||
```
|
||||
|
||||
Synchronize hardlink structure with current file tags.
|
||||
|
||||
This will:
|
||||
1. Remove hardlinks for removed tags
|
||||
2. Create new hardlinks for new tags
|
||||
|
||||
Args:
|
||||
files: List of File objects
|
||||
categories: Optional list of categories to sync
|
||||
dry_run: If True, only simulate
|
||||
category_roots: Optional category → root-folder map (overrides
|
||||
`categories` when given).
|
||||
category_transforms: Optional category → transform-name map for folders.
|
||||
category_filename_templates: Optional category → hardlink-name template.
|
||||
reserved_subfolders: Output top-level folders to never touch (mirrors,
|
||||
"Tipy dne", …).
|
||||
|
||||
Returns:
|
||||
Tuple of (created, create_failed, removed, remove_failed)
|
||||
|
||||
## Functions
|
||||
|
||||
### `create_hardlink_structure`
|
||||
|
||||
*function · line 707*
|
||||
|
||||
```python
|
||||
create_hardlink_structure(files: List[File], output_dir: Path, categories: Optional[List[str]]=None) -> Tuple[int, int, List[Tuple[Path, str]]]
|
||||
```
|
||||
|
||||
Convenience function to create hardlink structure.
|
||||
|
||||
Args:
|
||||
files: List of File objects to process
|
||||
output_dir: Base directory for output
|
||||
categories: Optional list of categories to include
|
||||
|
||||
Returns:
|
||||
Tuple of (successful_count, failed_count, errors_list)
|
||||
@@ -0,0 +1,77 @@
|
||||
# `src/core/integrity.py`
|
||||
|
||||
Video integrity checking via FFmpeg.
|
||||
|
||||
Detects corrupted or unreadable video files. Two depths:
|
||||
|
||||
- **deep** (default): a full `ffmpeg` decode to the null muxer
|
||||
(`-f null`). It actually decodes every frame, so it catches mid-file data
|
||||
corruption (broken GOPs, truncated streams, bad packets). Thorough but slow —
|
||||
it reads and decodes the whole file.
|
||||
- **quick**: an `ffprobe` container/stream probe. Fast but shallow — it catches
|
||||
an unreadable container, a truncated header or a missing video stream, but not
|
||||
corruption buried inside otherwise-parseable data.
|
||||
|
||||
Both tools ship with FFmpeg; `ffmpeg_available` reports whether they are on the
|
||||
PATH so callers can degrade gracefully.
|
||||
|
||||
Source: [`src/core/integrity.py`](../../src/core/integrity.py)
|
||||
|
||||
## Classes
|
||||
|
||||
### `IntegrityResult`
|
||||
|
||||
*class · line 30*
|
||||
|
||||
```python
|
||||
class IntegrityResult:
|
||||
```
|
||||
|
||||
Outcome of checking one file.
|
||||
|
||||
`ok` — the file decoded/probed cleanly.
|
||||
`checked` — the check actually ran; False means it couldn't (missing tool,
|
||||
missing file), which is *not* the same as "corrupted".
|
||||
`error` — a short reason when not `ok`.
|
||||
|
||||
## Functions
|
||||
|
||||
### `ffmpeg_available`
|
||||
|
||||
*function · line 45*
|
||||
|
||||
```python
|
||||
ffmpeg_available(deep: bool=True) -> bool
|
||||
```
|
||||
|
||||
Is the required FFmpeg tool (ffmpeg for deep, ffprobe for quick) on PATH?
|
||||
|
||||
### `check_video_integrity`
|
||||
|
||||
*function · line 50*
|
||||
|
||||
```python
|
||||
check_video_integrity(path: Path, deep: bool=True, timeout: Optional[int]=None) -> IntegrityResult
|
||||
```
|
||||
|
||||
Check a single video file for corruption.
|
||||
|
||||
Args:
|
||||
path: the video file to check.
|
||||
deep: True → full ffmpeg decode (catches data corruption, slow);
|
||||
False → ffprobe probe (catches unreadable/truncated files, fast).
|
||||
timeout: per-file timeout in seconds (None = no limit).
|
||||
|
||||
Returns:
|
||||
An `IntegrityResult`. `checked=False` when the check could not
|
||||
be run at all (tool or file missing) — distinct from a real failure.
|
||||
|
||||
### `_first_error_line`
|
||||
|
||||
*function · line 108*
|
||||
|
||||
```python
|
||||
_first_error_line(stderr: str) -> str
|
||||
```
|
||||
|
||||
First non-empty stderr line, truncated — a compact reason for the report.
|
||||
@@ -0,0 +1,105 @@
|
||||
# `src/core/jellyfin.py`
|
||||
|
||||
Jellyfin server API client — thin, hybrid integration.
|
||||
|
||||
The NFO + artwork on disk stay authoritative (Jellyfin writes and reads them);
|
||||
this client only does what the disk can't: **trigger a library scan** and **read
|
||||
back what Jellyfin currently sees** (to verify the pool against it). Connection
|
||||
details come from `.env` — `JELLYFIN_URL` and `JELLYFIN_API_KEY` (created in
|
||||
the Jellyfin dashboard). Everything degrades gracefully when they are missing.
|
||||
|
||||
Source: [`src/core/jellyfin.py`](../../src/core/jellyfin.py)
|
||||
|
||||
## Classes
|
||||
|
||||
### `JellyfinMovie`
|
||||
|
||||
*class · line 25*
|
||||
|
||||
```python
|
||||
class JellyfinMovie:
|
||||
```
|
||||
|
||||
A movie as Jellyfin currently sees it (for verification).
|
||||
|
||||
### `JellyfinError`
|
||||
|
||||
*class · line 35*
|
||||
|
||||
```python
|
||||
class JellyfinError(RuntimeError):
|
||||
```
|
||||
|
||||
A Jellyfin request could not be completed (no config, network, status).
|
||||
|
||||
## Functions
|
||||
|
||||
### `base_url`
|
||||
|
||||
*function · line 39*
|
||||
|
||||
```python
|
||||
base_url() -> Optional[str]
|
||||
```
|
||||
|
||||
The configured Jellyfin base URL (no trailing slash), or None.
|
||||
|
||||
### `api_key`
|
||||
|
||||
*function · line 45*
|
||||
|
||||
```python
|
||||
api_key() -> Optional[str]
|
||||
```
|
||||
|
||||
The configured Jellyfin API key, or None.
|
||||
|
||||
### `api_available`
|
||||
|
||||
*function · line 51*
|
||||
|
||||
```python
|
||||
api_available() -> bool
|
||||
```
|
||||
|
||||
True when both the Jellyfin URL and API key are configured.
|
||||
|
||||
### `_request`
|
||||
|
||||
*function · line 56*
|
||||
|
||||
```python
|
||||
_request(method: str, path: str, params: Optional[dict]=None, timeout: Optional[int]=None) -> requests.Response
|
||||
```
|
||||
|
||||
Send one authenticated request to the Jellyfin server (`X-Emby-Token` header). Raises `JellyfinError` when the URL/key are missing or the server can't be reached.
|
||||
|
||||
### `server_version`
|
||||
|
||||
*function · line 74*
|
||||
|
||||
```python
|
||||
server_version(timeout: Optional[int]=None) -> str
|
||||
```
|
||||
|
||||
Return the Jellyfin server version (a quick connectivity check).
|
||||
|
||||
### `scan_all`
|
||||
|
||||
*function · line 80*
|
||||
|
||||
```python
|
||||
scan_all() -> None
|
||||
```
|
||||
|
||||
Trigger a scan of all Jellyfin libraries (picks up on-disk changes).
|
||||
|
||||
### `fetch_movies`
|
||||
|
||||
*function · line 85*
|
||||
|
||||
```python
|
||||
fetch_movies() -> list[JellyfinMovie]
|
||||
```
|
||||
|
||||
Read every movie Jellyfin currently has, with its TMDb id and path.
|
||||
@@ -0,0 +1,83 @@
|
||||
# `src/core/legacy_meta.py`
|
||||
|
||||
Readers for Curator's *pre-index* metadata layouts.
|
||||
|
||||
Before the unified pool index (`.Curator.!index`), Tagger / early Curator stored
|
||||
a movie's metadata in a **per-file sidecar** `.{filename}.!tag` (JSON) sitting
|
||||
next to the video. When importing a file that came from such a pool we can
|
||||
recover its **ČSFD link** (and title / year) from that sidecar — or from an old
|
||||
`.Curator.!index` found by walking up from the file.
|
||||
|
||||
This is read-only recovery for the import dialog; nothing here writes.
|
||||
|
||||
Source: [`src/core/legacy_meta.py`](../../src/core/legacy_meta.py)
|
||||
|
||||
## Classes
|
||||
|
||||
### `LegacyMeta`
|
||||
|
||||
*class · line 25*
|
||||
|
||||
```python
|
||||
class LegacyMeta:
|
||||
```
|
||||
|
||||
What an old sidecar / index record can still tell us about a movie.
|
||||
|
||||
## Functions
|
||||
|
||||
### `_record_to_meta`
|
||||
|
||||
*function · line 34*
|
||||
|
||||
```python
|
||||
_record_to_meta(record: object, source: str) -> Optional[LegacyMeta]
|
||||
```
|
||||
|
||||
Pull the useful fields out of a stored movie record (any schema version).
|
||||
|
||||
### `_from_sidecar`
|
||||
|
||||
*function · line 51*
|
||||
|
||||
```python
|
||||
_from_sidecar(video_path: Path) -> Optional[LegacyMeta]
|
||||
```
|
||||
|
||||
Read the metadata from the per-file `.{name}.!tag` JSON sidecar next to a video, or return `None` when there is no readable sidecar.
|
||||
|
||||
### `_lookup_in_index`
|
||||
|
||||
*function · line 62*
|
||||
|
||||
```python
|
||||
_lookup_in_index(movies: dict, video_path: Path, index_dir: Path) -> Optional[dict]
|
||||
```
|
||||
|
||||
Find the record for `video_path` in an index's `movies` map.
|
||||
|
||||
Tries the exact pool-relative key first, then falls back to a unique
|
||||
basename match (the file may have moved since the index was written).
|
||||
|
||||
### `_from_legacy_index`
|
||||
|
||||
*function · line 79*
|
||||
|
||||
```python
|
||||
_from_legacy_index(video_path: Path) -> Optional[LegacyMeta]
|
||||
```
|
||||
|
||||
Read the metadata from a `.Curator.!index` file found by walking up at most `_MAX_INDEX_WALK_UP` directories from the video. Returns `None` if nothing matches.
|
||||
|
||||
### `read_legacy_meta`
|
||||
|
||||
*function · line 99*
|
||||
|
||||
```python
|
||||
read_legacy_meta(video_path: Path) -> Optional[LegacyMeta]
|
||||
```
|
||||
|
||||
Recover ČSFD link / title / year from a pre-index layout, or `None`.
|
||||
|
||||
Order: a per-file `.{name}.!tag` sidecar next to the video, then a
|
||||
`.Curator.!index` found walking up (max 4 levels) from the file.
|
||||
@@ -0,0 +1,47 @@
|
||||
# `src/core/list_manager.py`
|
||||
|
||||
Sorting of file lists by name or by date added. Small helper used by the file list views.
|
||||
|
||||
Source: [`src/core/list_manager.py`](../../src/core/list_manager.py)
|
||||
|
||||
## Classes
|
||||
|
||||
### `ListManager`
|
||||
|
||||
*class · line 4*
|
||||
|
||||
```python
|
||||
class ListManager:
|
||||
```
|
||||
|
||||
Holds the current sort mode (`name` or `date`) and sorts file lists accordingly.
|
||||
|
||||
#### `ListManager.__init__`
|
||||
|
||||
*method · line 5*
|
||||
|
||||
```python
|
||||
__init__()
|
||||
```
|
||||
|
||||
Start sorting by name.
|
||||
|
||||
#### `ListManager.set_sort`
|
||||
|
||||
*method · line 9*
|
||||
|
||||
```python
|
||||
set_sort(mode: str)
|
||||
```
|
||||
|
||||
Switch the sort mode to `name` or `date`; other values are ignored.
|
||||
|
||||
#### `ListManager.sort_files`
|
||||
|
||||
*method · line 13*
|
||||
|
||||
```python
|
||||
sort_files(files: List[File]) -> List[File]
|
||||
```
|
||||
|
||||
Return the files sorted by name (case-insensitive) or by date. Files without a date go last in date mode.
|
||||
@@ -0,0 +1,27 @@
|
||||
# `src/core/media_utils.py`
|
||||
|
||||
Helpers around media files: loading small icons for Qt widgets and detecting the vertical video resolution with `ffprobe`, mapping it to a `Rozlišení/<height>p` tag.
|
||||
|
||||
Source: [`src/core/media_utils.py`](../../src/core/media_utils.py)
|
||||
|
||||
## Functions
|
||||
|
||||
### `load_icon`
|
||||
|
||||
*function · line 14*
|
||||
|
||||
```python
|
||||
load_icon(path) -> ImageTk.PhotoImage
|
||||
```
|
||||
|
||||
Load an image file and scale it to 16×16 px as a Qt-compatible icon.
|
||||
|
||||
### `add_video_resolution_tag`
|
||||
|
||||
*function · line 19*
|
||||
|
||||
```python
|
||||
add_video_resolution_tag(file_obj: File, tagmanager: TagManager)
|
||||
```
|
||||
|
||||
Read the vertical resolution of a video with `ffprobe` and tag the file with `Rozlišení/<height>p`. Needs FFmpeg on the PATH.
|
||||
@@ -0,0 +1,62 @@
|
||||
# `src/core/migrate.py`
|
||||
|
||||
One-off migration of an existing pool to the Jellyfin naming convention.
|
||||
|
||||
Older pools carry files like `Title.ext` / `Title (rok).ext` with **no TMDb
|
||||
id**. This matches each such movie against TMDb by title + year and proposes a
|
||||
`tmdbid` (and the missing year, which TMDb supplies), so the file can be renamed
|
||||
to `Title (rok) [tmdbid].ext` and Jellyfin can match it deterministically.
|
||||
|
||||
The classification is deliberately **conservative**: only an exact
|
||||
(diacritics-insensitive) title match with a matching year and a single candidate
|
||||
is `auto`; everything else is `ambiguous` for the user to confirm.
|
||||
|
||||
Source: [`src/core/migrate.py`](../../src/core/migrate.py)
|
||||
|
||||
## Classes
|
||||
|
||||
### `MatchProposal`
|
||||
|
||||
*class · line 19*
|
||||
|
||||
```python
|
||||
class MatchProposal:
|
||||
```
|
||||
|
||||
One movie's proposed TMDb match.
|
||||
|
||||
## Functions
|
||||
|
||||
### `normalize`
|
||||
|
||||
*function · line 33*
|
||||
|
||||
```python
|
||||
normalize(text: str) -> str
|
||||
```
|
||||
|
||||
Fold a title for comparison: strip diacritics/punctuation, lowercase.
|
||||
|
||||
### `_year_close`
|
||||
|
||||
*function · line 41*
|
||||
|
||||
```python
|
||||
_year_close(a: Optional[str], b: Optional[str]) -> bool
|
||||
```
|
||||
|
||||
True when two year strings are both numeric and differ by at most one year (release years often disagree by one).
|
||||
|
||||
### `classify`
|
||||
|
||||
*function · line 47*
|
||||
|
||||
```python
|
||||
classify(title: str, year: Optional[str], candidates: list) -> tuple[str, object]
|
||||
```
|
||||
|
||||
Return `(status, chosen)` for a title/year against TMDb candidates.
|
||||
|
||||
`auto` requires an exact normalized-title match, a year within ±1, and that
|
||||
exactly one candidate qualifies. Otherwise `ambiguous` (top hit preselected)
|
||||
or `no_match` when there are no candidates.
|
||||
@@ -0,0 +1,207 @@
|
||||
# `src/core/movie.py`
|
||||
|
||||
The movie object — metadata first, files second.
|
||||
|
||||
A movie is identified by `(title, year, tmdb_id)` and owns **its files**, not
|
||||
the other way round. That inversion is the point: the pool index used to key a
|
||||
record by a file path, so metadata was a passenger on one particular file — and
|
||||
everything about multi-version movies got awkward (which file "holds" the tags,
|
||||
why renaming a grouped movie had to be blocked, why an import could silently
|
||||
create a duplicate instead of a version).
|
||||
|
||||
Here the record is the movie; each playable file is a `MediaFile` under
|
||||
it, told apart by a free-text `label` (`3D HSBS`, `4K`, `Director's
|
||||
Cut`, …). Where those files *live* is then a pure function of the record —
|
||||
`Movie.desired_layout` — so promoting to a folder, demoting back, or
|
||||
renaming the whole thing are all the same operation: recompute and move what
|
||||
doesn't match.
|
||||
|
||||
Layout (Jellyfin's own conventions — https://jellyfin.org/docs/general/server/media/movies/)::
|
||||
|
||||
one video <Library>/Title (rok) [tmdbid-N].ext
|
||||
two or more <Library>/Title (rok) [tmdbid-N]/
|
||||
Title (rok) [tmdbid-N] - Výchozí.ext
|
||||
Title (rok) [tmdbid-N].3D.HSBS.ext
|
||||
|
||||
Every file in a movie folder must start with the folder's exact name; both
|
||||
`" - label"` and `".3D.FMT"` are valid separators, so a 3D file keeps the
|
||||
documented `.3D.<FORMAT>` spelling whether it sits flat or grouped.
|
||||
Subtitles are not media entries — they ride along with their video by filename
|
||||
stem (see `src.core.subtitles`).
|
||||
|
||||
Source: [`src/core/movie.py`](../../src/core/movie.py)
|
||||
|
||||
## Classes
|
||||
|
||||
### `MediaFile`
|
||||
|
||||
*class · line 102*
|
||||
|
||||
```python
|
||||
class MediaFile:
|
||||
```
|
||||
|
||||
One file belonging to a movie (today always a playable video).
|
||||
|
||||
#### `MediaFile.format_3d`
|
||||
|
||||
*property · line 110*
|
||||
|
||||
```python
|
||||
format_3d -> Optional[str]
|
||||
```
|
||||
|
||||
The 3D format this version's label denotes, if any.
|
||||
|
||||
#### `MediaFile.to_record`
|
||||
|
||||
*method · line 114*
|
||||
|
||||
```python
|
||||
to_record(pool_dir: Path) -> dict
|
||||
```
|
||||
|
||||
Serialise for the pool index (path stored pool-relative, POSIX).
|
||||
|
||||
#### `MediaFile.from_record`
|
||||
|
||||
*class method · line 123*
|
||||
|
||||
```python
|
||||
from_record(data: dict, pool_dir: Path) -> 'MediaFile'
|
||||
```
|
||||
|
||||
Build a `MediaFile` from one entry of a movie record's `media` list, resolving its relative path against the pool directory.
|
||||
|
||||
### `Movie`
|
||||
|
||||
*class · line 132*
|
||||
|
||||
```python
|
||||
class Movie:
|
||||
```
|
||||
|
||||
A film in the pool: its identity, its metadata, and its media files.
|
||||
|
||||
#### `Movie.videos`
|
||||
|
||||
*property · line 156*
|
||||
|
||||
```python
|
||||
videos -> list[MediaFile]
|
||||
```
|
||||
|
||||
Playable versions, in a stable order (labelled ones by label).
|
||||
|
||||
#### `Movie.is_grouped`
|
||||
|
||||
*property · line 163*
|
||||
|
||||
```python
|
||||
is_grouped -> bool
|
||||
```
|
||||
|
||||
True when the movie needs its own folder (more than one version).
|
||||
|
||||
#### `Movie.stem`
|
||||
|
||||
*property · line 168*
|
||||
|
||||
```python
|
||||
stem -> str
|
||||
```
|
||||
|
||||
Canonical `Title (rok) [tmdbid-N]` — the folder / filename base.
|
||||
|
||||
Falls back to the bare title when no four-digit year is known (a movie
|
||||
can be imported without one).
|
||||
|
||||
#### `Movie.primary`
|
||||
|
||||
*property · line 182*
|
||||
|
||||
```python
|
||||
primary -> Optional[MediaFile]
|
||||
```
|
||||
|
||||
The version that represents the movie (first by `videos`).
|
||||
|
||||
#### `Movie.desired_layout`
|
||||
|
||||
*method · line 187*
|
||||
|
||||
```python
|
||||
desired_layout(pool_dir: Path) -> list[tuple[MediaFile, Path]]
|
||||
```
|
||||
|
||||
`(media, where it belongs)` for every file, from the record alone.
|
||||
|
||||
The whole flat-vs-folder rule lives here: one video sits directly in
|
||||
the library, two or more move into `<Library>/<stem>/`. Callers move
|
||||
whatever doesn't already match — which makes promotion, demotion and
|
||||
renaming the same operation.
|
||||
|
||||
#### `Movie.to_record`
|
||||
|
||||
*method · line 208*
|
||||
|
||||
```python
|
||||
to_record(pool_dir: Path) -> dict
|
||||
```
|
||||
|
||||
Serialise the movie into the dictionary stored in the pool index, with media paths relative to the pool directory.
|
||||
|
||||
#### `Movie.from_record`
|
||||
|
||||
*class method · line 227*
|
||||
|
||||
```python
|
||||
from_record(key: str, data: dict, pool_dir: Path, tagmanager=None) -> 'Movie'
|
||||
```
|
||||
|
||||
Build a `Movie` from an index record stored under `key`. Tags are resolved through `tagmanager` when one is given.
|
||||
|
||||
## Functions
|
||||
|
||||
### `normalize_3d_label`
|
||||
|
||||
*function · line 53*
|
||||
|
||||
```python
|
||||
normalize_3d_label(label: object) -> Optional[str]
|
||||
```
|
||||
|
||||
The 3D format a version label denotes (upper-case), or `None`.
|
||||
|
||||
Lets a plain label double as the 3D marker — `"3D HSBS"` typed in the
|
||||
version dialog and `"hsbs"` picked from a dropdown mean the same thing.
|
||||
|
||||
### `media_filename`
|
||||
|
||||
*function · line 63*
|
||||
|
||||
```python
|
||||
media_filename(stem: str, label: str, ext: str, *, grouped: bool) -> str
|
||||
```
|
||||
|
||||
Filename for one media file of a movie whose canonical stem is `stem`.
|
||||
|
||||
- no label → `stem.ext` (a lone, plain video)
|
||||
- 3D label → `stem.3D.<FORMAT>.ext` (Jellyfin's documented 3D form)
|
||||
- anything else → `stem - label.ext`
|
||||
|
||||
`grouped` only decides whether an empty label is allowed: inside a movie
|
||||
folder every file gets one, so it falls back to `DEFAULT_VERSION_LABEL`.
|
||||
|
||||
### `identity_key`
|
||||
|
||||
*function · line 84*
|
||||
|
||||
```python
|
||||
identity_key(tmdb_id: object=None, title: str='', year: object=None) -> str
|
||||
```
|
||||
|
||||
The pool-index key for a movie — `tmdb-<id>`, else a title/year slug.
|
||||
|
||||
Keying by identity (rather than by file path) is what lets a second file of
|
||||
the same film find its movie instead of becoming a separate entry.
|
||||
@@ -0,0 +1,119 @@
|
||||
# `src/core/naming.py`
|
||||
|
||||
Pool file naming convention.
|
||||
|
||||
Every movie in the pool is named `Title (YYYY) [tmdbid-N].ext` — a title, a
|
||||
four-digit year in parentheses, and (when known) the TMDb id in brackets, which
|
||||
is exactly the form Jellyfin matches on. The `[tmdbid-N]` part is optional: a
|
||||
plain `Title (YYYY)` is still valid (Jellyfin then matches by name). The year
|
||||
lets two same-named films coexist and makes the filename self-describing.
|
||||
|
||||
A 3D movie additionally carries a `.3D.<FORMAT>` tag right before the
|
||||
extension (Jellyfin's own convention — https://jellyfin.org/docs/general/server/media/movies/):
|
||||
`Title (YYYY) [tmdbid-N].3D.HSBS.ext`. `FORMAT` is one of
|
||||
`THREE_D_FORMATS`; Jellyfin reads it case-insensitively but Curator always
|
||||
writes it upper-case for a single canonical form.
|
||||
|
||||
These helpers build, validate and parse the stem (the filename without its
|
||||
extension).
|
||||
|
||||
Source: [`src/core/naming.py`](../../src/core/naming.py)
|
||||
|
||||
## Functions
|
||||
|
||||
### `is_valid_year`
|
||||
|
||||
*function · line 35*
|
||||
|
||||
```python
|
||||
is_valid_year(year: object) -> bool
|
||||
```
|
||||
|
||||
True when `year` is (or stringifies to) exactly four digits.
|
||||
|
||||
### `canonical_pool_stem`
|
||||
|
||||
*function · line 40*
|
||||
|
||||
```python
|
||||
canonical_pool_stem(title: str, year: object, tmdb_id: object=None, format_3d: object=None) -> str
|
||||
```
|
||||
|
||||
Build the canonical pool stem `Title (YYYY)` (+ ` [tmdbid-N]` /
|
||||
`.3D.<FORMAT>` if given).
|
||||
|
||||
Raises:
|
||||
ValueError: the title is empty, the year is not four digits, a given
|
||||
`tmdb_id` is not a positive integer, or `format_3d` isn't one
|
||||
of `THREE_D_FORMATS`.
|
||||
|
||||
### `parse_pool_stem`
|
||||
|
||||
*function · line 73*
|
||||
|
||||
```python
|
||||
parse_pool_stem(stem: str) -> tuple[str, int] | None
|
||||
```
|
||||
|
||||
Split a canonical stem into `(title, year)`; `None` if it isn't one.
|
||||
|
||||
A trailing `[tmdbid-N]` is tolerated and dropped (use `parse_tmdb_id`
|
||||
to read it), so the returned title is always the clean title.
|
||||
|
||||
### `parse_tmdb_id`
|
||||
|
||||
*function · line 85*
|
||||
|
||||
```python
|
||||
parse_tmdb_id(stem: str) -> int | None
|
||||
```
|
||||
|
||||
Read the `[tmdbid-N]` from a canonical stem, or `None` if absent.
|
||||
|
||||
### `parse_3d_format`
|
||||
|
||||
*function · line 93*
|
||||
|
||||
```python
|
||||
parse_3d_format(stem: str) -> str | None
|
||||
```
|
||||
|
||||
Read the `.3D.<FORMAT>` tag from a canonical stem (upper-cased), or
|
||||
`None` if absent.
|
||||
|
||||
### `is_canonical_pool_stem`
|
||||
|
||||
*function · line 102*
|
||||
|
||||
```python
|
||||
is_canonical_pool_stem(stem: str) -> bool
|
||||
```
|
||||
|
||||
True when `stem` matches the `Title (YYYY)` convention.
|
||||
|
||||
### `year_from_filename`
|
||||
|
||||
*function · line 107*
|
||||
|
||||
```python
|
||||
year_from_filename(name: str) -> str | None
|
||||
```
|
||||
|
||||
Best-effort four-digit year guessed from a raw (release) filename.
|
||||
|
||||
Prefers a parenthesised year (`… (1999) …`) and otherwise falls back to the
|
||||
last standalone 19xx/20xx run. Used only to pre-fill the import dialog — the
|
||||
user confirms it. Returns the year as a string, or `None`.
|
||||
|
||||
### `three_d_format_from_filename`
|
||||
|
||||
*function · line 129*
|
||||
|
||||
```python
|
||||
three_d_format_from_filename(name: str) -> str | None
|
||||
```
|
||||
|
||||
Best-effort 3D format guessed from a raw (release) filename, e.g.
|
||||
`Avatar.2009.3D.HSBS.BluRay.mkv` → `"HSBS"`. Unlike `parse_3d_format`
|
||||
this doesn't require the rest of the name to be a canonical pool stem — used
|
||||
to pre-fill a version label while importing. Returns `None` if absent.
|
||||
@@ -0,0 +1,103 @@
|
||||
# `src/core/nfo.py`
|
||||
|
||||
NFO reader — Curator's metadata source after ČSFD.
|
||||
|
||||
Jellyfin (configured to save metadata into the media folders) writes a Kodi-style
|
||||
`.nfo` next to each movie plus artwork. Curator only **reads** it — to drive the
|
||||
miniDLNA genre tree and to fill the year/rating used by folder grouping. It never
|
||||
writes NFO (Jellyfin owns that).
|
||||
|
||||
For a flat pool file `Title (rok) [tmdbid].mkv` the sidecar is
|
||||
`Title (rok) [tmdbid].nfo`; a per-movie folder may instead hold `movie.nfo`.
|
||||
Both are found. Missing or unparsable NFO → `None` (the movie simply has no
|
||||
genres until Jellyfin has scanned it).
|
||||
|
||||
Source: [`src/core/nfo.py`](../../src/core/nfo.py)
|
||||
|
||||
## Classes
|
||||
|
||||
### `NfoData`
|
||||
|
||||
*class · line 20*
|
||||
|
||||
```python
|
||||
class NfoData:
|
||||
```
|
||||
|
||||
The bits of a movie `.nfo` Curator uses.
|
||||
|
||||
#### `NfoData.field_values`
|
||||
|
||||
*method · line 31*
|
||||
|
||||
```python
|
||||
field_values(name: str) -> list[str]
|
||||
```
|
||||
|
||||
Values for a tag-schema source field (genres/countries/year/rating).
|
||||
|
||||
## Functions
|
||||
|
||||
### `find_nfo`
|
||||
|
||||
*function · line 44*
|
||||
|
||||
```python
|
||||
find_nfo(video_path: Path) -> Optional[Path]
|
||||
```
|
||||
|
||||
Locate the `.nfo` for a movie: sibling `<stem>.nfo` or `movie.nfo`.
|
||||
|
||||
### `_texts`
|
||||
|
||||
*function · line 56*
|
||||
|
||||
```python
|
||||
_texts(root: ET.Element, tag: str) -> list[str]
|
||||
```
|
||||
|
||||
All non-empty stripped texts of the child elements called `tag`.
|
||||
|
||||
### `_parse_rating`
|
||||
|
||||
*function · line 60*
|
||||
|
||||
```python
|
||||
_parse_rating(root: ET.Element) -> Optional[int]
|
||||
```
|
||||
|
||||
Read a 0–10 NFO rating and scale it to 0–100 (to match the old bands).
|
||||
|
||||
### `_parse_tmdb_id`
|
||||
|
||||
*function · line 74*
|
||||
|
||||
```python
|
||||
_parse_tmdb_id(root: ET.Element) -> Optional[int]
|
||||
```
|
||||
|
||||
The TMDb id from a Kodi NFO: a `<uniqueid type="tmdb">` element, or a legacy `<tmdbid>` element. `None` if neither holds a number.
|
||||
|
||||
### `_parse_csfd_url`
|
||||
|
||||
*function · line 82*
|
||||
|
||||
```python
|
||||
_parse_csfd_url(root: ET.Element) -> Optional[str]
|
||||
```
|
||||
|
||||
A ČSFD film URL from the NFO, if any.
|
||||
|
||||
Czech Kodi scrapers record ČSFD as `<uniqueid type="csfd">` (a bare id or a
|
||||
full URL) or a legacy `<csfdid>` / `<csfd>` element. A bare id is expanded
|
||||
to `https://www.csfd.cz/film/<id>/`.
|
||||
|
||||
### `read_nfo`
|
||||
|
||||
*function · line 105*
|
||||
|
||||
```python
|
||||
read_nfo(video_path: Path) -> Optional[NfoData]
|
||||
```
|
||||
|
||||
Read the movie's `.nfo` into `NfoData`, or None if absent/broken.
|
||||
@@ -0,0 +1,191 @@
|
||||
# `src/core/pool_index.py`
|
||||
|
||||
Unified metadata index for the Curator pool.
|
||||
|
||||
Instead of one sidecar file per movie, the whole pool keeps a single JSON index
|
||||
at `<pool>/.Curator.!index`. Curator owns the pool (it inserts/removes files
|
||||
itself), so files never move behind its back and a central index is safe.
|
||||
|
||||
The index also carries **library-level settings** (`tag_schema`,
|
||||
`copyasis_folders`) under a `settings` section, so everything describing a
|
||||
particular pool travels with the pool. The global config keeps only app data
|
||||
(window state, MRU) and the pointers to where the pool/output live.
|
||||
|
||||
On-disk shape (**v2**)::
|
||||
|
||||
{"version": 2,
|
||||
"settings": {...},
|
||||
"movies": {"tmdb-603": {"title": …, "media": [{"role", "label", "file"}, …]}}}
|
||||
|
||||
Records are keyed by the movie's **identity** (`tmdb-<id>`, or a title/year
|
||||
slug when there's no id) and own their files — see `src.core.movie`. The
|
||||
original **v1** shape keyed a record by one file's pool-relative path, which
|
||||
made metadata a passenger on that file; `PoolIndex.load` migrates such an
|
||||
index in place (backing the old file up first). Migration only rewrites
|
||||
metadata — no file on disk is moved, because a one-video movie is already in
|
||||
its v2 position.
|
||||
|
||||
Source: [`src/core/pool_index.py`](../../src/core/pool_index.py)
|
||||
|
||||
## Classes
|
||||
|
||||
### `PoolIndex`
|
||||
|
||||
*class · line 35*
|
||||
|
||||
```python
|
||||
class PoolIndex:
|
||||
```
|
||||
|
||||
Single-file JSON metadata store for all files in a pool.
|
||||
|
||||
#### `PoolIndex.__init__`
|
||||
|
||||
*method · line 38*
|
||||
|
||||
```python
|
||||
__init__(pool_dir: Path) -> None
|
||||
```
|
||||
|
||||
Open the index of the pool at `pool_dir` (loads it, migrating an older version if needed).
|
||||
|
||||
#### `PoolIndex._key`
|
||||
|
||||
*method · line 45*
|
||||
|
||||
```python
|
||||
_key(file_path: Path) -> str
|
||||
```
|
||||
|
||||
Pool-relative POSIX key for a file path.
|
||||
|
||||
#### `PoolIndex.load`
|
||||
|
||||
*method · line 53*
|
||||
|
||||
```python
|
||||
load() -> None
|
||||
```
|
||||
|
||||
Load the index from disk (missing/corrupt index = empty).
|
||||
|
||||
A v1 (path-keyed) index is migrated to v2 in place — the old file is
|
||||
copied to `.Curator.!index.v1.bak` first.
|
||||
|
||||
#### `PoolIndex._migrate_v1_to_v2`
|
||||
|
||||
*method · line 76*
|
||||
|
||||
```python
|
||||
_migrate_v1_to_v2() -> None
|
||||
```
|
||||
|
||||
Re-key path-keyed records into identity-keyed movie records.
|
||||
|
||||
Each v1 record described one file; several of them can describe one
|
||||
movie (the interim multi-version layout put them in a shared folder),
|
||||
so they are merged by identity. Metadata only — nothing is moved on
|
||||
disk, since a v1 movie's files already sit where v2 wants them.
|
||||
|
||||
#### `PoolIndex.save`
|
||||
|
||||
*method · line 139*
|
||||
|
||||
```python
|
||||
save() -> None
|
||||
```
|
||||
|
||||
Persist the index to disk.
|
||||
|
||||
#### `PoolIndex.get_setting`
|
||||
|
||||
*method · line 150*
|
||||
|
||||
```python
|
||||
get_setting(key: str, default=None)
|
||||
```
|
||||
|
||||
Return a library-level setting stored in the index.
|
||||
|
||||
#### `PoolIndex.set_setting`
|
||||
|
||||
*method · line 154*
|
||||
|
||||
```python
|
||||
set_setting(key: str, value) -> None
|
||||
```
|
||||
|
||||
Upsert a library-level setting and persist the index.
|
||||
|
||||
#### `PoolIndex.get_movie`
|
||||
|
||||
*method · line 163*
|
||||
|
||||
```python
|
||||
get_movie(key: str) -> dict | None
|
||||
```
|
||||
|
||||
Return the movie record under `key`, or None.
|
||||
|
||||
#### `PoolIndex.set_movie`
|
||||
|
||||
*method · line 167*
|
||||
|
||||
```python
|
||||
set_movie(key: str, data: dict) -> None
|
||||
```
|
||||
|
||||
Upsert a movie record and persist the index.
|
||||
|
||||
#### `PoolIndex.delete_movie`
|
||||
|
||||
*method · line 172*
|
||||
|
||||
```python
|
||||
delete_movie(key: str) -> None
|
||||
```
|
||||
|
||||
Remove a movie record (if present) and persist the index.
|
||||
|
||||
#### `PoolIndex.rekey_movie`
|
||||
|
||||
*method · line 177*
|
||||
|
||||
```python
|
||||
rekey_movie(old_key: str, new_key: str) -> None
|
||||
```
|
||||
|
||||
Move a record to a new identity key (e.g. its TMDb id was set/fixed).
|
||||
|
||||
#### `PoolIndex.all_movies`
|
||||
|
||||
*method · line 184*
|
||||
|
||||
```python
|
||||
all_movies() -> dict[str, dict]
|
||||
```
|
||||
|
||||
Every movie record, keyed by identity.
|
||||
|
||||
#### `PoolIndex.record_for`
|
||||
|
||||
*method · line 188*
|
||||
|
||||
```python
|
||||
record_for(file_path: Path) -> dict | None
|
||||
```
|
||||
|
||||
The movie record that owns `file_path`, or None if untracked.
|
||||
|
||||
#### `PoolIndex.find_movie_by_media`
|
||||
|
||||
*method · line 193*
|
||||
|
||||
```python
|
||||
find_movie_by_media(file_path: Path) -> tuple[str, dict] | None
|
||||
```
|
||||
|
||||
`(key, record)` of the movie owning `file_path`, or None.
|
||||
|
||||
The reverse lookup a file needs now that records are keyed by identity
|
||||
rather than by path.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,102 @@
|
||||
# `src/core/subtitles.py`
|
||||
|
||||
External subtitle sidecars — attach, discover, keep in sync on rename.
|
||||
|
||||
Curator does not mux/embed subtitles. Jellyfin plays an **external** subtitle
|
||||
file natively as long as it shares the video's filename stem, with an optional
|
||||
language code and flag suffix::
|
||||
|
||||
Title (rok) [tmdbid-N].mkv
|
||||
Title (rok) [tmdbid-N].cs.srt # language
|
||||
Title (rok) [tmdbid-N].cs.forced.srt # + a flag (forced / sdh / default)
|
||||
|
||||
Subtitle presence is a **filesystem fact**, like NFO — nothing is cached in the
|
||||
pool index. What Curator does own: copying a picked file into place with the
|
||||
right name (`attach_subtitle`), and moving a subtitle along whenever its
|
||||
video gets renamed so it never silently detaches (`rename_subtitles_with`).
|
||||
|
||||
Source: [`src/core/subtitles.py`](../../src/core/subtitles.py)
|
||||
|
||||
## Classes
|
||||
|
||||
### `SubtitleFile`
|
||||
|
||||
*class · line 30*
|
||||
|
||||
```python
|
||||
class SubtitleFile:
|
||||
```
|
||||
|
||||
One subtitle sidecar found next to a video.
|
||||
|
||||
#### `SubtitleFile.label`
|
||||
|
||||
*property · line 38*
|
||||
|
||||
```python
|
||||
label -> str
|
||||
```
|
||||
|
||||
Human-readable `jazyk (flag)` for display, e.g. `cs (forced)`.
|
||||
|
||||
## Functions
|
||||
|
||||
### `subtitle_filename`
|
||||
|
||||
*function · line 44*
|
||||
|
||||
```python
|
||||
subtitle_filename(video_stem: str, ext: str, language: Optional[str]=None, flag: Optional[str]=None) -> str
|
||||
```
|
||||
|
||||
Canonical subtitle filename: `{video_stem}[.language][.flag]{ext}`.
|
||||
|
||||
### `_parse_subtitle_name`
|
||||
|
||||
*function · line 57*
|
||||
|
||||
```python
|
||||
_parse_subtitle_name(video_stem: str, filename: str) -> Optional[tuple[Optional[str], Optional[str]]]
|
||||
```
|
||||
|
||||
`(language, flag)` if `filename` is a subtitle sidecar of `video_stem`.
|
||||
|
||||
### `find_subtitles`
|
||||
|
||||
*function · line 83*
|
||||
|
||||
```python
|
||||
find_subtitles(video_path: Path) -> list[SubtitleFile]
|
||||
```
|
||||
|
||||
Every external subtitle sidecar sitting next to `video_path`, sorted.
|
||||
|
||||
### `attach_subtitle`
|
||||
|
||||
*function · line 101*
|
||||
|
||||
```python
|
||||
attach_subtitle(video_path: Path, source: Path, *, language: Optional[str]=None, flag: Optional[str]=None, move: bool=False) -> Path
|
||||
```
|
||||
|
||||
Copy (or move) `source` next to `video_path`, named to match it.
|
||||
|
||||
An existing subtitle already at that exact name (e.g. replacing the Czech
|
||||
track) is overwritten; nothing else next to the video is touched.
|
||||
|
||||
Raises:
|
||||
ValueError: `source`'s extension isn't a recognised subtitle format.
|
||||
|
||||
### `rename_subtitles_with`
|
||||
|
||||
*function · line 128*
|
||||
|
||||
```python
|
||||
rename_subtitles_with(old_video_path: Path, new_video_path: Path) -> list[Path]
|
||||
```
|
||||
|
||||
Move every subtitle sidecar of `old_video_path` to match `new_video_path`.
|
||||
|
||||
Call this wherever a video gets renamed/moved so an attached subtitle never
|
||||
silently detaches (its stem would stop matching and Jellyfin would drop it).
|
||||
No-op when the video's path didn't actually change. Returns the new paths.
|
||||
@@ -0,0 +1,37 @@
|
||||
# `src/core/tag.py`
|
||||
|
||||
The `Tag` value object: a `(category, name)` pair identified by its `full_path` (`Category/Name`). Hashable and comparable, so tags can live in sets and be compared across the tag manager and the files.
|
||||
|
||||
Source: [`src/core/tag.py`](../../src/core/tag.py)
|
||||
|
||||
## Classes
|
||||
|
||||
### `Tag`
|
||||
|
||||
*class · line 1*
|
||||
|
||||
```python
|
||||
class Tag:
|
||||
```
|
||||
|
||||
A tag: a category and a name. Two tags are equal when both parts match.
|
||||
|
||||
#### `Tag.__init__`
|
||||
|
||||
*method · line 2*
|
||||
|
||||
```python
|
||||
__init__(category: str, name: str)
|
||||
```
|
||||
|
||||
Create a tag in `category` with the given `name`.
|
||||
|
||||
#### `Tag.full_path`
|
||||
|
||||
*property · line 7*
|
||||
|
||||
```python
|
||||
full_path
|
||||
```
|
||||
|
||||
The tag as `Category/Name`, the form used in the index and in strings.
|
||||
@@ -0,0 +1,107 @@
|
||||
# `src/core/tag_manager.py`
|
||||
|
||||
`TagManager`: the registry of all tag categories and their tags. Initialised with the default categories; the GUI tag tree and the AND-filter read from it.
|
||||
|
||||
Source: [`src/core/tag_manager.py`](../../src/core/tag_manager.py)
|
||||
|
||||
## Classes
|
||||
|
||||
### `TagManager`
|
||||
|
||||
*class · line 16*
|
||||
|
||||
```python
|
||||
class TagManager:
|
||||
```
|
||||
|
||||
Registry of tag categories (`tags_by_category`). Adding a tag also creates its category; removing the last tag of a category removes the category.
|
||||
|
||||
#### `TagManager.__init__`
|
||||
|
||||
*method · line 17*
|
||||
|
||||
```python
|
||||
__init__()
|
||||
```
|
||||
|
||||
Create the registry and fill it with the default tags.
|
||||
|
||||
#### `TagManager._init_default_tags`
|
||||
|
||||
*method · line 21*
|
||||
|
||||
```python
|
||||
_init_default_tags()
|
||||
```
|
||||
|
||||
Initialize default tags (ratings and colors)
|
||||
|
||||
#### `TagManager.add_category`
|
||||
|
||||
*method · line 27*
|
||||
|
||||
```python
|
||||
add_category(category: str)
|
||||
```
|
||||
|
||||
Create an empty category if it does not exist yet.
|
||||
|
||||
#### `TagManager.remove_category`
|
||||
|
||||
*method · line 31*
|
||||
|
||||
```python
|
||||
remove_category(category: str)
|
||||
```
|
||||
|
||||
Delete a category and all its tags, if it exists.
|
||||
|
||||
#### `TagManager.add_tag`
|
||||
|
||||
*method · line 35*
|
||||
|
||||
```python
|
||||
add_tag(category: str, name: str) -> Tag
|
||||
```
|
||||
|
||||
Register a tag in `category` (the category is created when missing) and return it.
|
||||
|
||||
#### `TagManager.remove_tag`
|
||||
|
||||
*method · line 41*
|
||||
|
||||
```python
|
||||
remove_tag(category: str, name: str)
|
||||
```
|
||||
|
||||
Unregister one tag. When it was the category's last tag, the category is removed too.
|
||||
|
||||
#### `TagManager.get_all_tags`
|
||||
|
||||
*method · line 48*
|
||||
|
||||
```python
|
||||
get_all_tags()
|
||||
```
|
||||
|
||||
Every registered tag as a `Category/Name` string.
|
||||
|
||||
#### `TagManager.get_categories`
|
||||
|
||||
*method · line 52*
|
||||
|
||||
```python
|
||||
get_categories()
|
||||
```
|
||||
|
||||
The names of all registered categories.
|
||||
|
||||
#### `TagManager.get_tags_in_category`
|
||||
|
||||
*method · line 55*
|
||||
|
||||
```python
|
||||
get_tags_in_category(category: str) -> list[Tag]
|
||||
```
|
||||
|
||||
Get tags in category, sorted by predefined order for default categories
|
||||
@@ -0,0 +1,160 @@
|
||||
# `src/core/tmdb.py`
|
||||
|
||||
TMDb (The Movie Database) client — Curator's identity source.
|
||||
|
||||
Replaces ČSFD. Its only job is to turn a **TMDb id** (or a title/year search)
|
||||
into a canonical **title + year** for naming the pool file. The rich metadata
|
||||
and artwork are fetched by **Jellyfin itself** — Curator does not need them here.
|
||||
|
||||
Needs a free API key in `TMDB_API_KEY` (loaded from `.env`). Everything
|
||||
degrades gracefully when the key or the network is missing: `api_available`
|
||||
reports readiness and the lookups raise `TmdbError` with a clear message.
|
||||
|
||||
Source: [`src/core/tmdb.py`](../../src/core/tmdb.py)
|
||||
|
||||
## Classes
|
||||
|
||||
### `TmdbResult`
|
||||
|
||||
*class · line 29*
|
||||
|
||||
```python
|
||||
class TmdbResult:
|
||||
```
|
||||
|
||||
A resolved TMDb entry — just enough to name the pool file.
|
||||
|
||||
### `TmdbDetails`
|
||||
|
||||
*class · line 39*
|
||||
|
||||
```python
|
||||
class TmdbDetails:
|
||||
```
|
||||
|
||||
Full TMDb data used to (re)generate a movie's tags (genres etc.).
|
||||
|
||||
Exposes `field_values` so it plugs into the tag schema the same way the
|
||||
NFO reader does (fields: genres / countries / year / rating).
|
||||
|
||||
#### `TmdbDetails.field_values`
|
||||
|
||||
*method · line 53*
|
||||
|
||||
```python
|
||||
field_values(name: str) -> list[str]
|
||||
```
|
||||
|
||||
The values of one tag source field (`genres`, `countries`, `year`, `rating`, …) as a list of strings, ready to become tags. Unknown field names return an empty list.
|
||||
|
||||
### `TmdbError`
|
||||
|
||||
*class · line 65*
|
||||
|
||||
```python
|
||||
class TmdbError(RuntimeError):
|
||||
```
|
||||
|
||||
A TMDb request could not be completed (no key, network, bad status).
|
||||
|
||||
## Functions
|
||||
|
||||
### `api_key`
|
||||
|
||||
*function · line 69*
|
||||
|
||||
```python
|
||||
api_key() -> Optional[str]
|
||||
```
|
||||
|
||||
The configured TMDb API key, or None.
|
||||
|
||||
### `api_available`
|
||||
|
||||
*function · line 75*
|
||||
|
||||
```python
|
||||
api_available() -> bool
|
||||
```
|
||||
|
||||
True when a TMDb API key is configured.
|
||||
|
||||
### `_get`
|
||||
|
||||
*function · line 80*
|
||||
|
||||
```python
|
||||
_get(path: str, params: dict) -> dict
|
||||
```
|
||||
|
||||
GET a TMDb API path with the configured key and language. Raises `TmdbError` on a missing key, a network failure or an error response.
|
||||
|
||||
### `_year_of`
|
||||
|
||||
*function · line 98*
|
||||
|
||||
```python
|
||||
_year_of(date_str: Optional[str]) -> Optional[str]
|
||||
```
|
||||
|
||||
The four-digit year at the start of a TMDb date (`YYYY-MM-DD`), or `None`.
|
||||
|
||||
### `lookup_movie`
|
||||
|
||||
*function · line 104*
|
||||
|
||||
```python
|
||||
lookup_movie(tmdb_id: int) -> Optional[TmdbResult]
|
||||
```
|
||||
|
||||
Resolve a movie by its TMDb id → title + year, or None if it has no title.
|
||||
|
||||
### `movie_details`
|
||||
|
||||
*function · line 113*
|
||||
|
||||
```python
|
||||
movie_details(tmdb_id: int) -> TmdbDetails
|
||||
```
|
||||
|
||||
Full movie data from TMDb (genres/countries/rating/year), localized to cs.
|
||||
|
||||
### `lookup_tv`
|
||||
|
||||
*function · line 127*
|
||||
|
||||
```python
|
||||
lookup_tv(tmdb_id: int) -> Optional[TmdbResult]
|
||||
```
|
||||
|
||||
Resolve a series by its TMDb id → name + first-air year, or None.
|
||||
|
||||
### `lookup`
|
||||
|
||||
*function · line 136*
|
||||
|
||||
```python
|
||||
lookup(tmdb_id: int, kind: str='movie') -> Optional[TmdbResult]
|
||||
```
|
||||
|
||||
Resolve a TMDb id as a movie (default) or a series (`kind="tv"`).
|
||||
|
||||
### `search_movie`
|
||||
|
||||
*function · line 141*
|
||||
|
||||
```python
|
||||
search_movie(query: str, year: Optional[str]=None, limit: int=5) -> list[TmdbResult]
|
||||
```
|
||||
|
||||
Search movies by title (optionally narrowed by year); best matches first.
|
||||
|
||||
### `search_tv`
|
||||
|
||||
*function · line 161*
|
||||
|
||||
```python
|
||||
search_tv(query: str, year: Optional[str]=None, limit: int=5) -> list[TmdbResult]
|
||||
```
|
||||
|
||||
Search series by name (optionally narrowed by first-air year).
|
||||
@@ -0,0 +1,43 @@
|
||||
# `src/core/transforms.py`
|
||||
|
||||
Tag-schema value transforms for miniDLNA folder names.
|
||||
|
||||
Applied when building a folder name from a tag value — the tag keeps its exact
|
||||
value (e.g. rating 87 → tag "Hodnocení/87"), while the folder groups it
|
||||
(→ "…/80–89 %"). Config-driven and extensible via `TAG_TRANSFORMS`.
|
||||
|
||||
Source: [`src/core/transforms.py`](../../src/core/transforms.py)
|
||||
|
||||
## Functions
|
||||
|
||||
### `rating_band`
|
||||
|
||||
*function · line 11*
|
||||
|
||||
```python
|
||||
rating_band(rating: int) -> str
|
||||
```
|
||||
|
||||
Bucket a 0–100 rating into a ten-point band label (e.g. "80–89 %").
|
||||
|
||||
The top bucket spans 90–100 % so a perfect 100 still lands in a band.
|
||||
|
||||
### `_decade_band`
|
||||
|
||||
*function · line 21*
|
||||
|
||||
```python
|
||||
_decade_band(value) -> str
|
||||
```
|
||||
|
||||
`decade_band` transform: pull the leading integer and bucket it.
|
||||
|
||||
### `apply_transform`
|
||||
|
||||
*function · line 35*
|
||||
|
||||
```python
|
||||
apply_transform(value: str, transform: Optional[str]) -> str
|
||||
```
|
||||
|
||||
Apply a named tag-schema transform to a value, returning a string.
|
||||
@@ -0,0 +1,17 @@
|
||||
# `src/core/utils.py`
|
||||
|
||||
Small filesystem helpers shared across the core modules.
|
||||
|
||||
Source: [`src/core/utils.py`](../../src/core/utils.py)
|
||||
|
||||
## Functions
|
||||
|
||||
### `list_files`
|
||||
|
||||
*function · line 3*
|
||||
|
||||
```python
|
||||
list_files(folder_path: str | Path) -> list[Path]
|
||||
```
|
||||
|
||||
All regular files below `folder_path`, searched recursively. Raises `NotADirectoryError` when the path is not a folder.
|
||||
@@ -0,0 +1,53 @@
|
||||
# `Curator.py`
|
||||
|
||||
Application entry point. Creates the shared `FileManager` and `TagManager`, applies the OS certificate store for HTTPS, and starts the Qt main window.
|
||||
|
||||
Source: [`Curator.py`](../../Curator.py)
|
||||
|
||||
## Classes
|
||||
|
||||
### `State`
|
||||
|
||||
*class · line 21*
|
||||
|
||||
```python
|
||||
class State:
|
||||
```
|
||||
|
||||
Holds the application's shared services: one `TagManager` and one `FileManager` built on top of it. Created once in `main()`.
|
||||
|
||||
#### `State.__init__`
|
||||
|
||||
*method · line 22*
|
||||
|
||||
```python
|
||||
__init__() -> None
|
||||
```
|
||||
|
||||
Create the tag manager and the file manager that uses it.
|
||||
|
||||
## Functions
|
||||
|
||||
### `_use_system_certificates`
|
||||
|
||||
*function · line 7*
|
||||
|
||||
```python
|
||||
_use_system_certificates() -> None
|
||||
```
|
||||
|
||||
Make HTTPS (ČSFD fetching) use the OS certificate store.
|
||||
|
||||
Required behind corporate SSL inspection, where the proxy's root CA is in the
|
||||
Windows trust store but not in certifi's bundle. Best-effort; ignored if the
|
||||
optional `truststore` package is unavailable.
|
||||
|
||||
### `main`
|
||||
|
||||
*function · line 27*
|
||||
|
||||
```python
|
||||
main() -> None
|
||||
```
|
||||
|
||||
Program entry point: installs the system certificate store, builds the `State` and runs the Qt application on its file manager and tag manager.
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user