Compare commits

7 Commits
96 changed files with 17544 additions and 2096 deletions
+9 -5
View File
@@ -1,4 +1,8 @@
# Python
# --- Rust ---------------------------------------------------------------
# Cargo build output (debug/release builds, incremental caches)
/target/
# --- Python -------------------------------------------------------------
__pycache__/
*.pyc
*.pyo
@@ -11,15 +15,16 @@ __pycache__/
.ruff_cache/
.pytest_cache/
# Environment and secrets
.env
# Logs (loguru file sink)
logs/
# PyInstaller build artifacts (dist/ is committed — see DESIGN_DOCUMENT §13)
build/
# --- Common -------------------------------------------------------------
# Environment and secrets
.env
# Curator runtime metadata (user/machine-specific, regenerated)
.Curator.!gtag
*.!gtag
@@ -35,4 +40,3 @@ AGENTS.md
CLAUDE.md
DESIGN_DOCUMENT.md
.claude/
+459 -47
View File
@@ -21,66 +21,478 @@ Each version entry uses these sections (include only those that apply):
## Unreleased
### Changed
- **The standalone build is now a single file.** `Curator.spec` produces one
self-contained `dist/Curator` executable (onefile) instead of a
`dist/Curator/` folder of libraries. It unpacks to a temp folder on every
start, so launching takes a few seconds longer.
### Dependencies
- Added `pyinstaller` to the dev group — `uv run pyinstaller Curator.spec`
now works out of the box (it was used for builds but never declared).
## 1.13.0 — 2026-10-12
### Changed
- **Custom attributes are gone from the GUI.** The attribute table in
*Upravit film* and *Filmy → "Nastavit atribut…"* (menu and context menu) are
removed. Values already stored keep working in miniDLNA filename templates
(`File.name_context`); they just can't be edited from the app anymore.
- **The pool index is keyed by the movie, not by a file path** (index **v2**,
`src/core/movie.py`). A record used to describe one *file*, so a movie's
metadata was a passenger on one particular file — which is why the "primary
version" had to carry the tags, why removing it needed a metadata-rescue
hack, and why renaming a multi-version movie had to be disabled outright.
Now the record **is** the movie (`tmdb-603`, or a diacritics-folded
`name-<title>-<year>` slug without an id) and owns its files:
```json
"tmdb-19995": {"title": "Avatar", "year": "2009", "tmdb_id": 19995,
"library": "Filmy", "tags": [...], "csfd_url": …,
"media": [{"role": "video", "label": "", "file": "…"},
{"role": "video", "label": "3D HSBS", "file": "…"}]}
```
Every version of a film therefore reads and writes **the same** metadata —
adding a version inherits it, removing any version (including the one shown
in the table) leaves it intact, and the rescue hack (plus the interim
`File.is_primary_version` flag) is gone. A newly seen file also joins an
existing record of the same identity instead of blanking its metadata.
`Movie.desired_layout` derives where the files belong from the record alone
(one video → flat in the library, two or more → its own
`Title (rok) [tmdbid]/` folder; a 3D label keeps Jellyfin's documented
`.3D.<FORMAT>` spelling in both layouts, other labels use `" - label"`) —
**not wired in yet**: `add_movie_version` / `remove_movie_version` still lay
files out themselves, and the edit dialog doesn't apply a recomputed layout
on rename. A move now updates the record's `library` as well.
`check_data_consistency` compares disk against the records' media paths.
`File` is now a *view* on one media entry of a record (same public
interface); `PoolIndex` gained `get_movie`/`set_movie`/`delete_movie`/
`rekey_movie`/`all_movies`/`find_movie_by_media`/`record_for`, and
**migrates a v1 index on first open** — metadata only, **no file is moved**
(a v1 movie already sits where v2 wants it), with the old index copied to
`.Curator.!index.v1.bak` first.
- **"Odebrat z poolu" removes the whole movie** (`FileManager.remove_movie`):
on a multi-version row it deletes every version, their subtitles and the
version folder — it used to delete only the file shown in the table and
leave the rest behind as a half-empty group. Subtitles of a flat movie are
removed too now.
### Added
- **Upravit film: one "Média filmu" section with a folder mode.** A flat
movie (one file in the library root, plus subtitles) shows as "Samostatný
film"; adding a second medium moves it into its own folder automatically.
The folder has two modes, chosen with a **"Verze / Díly"** switch:
**versions** — alternatives, each with its own popisek (free text such as
"kinoverze", "zkrácená verze", or a 3D preset via **"✏️ Popisek / 3D…"**);
**parts** — pieces played back to back, listed in order and reordered with
**↑ / ↓**. "➕ Přidat médium…" adds by the chosen mode (for a flat movie the
switch decides what the second file becomes). Switching an existing folder
converts it: to parts numbers the files in their current order; to versions
makes the first file the default and asks a popisek for each other one.
New `FileManager.movie_group_mode`, `ordered_media` (parts sorted
numerically, `part2` before `part10`), `move_movie_part`,
`set_movie_group_mode`; all of them relabel the folder through temporary
names, so swapping two parts never overwrites a file.
- **Movie parts (Jellyfin multi-part / stacking)** — a film that TMDb lists
as one movie but that you have as several files. *Upravit film* gets
**"➕ Přidat díl…"**: the film moves into its own folder (Jellyfin requires
it for parts, even with no other versions) as `Folder-part1.ext`,
`Folder-part2.ext`, … and Jellyfin plays them back to back as one movie —
unlike versions, which it offers as alternatives. Under the hood a part is
a version whose popisek is `partN`, so everything that handles versions
handles parts too (removing down to one demotes to flat, subtitles follow
each part, renaming works). Any Jellyfin spelling typed as a popisek
(`part2`, `CD2`, `pt 2`, `disc-2`) is written back as `-part2` and the
existing file becomes `part1` — so the import collision dialog's "Přidat
jako verzi" with popisek `CD2` works the same way. The version table shows
"díl N", the main table "Film · N díly". Parts and versions can't be
combined in one folder yet (`add_movie_part` refuses). New
`FileManager.add_movie_part`, `movie.normalize_part_label` / `part_label`.
- **Upravit seriál: TMDb lookup, optionally from a movie id.** The series
editor gets a **"🔎 Dohledat"** button (title + year from TMDb, by the typed
id or by the title) and a **"Metadata projednou z ID filmu (ne seriálu)"**
checkbox for series that TMDb lists only as a movie (a miniseries cut
together, a documentary in parts): ticked, the lookup uses the movie
endpoints instead of the TV ones. The movie id is then **not** written into
the series folder's `[tmdbid-N]` — Jellyfin's TV provider would resolve it as
a TV id and match an unrelated show — so Jellyfin matches the series by
title and year instead (`SeriesEditDialog._lookup`, `.tmdb_id`).
- **Import movies: assign a version right in the batch** (e.g. a 2D + a 3D
file of the same film picked together). A row's new **"🔗 Přiřadit jako
verzi…"** button picks which other row in the dialog it's a version of and
a popisek (pre-filled with the 3D formát detected in its filename via
`naming.three_d_format_from_filename` — e.g. `Avatar.2009.3D.HSBS.mkv` →
`3D HSBS` — free text otherwise, same `VersionLabelDialog` as *Upravit
film → Verze*); its own název/rok/TMDb ID/ČSFD fields grey out since they
come from the film it's a version of. On import the target row lands
first via `import_movie`, then every linked row is added onto it via
`FileManager.add_movie_version` — one movie with several versions instead
of two separate (duplicate) movies. Removing a row it points to un-links
it back to standalone; a row already pointed at by others can't itself
become a version (no chains).
- **Import movies: "3D formát" per row**, for the opposite case — a film
whose *only* file is entirely 3D, with no plain version to make it a
version *of*. A combo box (pre-filled from the filename, same detection as
the popisek above) tags a standalone row's import straight to
`Title (rok) [tmdbid].3D.<FORMÁT>.ext`, exactly like the 3D formát dropdown
in *Upravit film* — no more importing plain, then editing it in just to add
the tag. Disabled (and reset) the moment a row is linked as a version or
gains one as a follower, since a grouped movie's 3D-ness belongs on the
specific version's popisek instead, never on the group as a whole.
- **Duplicate check** (*Nástroje → Kontrola duplicit…*,
`FileManager.find_duplicates`). Lists movies that are in the pool more than
once — a version folder counts as one movie, never as a duplicate of itself.
Flagged when two movies share the **same TMDb id** (e.g. `X.mkv` and
`X.3D.HSBS.mkv` lying flat side by side, or one film in two libraries), the
**same title + year** (one copy paired with TMDb, the other not), or a
**byte-identical file size** (a plain copy); overlapping hits form one group
with every reason listed. Per file the dialog offers **"Sloučit jako verzi
do…"** (`FileManager.merge_as_version`: moves the file into the other movie's
version folder — promoting it from flat when needed, a 3D file pre-labelled
`3D <FORMÁT>` — carries its subtitles along, keeps the target's metadata and
only adds missing tags / ČSFD link, and drops the duplicate's own record) or
**"Smazat vybraný z poolu…"**.
- **Multi-version movies are marked in the main table**: `▣` before the bold
name, `Film · N verze` in the Typ column, the size summed over all versions
and a tooltip listing every file; the detail panel shows the folder and the
version count.
- **Episodes split into physical parts** — an old recording cut into several
files, as opposed to `E01-E02` (several *episodes* in one file) —
get Jellyfin's own `.ptN` stacking suffix: `SxxEyy - {série}.ptN.ext`.
Recognised from *cd/dvd/part/pt/disc/disk* + a number already in the source
filename (`Show.S01E02.pt2.mkv`, `Show S01E01-part-1.mkv`, …), or typed by
hand in the import dialog's SxxEyy column as `S01E02P3` (new
`series.parse_episode_part`/`parse_episode_code`); carried through
scanning, import, "Napravit názvy souborů…" and the series tree.
- **Přehled seriálů → "Odebrat z poolu…" now targets whatever is selected in
the tree**, not always the whole series: pick a single episode to delete
just that file (+ its subtitles), a season to delete the whole `Season NN`/
`Specials` folder, or the series itself as before. New
`FileManager.remove_episodes`/`remove_season`; a season folder left
completely empty by the removal is cleaned up, one still holding something
else is left alone.
- **Multiple versions of one movie** (Jellyfin's own "multiple versions" —
2D+3D, 4K+1080p, Director's Cut, …). A movie is identified by its
`(title, year, tmdbid)` and stays a **flat file** while it has exactly one
playable version (today's layout, unchanged). Adding a second version
**promotes it automatically** into its own folder — Jellyfin's required
shape, every file prefixed with the exact folder name:
```
Title (rok) [tmdbid]/
Title (rok) [tmdbid] - Výchozí.ext
Title (rok) [tmdbid] - 3D HSBS.ext
```
*Upravit film* gets a **„Verze"** list — **"➕ Přidat verzi…"** (pick a file,
then a small `VersionLabelDialog`: a **3D formát** preset — its label becomes
exactly `3D <FORMÁT>`, the same tokens Jellyfin scans for in a flat
`.3D.<FORMÁT>` filename — or free text for a non-3D version like `4K` /
`Director's Cut`), **"✏️ Přejmenovat verzi…"** (`FileManager.rename_movie_version`
— same dialog, pre-filled with the current label; renames the file + its
subtitles in place, shared metadata untouched) and **"➖ Odebrat verzi"**
(removing down to one version **demotes back to flat** automatically). A
flat movie always shows one row too — labelled **„Výchozí"**, a placeholder
since it has no on-disk label yet; renaming is disabled until a second
version exists (there's nothing to rename it *from* until then). The table
still shows **one row per movie** — a group's other versions live only in
that list, not as
separate rows (`FileManager.visible_movies`; the row shown is the plain /
`Výchozí` version, `primary_version`). All versions share **one metadata
record** (see *Changed → index v2*), so a new version inherits the title/
tags/ČSFD/atributy and removing any version — even the one shown in the table
— leaves them intact. Renaming/moving/retitling a grouped movie isn't
supported yet (those fields are disabled in the edit dialog; remove the
version or edit the files directly). `FileManager.is_movie_group_member` /
`list_movie_versions` / `primary_version` / `add_movie_version` /
`remove_movie_version`. Works in `mixed` libraries too (see *Fixed* below
for how a version folder there is told apart from a series).
- **3D movie tagging** (`naming.py`, Jellyfin's own convention —
https://jellyfin.org/docs/general/server/media/movies/). *Upravit film* gets
a **„3D formát"** dropdown (žádný / HSBS / FSBS / HTAB / FTAB / MVC),
pre-filled from the current filename; picking one renames the pool file to
`Title (rok) [tmdbid].3D.<FORMÁT>.ext`. The tag is preserved across every
future rename (editing other fields, "Přejmenovat dle metadat") the same way
`[tmdbid-N]` already is — `canonical_pool_stem` / `parse_3d_format` /
`THREE_D_FORMATS`; `apply_movie_edits(format_3d=…)` (omitted = keep, `""` =
clear). This is for a movie that's **only ever going to have one file** —
deliberately **not** exposed in the import dialog: importing a second file
for a movie already in the pool that way would create a plain, ungrouped
duplicate instead of a proper version (see "Multiple movie versions" above,
which is the right tool for 2D+3D of the same film). `import_movie` still
takes `format_3d=…` as a building block, just with no UI wired to it.
- **miniDLNA hardcopies `homevideo` libraries too.** A `homevideo` library
(no TMDb id, arbitrary internal structure) has no genre tags to build a tree
from — same reasoning as series — so it's now mirrored whole into
`output/<název knihovny>` alongside the series copy-as-is mirrors
(`FileManager.mirror_as_is_libraries` = `series` + `homevideo`; the
series-only `copyasis_folders` used by the library organizer is unchanged).
Respects the per-library miniDLNA selection.
- **miniDLNA covers series inside `mixed` libraries.** A `mixed` library's
top-level files are movies (genre-tagged as usual); any **series in its
subfolders** now get hardcopied into `output/Seriály/<vlastní název>/…` —
no genre tags apply to a series, so it bypasses the tag tree entirely, same
mechanism as a dedicated series library's copy-as-is mirror but scoped to one
series' own folder. Respects the per-library miniDLNA selection below.
`FileManager.minidlna_scope` now returns a third element, the in-scope
mixed-library `series.Series` list.
- **miniDLNA: choose which libraries generate.** *miniDLNA → "Knihovny pro
generování…"* — a checklist of the pool's libraries (persisted in the pool
index, default = all, matching the previous behaviour). "Vygenerovat miniDLNA
strom" then only includes movies from the checked libraries in the genre tree
/ Tipy dne / Nově přidané, and only mirrors checked series/homevideo
libraries — the dialog warns up front that unchecking one removes its output.
A movie library's genre-tree links are swept as obsolete the normal way; a
mirrored (series/homevideo) library or a mixed-library series has **no**
obsolete-link mechanism of its own, so deselecting one is handled explicitly:
`FileManager.minidlna_prune_targets` works out what fell out of scope (the
whole-library output folder, or — since a mixed-library series and a
same-named real library can share one output bucket like `Seriály` — just the
stale entries in it) and `HardlinkManager.remove_mirror` deletes it.
`FileManager.minidlna_libraries` / `set_minidlna_libraries` / `minidlna_scope`.
- **External subtitle management for movies** (`src/core/subtitles.py`). Jellyfin
plays an external `.srt`/`.ass`/`.ssa`/`.sub`/`.vtt` natively when it shares the
video's filename stem (`Title (rok) [tmdbid].cs.srt`, optionally `.forced`/
`.sdh`/`.default`) — no muxing needed. *Upravit film* lists a movie's attached
subtitles and can **"➕ Přidat titulky…"** (pick a file, tag its language,
mark forced; copied in and named to match) or remove one. Subtitles now
**follow the movie through every rename/move** (`rename_movie`,
`apply_movie_edits`, `move_to_library`, `rename_to_canonical`, and
`rename_episodes_to_canonical` for series) instead of silently detaching —
`subtitles.rename_subtitles_with`. `FileManager.list_subtitles` /
`attach_subtitle` / `remove_subtitle`. Presence is read live from disk (like
NFO), nothing is cached in the pool index.
- **Movies and series in one list.** The main table lists both as rows with a
**Typ** column (Film / Seriál); size is the file size for a movie and the sum of
the episodes for a series. Double-click (or the context menu) opens the right
editor by type — a movie → the movie parameters dialog, a series → the series
editor with the per-episode SxxEyy tree. The right detail panel handles both.
Library filter + search cover both; a tag filter narrows movies (series carry
no tags).
- **Detail panel on the right** of the table — the selected item's filename (or
series folder), Czech title, library and size (`_update_details`).
- **Consolidated edit dialogs.** *Filmy → "Upravit film…"* (double-click) opens
one dialog for every Curator-owned movie parameter — title, year, TMDb ID (with
"Dohledat z TMDb"), library, date, attributes and a ČSFD link — applied at once
(rename `Title (rok) [tmdbid].ext`, move between libraries, set metadata) via
`FileManager.apply_movie_edits`. A series opens an editor (in *Přehled seriálů*
or from the list): edit its title/year/TMDb ID/library **and each episode's
season/episode number**, renaming the folder and laying episodes out canonically
(`FileManager.apply_series_edits`).
- **Library organizer** (*Pool → "Knihovny…"*). One dialog manages the pool's
top-level libraries as ``{name, kind}`` — kinds **movie / series / mixed /
homevideo** — stored in a unified ``libraries`` setting in the pool index
(`FileManager.libraries` / `set_libraries`; legacy `movie_libraries` /
`copyasis_folders` are derived from it and synthesised for old pools). Setting
the pool creates a folder for every library. Replaces the separate "Knihovny
(rozdělení)" and "Copy-as-is složky" dialogs. **mixed** and **homevideo** can be
defined now; homevideo scanning lands in a later phase.
- **Series import into `mixed` libraries.** `series_libraries` (import target +
`load_series` scan) covers kind `series` **and** `mixed`, so episodes can be
imported into a mixed library and show up in the series overview/editor.
- **ČSFD link stored as a second DB pointer** (`File.csfd_url`) — captured at
import (a column in the movie import dialog) and editable in *Upravit film*.
- **ČSFD → TMDb title bridge** (`src/core/csfd.py`, lean revival of the removed
scraper). A direct TMDb search on a Czech distribution title is unreliable
(remakes, localized names); ČSFD indexes Czech release names well **and** lists
each film's *original* title, so the flow is: filename → ČSFD search → ČSFD
film page → original title + year → TMDb search → tmdbid.
- Import dialog: a second button **"🎬 Dohledat přes ČSFD"** next to "Dohledat
z TMDb". Per row it uses the filled ČSFD odkaz, else searches ČSFD by the
cleaned filename; fills ČSFD odkaz / TMDb ID / Název / Rok (blue = suggested).
- Migration: a movie carrying a `csfd_url` is resolved through ČSFD first, so
`match_pool_to_tmdb` searches on the original title + year (the proposal is
tinted blue with a *"přes ČSFD: …"* tooltip). `FileManager.resolve_via_csfd`.
- Search is scoped to the *Filmy* section and rejects two-segment episode
hrefs (a bare-title search otherwise returned a series episode); a yearful
query that misses is retried without the year. The original title is only
taken from a `.film-names` entry whose flag matches the country of origin —
a Czech film (which lists only foreign alt-titles there) keeps its `<h1>`
title instead of a random Slovenian/English one.
- Scope: a title→tmdbid bridge only — **no ČSFD tags/genres return** (those
stay TMDb-via-Jellyfin/NFO). ČSFD's Anubis proof-of-work wall is solved
(SHA-256, stdlib) and replayed over a shared session so a batch pays it once.
- **Import rows pre-filled from metadata next to the source file.** When a picked
video sits next to metadata from an earlier Curator/Tagger setup, the import
row is seeded on open (no network) — **ČSFD odkaz**, Název, Rok (blue =
suggested, verify):
- `src/core/legacy_meta.py` — reads Curator's **pre-index** layouts: a per-file
`.{filename}.!tag` JSON sidecar (Tagger / early Curator; key `csfd_link`),
else a `.Curator.!index` found walking up the tree (matched by relative key,
then by unique basename). These win for the ČSFD link.
- `nfo.read_nfo` also parses a ČSFD id/URL from `<uniqueid type="csfd">` (bare
id or full URL) / legacy `<csfdid>` / `<csfd>` (`nfo.NfoData.csfd_url`), and
fills what the sidecar/index don't carry (notably the TMDb id).
- `ImportMoviesDialog._prefill_row`.
- **Import: "Vynechat řádky bez TMDb ID".** A checkbox in the import dialog that
drops every row without a TMDb ID on OK — they are skipped (and no longer need
a year). Rows that would be skipped are shown struck-through/italic as the box
is ticked; the import summary reports how many were skipped
(`ImportMoviesDialog.skipped_no_tmdb`).
- **Episode import: "📁 Přidat složku…".** The episode-import dialog can now take
a whole series folder and scans it **recursively** (all subfolders — `Season
01`, `Season 02`, …), adding every video file it finds (sidecars skipped);
season/episode still parsed per file. A canonical folder name
(`Title (rok) [tmdbid-N]`) also pre-fills the series title / year / TMDb ID.
The dialog now opens empty (add files or a folder from inside it).
`series.collect_episode_files`, `ImportEpisodesDialog._add_folder`.
- **Episode import: editable SxxEyy per row.** The `SxxEyy` column is now an
editable field, pre-filled from the filename and colour-coded (green valid /
red unparsable / plain empty). Fill in an unrecognised episode (or fix a
mis-parsed one) right in the import dialog — `S01E05` / `1x05` both work; OK
warns before importing if any row still has no valid code.
`ImportEpisodesDialog.episode_codes` feeds a new `codes=` override on
`FileManager.import_episodes`.
- **Remove a series from the pool.** *Seriály → "Odebrat z poolu…"*, the table
context menu (series-only selection, one or more), and a button in *Přehled
seriálů* delete a whole series folder — every season and episode — after a
confirm. `FileManager.remove_series` (refuses a folder outside the pool).
- **Bulk-fix series episode filenames.** *Seriály → "Napravit názvy souborů…"*
goes over every series in the pool and renames each episode to
**`SxxEyy - {název seriálu}.ext`** in the right `Season NN` / `Specials`
folder, then removes the season folders emptied by the moves. Episodes with no
recognisable `SxxEyy` are left in place and listed (fix them in the series
editor). `FileManager.rename_all_series_to_canonical`.
- **Pool migration to TMDb** (`src/core/migrate.py`, *Nástroje → "Spárovat pool
s TMDb (migrace)…"*). Matches existing movies (no `tmdbid` yet) against TMDb by
title + year and proposes an id, filling a missing year from TMDb. Conservative
classification — **jisté** (exact diacritics-insensitive title + matching year,
unique), **nejednoznačné** (pick from candidates), **bez shody**. A review
dialog applies the confident ones or the ones you pick → renames to
`Title (rok) [tmdbid].ext`. Already-migrated files are skipped (re-runnable).
`FileManager.match_pool_to_tmdb` / `assign_tmdb_id`.
### Fixed
- **Adding/removing a movie version could silently drop its 3D formát.**
Promoting a flat, 3D-only movie to a group (adding a second version)
always renamed the pre-existing file to `" - Výchozí"`, discarding
whatever `.3D.<FORMAT>` tag it had — same the other way round: demoting
back to flat when the sole survivor's own popisek *was* a 3D formát always
produced a plain filename. Both now read the file's own current formát
(`naming.parse_3d_format` / `movie.normalize_3d_label`) and carry it over
automatically, so a movie can freely gain and lose versions — 3D-only,
2D-only, several formats at once — without ever losing a formát that
wasn't touched in that particular step. Affects `add_movie_version` (its
`existing_label` param, and `merge_as_version`'s `keep_label` which now
derives from it too) and `remove_movie_version`.
- **A version's popisek can be left empty** — for a plain "2D" file with
nothing else to call it — instead of being rejected; it becomes
`movie.DEFAULT_VERSION_LABEL` ("Výchozí") the same way the pre-existing
file already did on promotion. Applies to `add_movie_version`,
`rename_movie_version`, `merge_as_version`, and everywhere they're driven
from the GUI (*Upravit film → Verze*, *Kontrola duplicit*, *Importovat
filmy…*'s "Přiřadit jako verzi…") — none of them require typing "Výchozí"
by hand anymore. `VersionLabelDialog`'s popisek field says so directly.
- **Renaming or demoting a version could wipe the film's tags.** Every version
holds its own in-memory copy of the shared metadata; one loaded before a tag
was added wrote its stale copy back when it was renamed, relabelled or left
as the sole survivor. Those paths now use the new `File.save_location`, which
re-reads the movie record first so only the file's own path and popisek
change.
- **A movie's version couldn't be removed when the edit dialog was open on
it.** *Upravit film* opens on the movie's primary version, and removing that
very file was blocked ("Nelze odebrat verzi, kterou právě upravuješ…") — so
in a folder whose first file alphabetically was the one to go, it couldn't go
at all. It can now: the dialog carries on with a sibling (all versions share
one movie record) and *Upravit film* applies its edits to that file.
- **Parts and versions could be mixed in one folder**, e.g. `-part1`, `-part2`
and a version labelled `díl 1` — and the version table showed both the part
and the version as "díl 1". `add_movie_version` and `rename_movie_version`
now refuse a folder holding both kinds, and the version table has separate
**Typ** (díl N / verze / Výchozí) and **Popisek** (the exact label) columns.
- **The default version of a grouped movie is named bare.** In a folder of
two or more versions the plain one is now `Folder/Folder.ext`, not
`Folder/Folder - Výchozí.ext`: "Výchozí" is only the table's name for it.
Other versions stay `Folder - <popisek>.ext`. The version table and index
label read it as the default. Pool files already named `- Výchozí` are
renamed with **Nástroje → "Sjednotit názvy výchozích verzí…"**
(`FileManager.normalize_default_version_names`; subtitles and metadata follow,
a folder whose bare name is already taken is left alone).
- **Importing a film already in the pool added a root-level duplicate instead
of a version.** `pooled_with_stem` compared only the filename stem, so the
files of a grouped movie (`Folder - 3D HSBS.mkv`) never matched the film's
identity and the import wrote `Title (rok) [tmdbid].mkv` beside the folder.
Matching is now by movie identity (flat file, any version of a group, or a
3D-tagged copy). When the batch import finds such a film it asks **"Přidat
jako verzi" / "Nahradit celý film novým" / "Přeskočit"** (no "keep both as
separate films" — same TMDb id means one movie record, so that's just a
duplicate) and, for "Přidat jako verzi", asks for each file's popisek
(pre-filled from its 3D formát). `import_movie(on_conflict="version",
version_label=…)` does the adding; `"replace"` removes a grouped movie as a
whole rather than one of its files, and "suffix" no longer lands beside a
folder of the same identity.
- Repair for an already-created root duplicate: **Nástroje → Kontrola duplicit**
flags it (same TMDb id) and **"Sloučit jako verzi do…"** folds it into the
group.
### Changed
- **Tags come from TMDb, not NFO.** *Filmy → "Načíst žánry z TMDb"*
(`FileManager.refresh_tags_from_tmdb`, `tmdb.movie_details`) fetches
genres/year/country/rating by the movie's tmdbid and regenerates its tags;
generating the miniDLNA tree refreshes from TMDb first. Genres come localized
(cs-CZ); production countries stay TMDb's English names. Removes the wait for
Jellyfin to write NFO before the genre tree is populated.
- **Movie edit dialog: dropped the free-form tags field**, added **ČSFD odkaz**
(genre tags come from TMDb; manual tag editing isn't needed in the Jellyfin flow).
- **Episode filename is `SxxEyy - {série}.ext`** inside `Series (rok) [tmdbid]/
Season NN/`. The release string is deliberately dropped — identity is the
`SxxEyy` code and Jellyfin fetches the episode title/metadata from TMDb
(matched by the code + the folder's `[tmdbid-N]`); an empty/unsafe title
collapses to `SxxEyy.ext`. `episode_filename` takes the series title,
`is_canonical_episode(path, series_title)`, and import / rename / edit all
follow (`series.sanitize_title`).
### Fixed
- **Movies can live in mixed libraries.** The import/edit/move dialogs and the
library filter offered only `movie`-kind libraries (falling back to a default
"Filmy"), so a pool of only mixed/series/homevideo libraries had nowhere to put
a movie. They now use `libraries_accepting_movies` (kind **movie or mixed**),
and `load_pool_movies` scans a mixed library's **top-level** files as movies
(its subfolders are series). Movies can be imported into / moved between mixed
(subfolders are series). Movies can be imported into / moved between mixed
libraries.
- **Cleanup of leftover library folders.** After editing libraries, Curator now
- **Cleanup of leftover library folders.** After editing libraries, Curator
offers to delete **empty** pool folders that are no longer a library, and lists
non-empty ones (never auto-deleted). `FileManager.orphan_pool_folders` /
`remove_empty_pool_folder`.
- **Adding a movie version in a `mixed` library silently broke the movie.**
Its version folder and a series folder are both `Title (rok) [tmdbid]/` —
indistinguishable by name — and a `mixed` library's subfolders used to be
read as series unconditionally (`load_pool_movies` only scanned that
library's **top-level** files as movies). So the movie vanished from the
Film list and reappeared, mis-parsed, as a "series" — no longer reachable
via *Upravit film*. Told apart by **shape** now instead: a series always
nests its episodes in a `Season NN`/`Specials` folder, a movie's version
folder never does (`series.looks_like_series_folder`). A `mixed` library's
canonical-shaped subfolder without one is read as a movie by
`load_pool_movies`/`is_movie_group_member`, and skipped by `scan_series`
(`skip_movie_folders=True` there, so a `movie`-and-`series` pair with the
same identity can sit side by side without either one leaking into the
other's listing) — no manual per-item type switch needed.
### Added
- **ČSFD link stored as a second DB pointer** (`File.csfd_url`) — captured at
import (new column in the movie import dialog) and editable in *Upravit film*.
Kept only as a pointer next to the TMDb id; nothing is fetched from ČSFD.
- **Series import into `mixed` libraries.** `series_libraries` (import target +
`load_series` scan) now covers kind `series` **and** `mixed`, so episodes can be
imported into a mixed library and show up in the series overview/editor.
### Dependencies
- **Poetry and pyenv are replaced by uv.** `pyproject.toml` was recreated with
`uv init` / `uv add` (no `[tool.poetry]`, no `[build-system]`), `poetry.lock`
is replaced by `uv.lock`, and `.venv` is built on a uv-managed Python 3.14.
Commands are now `uv sync` / `uv add` / `uv run` (`DESIGN_DOCUMENT_PYTHON.md`
§11). Dependency lower bounds moved up to the versions uv resolved; Poetry's
upper bounds (`<7.0.0`, …) are gone.
- **ruff 0.15 → 0.17** (also mypy 2.4, pytest 9.1, PySide6 6.12, Pillow 12.3,
python-dotenv 1.2.4). ruff 0.17 enables a much wider default rule set; the
whole codebase was brought in line with it — see *Changed* below.
### Changed
- **Movie edit dialog: dropped the free-form tags field**, added **ČSFD odkaz**.
(Genre tags come from the NFO; manual tag editing isn't needed in the Jellyfin
flow.)
- **Episode filenames now keep the original name:** `SxxEyy - {original}.ext`
(was `Series SxxEyy.ext`), inside `Series (rok) [tmdbid]/Season NN/`. The
`SxxEyy` code is stripped from the original before re-applying, so repeated
renames don't double it (`series.episode_body`); `episode_filename` /
`is_canonical_episode` and the series import/rename/edit paths follow.
### Added
- **Library organizer** (*Pool → "Knihovny…"*). One dialog manages the pool's
top-level libraries as ``{name, kind}`` — kinds **movie / series / mixed /
homevideo** — stored in a unified ``libraries`` setting in the pool index
(`FileManager.libraries` / `set_libraries`; the legacy `movie_libraries` /
`copyasis_folders` are now derived from it and synthesised for old pools).
Setting the pool creates a folder for every library. Replaces the separate
"Knihovny (rozdělení)" and "Copy-as-is složky" dialogs. **mixed** and
**homevideo** can be defined now; their scanning/generation lands in the next
phases (for now only movie + series libraries are processed).
- **Pool migration to TMDb** (phase 6; `src/core/migrate.py`, *Nástroje →
"Spárovat pool s TMDb (migrace)…"*). Matches existing movies (no `tmdbid` yet)
against TMDb by title + year and proposes an id, filling a missing year from
TMDb. Classification is conservative — **jisté** (exact diacritics-insensitive
title + matching year, unique), **nejednoznačné** (pick from candidates),
**bez shody**. A review dialog lets you apply just the confident ones or the
ones you pick; renames to `Title (rok) [tmdbid].ext`. Already-migrated files are
skipped, so it is re-runnable. `FileManager.match_pool_to_tmdb` /
`assign_tmdb_id` drive it.
- **Consolidated edit dialogs.** *Filmy → "Upravit film…"* (double-click a row)
opens one dialog for every Curator-owned parameter of a movie — title, year,
TMDb ID (with "Dohledat z TMDb"), library, date, free-form attributes and tags
— and applies them at once (rename `Title (rok) [tmdbid].ext`, move between
libraries, set metadata) via `FileManager.apply_movie_edits`. In *Seriály →
"Přehled seriálů…"* a series now opens an editor (double-click / "Upravit
seriál…"): edit the series' title/year/TMDb ID/library **and each episode's
season/episode number**, which renames the folder and lays the episodes out as
`Series SxxEyy.ext` (`FileManager.apply_series_edits`). The double-click on a
movie now edits it; "Otevřít" (playback) stays in the context menu.
- **Codebase cleaned up for ruff 0.17's default rules** (`uv run ruff check`
passes). Mostly mechanical: PEP 604/585 annotations (`X | None`, `list[...]`
instead of `Optional`/`List`), sorted imports, stale `# noqa` removed, unused
unpacked variables prefixed with `_`, silent `try/except/pass` blocks
rewritten as `contextlib.suppress`, dialog class-level tables in
`src/ui/qt_app.py` annotated `ClassVar`. `scripts/tipy_dne.py` is now
executable (it has a shebang).
- **Three blanket `except Exception` handlers are narrowed** — anything outside
the listed types now propagates instead of being swallowed:
`config.load_global_config` (`OSError`/`ValueError`/`TypeError`),
the ffprobe resolution lookup in `media_utils`
(`OSError`/`SubprocessError`/`ValueError`), and the window-geometry restore
in `qt_app` (`ValueError`/`AttributeError`).
- User-facing "missing packages" hints (ČSFD) now say `uv sync` instead of
`poetry install`.
## 1.12.0 — 2026-08-31
+5 -5
View File
@@ -1,7 +1,9 @@
# Imports
from src.ui.qt_app import run
import contextlib
from src.core.file_manager import FileManager
from src.core.tag_manager import TagManager
from src.ui.qt_app import run
def _use_system_certificates() -> None:
@@ -11,11 +13,10 @@ def _use_system_certificates() -> None:
Windows trust store but not in certifi's bundle. Best-effort; ignored if the
optional `truststore` package is unavailable.
"""
try:
with contextlib.suppress(Exception):
import truststore
truststore.inject_into_ssl()
except Exception:
pass
class State:
@@ -32,4 +33,3 @@ def main() -> None:
if __name__ == "__main__":
main()
+4 -11
View File
@@ -19,27 +19,20 @@ pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
exclude_binaries=True,
name='Curator',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
onefile=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='Curator',
)
+18 -15
View File
@@ -1,6 +1,6 @@
# Python Development Guidelines
**Document Version:** v11
**Document Version:** v12
> **Note on Versioning:**
> - This document version is independent — reused across projects
@@ -129,28 +129,31 @@ Define specific exception types. Use a fail-fast approach — surface errors ear
Run before every commit:
```bash
poetry run ruff check
poetry run mypy
poetry run pytest
uv run ruff check
uv run mypy
uv run pytest
```
---
## 11. Poetry
## 11. uv
```bash
poetry install # Install all dependencies
poetry add <pkg> # Add runtime dependency
poetry add --group dev <pkg> # Add dev dependency
poetry remove <pkg> # Remove dependency
poetry run <cmd> # Run command in virtualenv
uv sync # Install all dependencies
uv add <pkg> # Add runtime dependency
uv add --dev <pkg> # Add dev dependency
uv remove <pkg> # Remove dependency
uv run <cmd> # Run command in virtualenv
uv python install <version> # Install a uv-managed Python
```
Never edit `pyproject.toml` directly to add or remove dependencies.
### poetry.lock
The interpreter is a uv-managed Python — no pyenv, no system Python.
`poetry.lock` **is committed** for applications — it pins the exact dependency graph and keeps builds reproducible.
### uv.lock
`uv.lock` **is committed** for applications — it pins the exact dependency graph and keeps builds reproducible.
---
@@ -162,7 +165,7 @@ project/
├── src/ # All application modules
├── tests/ # Tests
├── docs/ # Detailed documentation
├── .venv/ # Virtual environment (managed by Poetry)
├── .venv/ # Virtual environment (managed by uv)
└── pyproject.toml # Project config and dependencies
```
@@ -193,8 +196,8 @@ When a project is distributed as a standalone executable (no Python required on
Build commands:
```bash
poetry run python prebuild.py
poetry run pyinstaller ToolName.spec
uv run python prebuild.py
uv run pyinstaller ToolName.spec
```
> This section applies only to projects that produce standalone executables. Skip for libraries or web services.
+180 -27
View File
@@ -66,6 +66,16 @@ movie table, and one-click Filmotéka generation.
- **Metadata storage:** one **unified metadata file** for the whole pool (a
central index), not per-file sidecars. Justified because Curator owns the pool
and files are never moved manually, so it is not exposed to path drift.
- **The record is the movie, files are its children** (index **v2**,
`movie.py`). Keyed by identity (`tmdb-<id>`, else a `name-<slug>` fallback),
each record holds the metadata *and* a `media` list of its files with a free
`label` per version. Chosen after the v1 path-keyed shape made multi-version
movies unworkable: metadata rode on one file, so it had to be rescued when
that file went, and retitling a grouped movie had to be forbidden. With
identity as the key, `Movie.desired_layout()` is a pure function of the
record — **one video → flat, two or more → own folder** — so promote, demote
and rename are the same operation, and an import can recognise "this tmdbid
is already in the pool" instead of silently creating a duplicate.
- **Config split by scope:** the global config (`.Curator.!gtag`) holds **app
data only** — window state, MRU folders, and the `pool_dir` / `filmoteka_dir`
pointers (which must stay global: the index is found *through* them). Everything
@@ -182,11 +192,104 @@ library manager**. Agreed shape:
`Dokumenty` (mixed, mostly no-TMDb YT series), `Anime` (series), `Chajda`
(homevideo). Kids are **not** a separate folder — see decisions.
- **Naming convention:**
- Movie: `{CZ název} (rok) [tmdbid-N].ext` — the exact Jellyfin form.
- Series: `{název série} (rok) [tmdbid]/Season NN/S{ss}E{ee} - {filename}.ext`
(season 0 → `Specials`); the episode keeps its original filename after the
`SxxEyy` code. Episode numbers are recognised from the filename; editable in
the series GUI.
- Movie: `{CZ název} (rok) [tmdbid-N].ext` — the exact Jellyfin form. A 3D
movie carries an extra `.3D.<FORMAT>` tag before the extension (Jellyfin's
own convention; `FORMAT` ∈ `HSBS`/`FSBS`/`HTAB`/`FTAB`/`MVC`), set via the
edit dialog and preserved across every future rename like the tmdbid is.
- **Multiple movie versions** (2D+3D, 4K+1080p, …): identity is
`(title, rok, tmdbid)`, stays a **flat file** at one version. A second
version promotes it to Jellyfin's required per-movie folder — every file
prefixed with the exact folder name — `Title (rok) [tmdbid]/Title (rok)
[tmdbid] - {popisek}.ext`; a 3D version's popisek doubles as its 3D tag
(`3D HSBS`, scanned by Jellyfin the same as the flat `.3D.HSBS` form).
Curator does the promotion/demotion itself (*Upravit film → Verze*), and a
version's popisek is renamable too (`rename_movie_version`) — a flat movie
always shows one placeholder row ("Výchozí") in the list even though it
has no on-disk label yet, but renaming only applies once it's grouped. A
popisek left empty (a plain "2D" file with nothing else to call it) is
`DEFAULT_VERSION_LABEL` ("Výchozí"), not an error — everywhere a popisek is
entered (`add_movie_version`, `rename_movie_version`, `merge_as_version`,
the import dialog's version link). Promoting/demoting always carries the
file's *own* current 3D formát over automatically when no popisek is
given for it (`existing_label`/`keep_label` default to `None` → derived
via `naming.parse_3d_format`) — a movie can freely gain and lose versions
in any order (3D-only → +2D → -3D → 3D-only again, …) without a formát
ever getting silently dropped along the way.
The default (plain) version of a grouped movie carries **no label**:
`Folder/Folder.ext` (`file_manager._version_filename`); "Výchozí" is the
UI's name for it. Identity — what decides "is this film already in the
pool" — is the folder name for a grouped movie and the stem minus any
`.3D.<FORMAT>` for a flat one (`FileManager._identity_stem`,
`pooled_with_stem`). Importing a film that's already there defaults to
adding a version, never a root-level duplicate, and "keep both as separate
films" is not offered for same-TMDb-id films (they'd share one index record).
**Parts** (one film split across files, Jellyfin "stacking") are versions
with a `partN` popisek, written as `Folder-partN.ext` instead of
`Folder - popisek.ext` (`file_manager._version_filename`,
`movie.normalize_part_label`); a film with parts always lives in its own
folder. Parts and versions in one folder are not combined yet.
*Upravit film* presents this as one "Média filmu" section: flat
("Samostatný film") until a second medium arrives, then a folder whose
mode is switched between versions and parts (`movie_group_mode`,
`set_movie_group_mode`); parts are reordered with ↑/↓ (`move_movie_part`).
Renames after a relabel go through `File.save_location`, never a plain
`save_metadata`, so a stale sibling can't overwrite shared metadata.
Custom per-movie attributes are no longer editable in the GUI; stored ones
still feed the miniDLNA filename templates.
The table always shows one row per movie (`visible_movies`), marked `▣` /
`Film · N verze` when it has several; shared metadata lives on the movie
record (index v2), so every version reads the same. Accidental duplicates
(same tmdbid / title+year / file size as separate movies) are found by
*Nástroje → Kontrola duplicit* and folded in via `merge_as_version`.
**Not yet supported:** renaming/moving/retitling a grouped movie (remove
the version or edit files directly).
Works in `mixed` libraries too, disambiguated from a series by **shape**:
a movie's version folder and a series folder are both
`Title (rok) [tmdbid]/`, but a series always nests episodes in a
`Season NN`/`Specials` folder and a movie's version folder never does
(`series.looks_like_series_folder`) — `load_pool_movies`/
`is_movie_group_member` read the shape and `scan_series`
(`skip_movie_folders=True` for `mixed`) skips what it recognises as a
movie, so both kinds can share one identity-named folder pair without
either leaking into the other's listing. A `series`-kind library (which
never holds movies) keeps reading every subfolder as a series regardless
of shape, so a raw not-yet-renamed import can still have loose episode
files at the series root before `Season NN` exists.
Assignable at **import** time too, not just after the fact: picking
several files for the same film in one batch (*Importovat filmy…*), a
row's "🔗 Přiřadit jako verzi…" marks it a version of another row instead
of importing it as its own movie — the earlier attempt at this (a 3D
formát combo per row) was rejected because a lone row has nothing to
attach the version *to* and just created a duplicate; picking the target
row explicitly is what that was missing. The popisek is pre-filled from
the filename (`naming.three_d_format_from_filename`) the same way
`year_from_filename` pre-fills the year. Import runs the target rows
first, then folds each linked row onto its target via
`add_movie_version`. The reverse case — a film whose only file *is* 3D,
with nothing to link it to — gets its own **3D formát combo per row**
instead (`import_movie(..., format_3d=…)`, same as *Upravit film*);
disabled the moment the row is linked as a version or gains one as a
follower, since a grouped movie's 3D-ness lives on the version's popisek,
not on the row that started the group.
- Series: `{název série} (rok) [tmdbid]/Season NN/S{ss}E{ee} - {série}.ext`
(season 0 → `Specials`). The episode filename is the code + the series title
only — the release string is dropped (identity is the code; Jellyfin fetches
episode metadata from TMDb via the code + `[tmdbid]`). Episode numbers are
recognised from the filename and editable at import / in the series GUI;
*Seriály → "Napravit názvy souborů…"* bulk-fixes the whole pool.
- **Episodes split into physical parts** (an old recording cut into
several files) get Jellyfin's own `.ptN` stacking suffix —
`SxxEyy - {série}.ptN.ext` — recognised from *cd/dvd/part/pt/disc/disk* +
a number in the source filename (`Show.S01E02.pt2.mkv`,
`Show S01E01-part-1.mkv`, …) or typed by hand at import as `S01E02P3`
(`src.core.series.parse_episode_code`/`parse_episode_part`). Distinct from
a multi-episode file (`E01-E02`, one file = several episodes) — a part is
the opposite: several files = one episode.
- **Removing a series is scoped to the tree selection**: *Přehled seriálů →
"Odebrat z poolu…"* deletes the series, a single season, or a single
episode, whichever is selected (`FileManager.remove_series`/
`remove_season`/`remove_episodes`) — series still carry no index metadata,
so this is always just deleting files/folders from disk.
- **CZ title source:** TMDb (cs-CZ) **primarily** — reliable when matched by
`tmdbid` (yearless title search picks the wrong film, hence the earlier
"weird names"); **ČSFD as fallback/override** (a ČSFD link at import, or when
@@ -195,15 +298,45 @@ library manager**. Agreed shape:
- **Metadata is Jellyfin's job**: Jellyfin fetches from TMDb and (configured to)
writes `.nfo` + artwork into the folders → survives a Jellyfin reinstall.
Curator resolves a **TMDb id → title + year** for naming (`tmdb.py`). ČSFD is
kept **only** as an optional CZ-title source for the filename (not for tags/
matching — it exposes no TMDb/IMDb id, verified).
kept **only** as a title source (not for tags/matching — it exposes no
TMDb/IMDb id, verified): it feeds the **ČSFD → TMDb bridge** (`csfd.py`,
`FileManager.resolve_via_csfd`) — filename → ČSFD search → ČSFD *original*
title + year → TMDb search → tmdbid, more reliable than a raw Czech-title
TMDb search. Used by the import dialog ("Dohledat přes ČSFD") and by migration
for any movie with a stored `csfd_url`.
- **Import:** mandatory **TMDb id** (matching + CZ title), optional **ČSFD link**
(CZ-title source/override); filename hand-editable before/after import.
(drives the ČSFD→TMDb bridge, and stored as a pointer); filename hand-editable
before/after import. The import row is pre-filled on open (no network) from
metadata already next to the source file: Curator's **pre-index** layouts —
a `.{filename}.!tag` sidecar or an old `.Curator.!index` up the tree
(`legacy_meta.read_legacy_meta`, authoritative for the ČSFD link) — then a
Kodi/Jellyfin `.nfo` (`nfo.read_nfo`, also reads a `csfd` `uniqueid` /
`<csfdid>`, and supplies the TMDb id).
- **miniDLNA**: keep a genre-split **hardlink tree** for a dumb DLNA browser,
but drive it from the **NFO** Jellyfin wrote (repurposing `HardlinkManager` +
`naming.py`), not from ČSFD tags.
`naming.py`), not from ČSFD tags. **Scoped per library** (*miniDLNA →
"Knihovny pro generování…"*, `minidlna_libraries` in the pool index, default
= all) — lets a library (e.g. Kids) be excluded from the dumb-DLNA output
entirely; a movie library's genre-tree links are swept as obsolete the normal
way when it's excluded. A `series` **or** `homevideo`-kind library has no
genre tags to build a tree from (homevideo has no TMDb id either), so both
hardcopy whole into `output/<jejich vlastní název>` (`mirror_as_is_libraries`)
— this works today independent of phase 8 (homevideo needs no GUI
import/browse support to be mirrored). Mirrors have **no obsolete-link
mechanism of their own**, so excluding one is pruned explicitly
(`FileManager.minidlna_prune_targets` + `HardlinkManager.remove_mirror`) —
including the shared `output/Seriály` bucket a mixed-library series and a
same-named real library can both write into, reconciled per-entry so pruning
one never erases the other's still-valid content.
- **Jellyfin API** (thin, hybrid): trigger scans, verify, manage collections;
key + URL in `.env`. NFO on disk stays authoritative.
- **Subtitles stay external, never muxed** (`subtitles.py`): a `.srt`/`.ass`/
`.ssa`/`.sub`/`.vtt` sharing the video's stem (+ optional `.lang`/`.forced`/
`.sdh`/`.default`) plays natively in Jellyfin — no re-encode, multiple
languages side by side, easy to replace. Presence is read live from disk
(like NFO), not cached in the index. The one thing Curator owns: a subtitle
**follows its video through every rename/move** so it never silently
detaches (every rename/move path calls `subtitles.rename_subtitles_with`).
### Decisions (locked)
@@ -231,9 +364,9 @@ physical partitions + import-into / move-between [done]; (3) Jellyfin API — sc
verify + status [done]; (4) miniDLNA genre tree from NFO + ČSFD removed [done];
(5) series model — SxxEyy layout, import, rename [done]; (6) migrate the existing
pool — title+year → TMDb search → tmdbid [done]; (7) consolidated edit dialogs +
library organizer (unified `libraries` model with kinds) [done].
Remaining: **(8) import without TMDb/year** (homevideo, no-TMDb docs);
**(9) mixed-library scanning + generation** (movies + series in one folder).
library organizer (unified `libraries` model with kinds) [done]; (9) mixed-library
scanning + generation (movies + series in one folder) [done].
Remaining: **(8) import without TMDb/year** (homevideo, no-TMDb docs).
Note: libraries hold **flat** files for now (`pool/<Library>/Title (rok)
[tmdbid].ext`); per-movie folders are a later refinement.
@@ -249,28 +382,45 @@ Note: libraries hold **flat** files for now (`pool/<Library>/Title (rok)
skip the TMDb requirement; `movie` keeps requiring a year (as today).
- `homevideo` library scanning: load its files as items (flat), no TMDb naming,
no genre tags. Consider a dedicated import path or a per-import "bez TMDb" toggle.
**miniDLNA generation for `homevideo` needs none of this** — it hardcopies the
whole library folder as-is (`mirror_as_is_libraries` **[done]**); this task is
only about GUI import/browsing.
- Tests: import with no year/id lands correctly; homevideo library loads.
### Phase 9 — mixed-library scanning + generation
- Scan a `mixed` library by structure: a top-level **single video file = movie**;
a subfolder containing `Season NN/` (or `SxxEyy` files) = **series**. Reuse
`series.scan_series` for the series part and the flat movie scan for the rest.
- `load_pool_movies` / `load_series` become **kind-aware** (iterate `libraries`,
dispatch by kind); today they read `movie_libraries` / `copyasis_folders`.
- miniDLNA generation + NFO refresh must cover mixed libraries too.
- The library filter / table should surface mixed libraries once loadable.
- Tests: a mixed folder with both a movie and a series loads/generates correctly.
- Scanning **[done]**: a `mixed` library's top-level files load as movies
(`load_pool_movies`), its subfolders as series (`load_series` /
`series_libraries` = kind `series` **or** `mixed`, reusing `scan_series`) —
*except* a canonical-shaped subfolder with no `Season NN`/`Specials` inside
it, which is a movie's version folder instead and loads as a movie too (see
"Multiple movie versions" above). Both are kind-aware over the unified
`libraries` list. The library filter / table already surfaces mixed
libraries.
- miniDLNA generation **[done]**: a mixed library's movies are genre-tagged as
usual; series in its subfolders have no genre tags, so they're hardcopied
straight into `output/Seriály/<název>/…` instead
(`FileManager.minidlna_scope` → the mixed-library series list; the mirror
loop in `generate_filmoteka`). NFO refresh needs no separate handling — it
only ever touches movies (`self.filelist`), series never go through NFO/TMDb
tagging, mixed or not.
- Tests: a mixed folder with both a movie and a series loads/generates
correctly **[done]** (`TestMinidlnaScope` in `test_file_manager.py`).
### Later / optional
- **ČSFD CZ-title fetch (deferred).** Live test showed TMDb cs-CZ titles are good
when matched by tmdbid, so ČSFD title-fetch was **not** rebuilt (would need the
Anubis PoW solver removed in phase 4). If TMDb gets a title wrong, edit the
filename in the GUI. The ČSFD **link** is already stored per movie
(`File.csfd_url`, set at import / in the edit dialog) as a pointer for later —
revisit fetching only if TMDb proves insufficient in practice.
- Per-movie folders (`pool/<Lib>/Title (rok) [tmdbid]/…`) instead of flat files.
- **ČSFD → TMDb title bridge (done).** A direct TMDb search on a Czech title was
matching the wrong film too often, so a lean `csfd.py` was revived (Anubis PoW
solver, film-page + search parsing, `original_title` extraction — the entry in
`.film-names` whose flag matches the country of origin). `resolve_via_csfd`
drives it from the import dialog and from migration. **Not** a return of ČSFD
tags — genres still come from TMDb via Jellyfin/NFO. Open follow-up: batch
"look up ČSFD links for the whole pool" as a migration pre-pass (today the
`csfd_url` is per-movie, set at import / in the edit dialog).
- Per-movie folders (`pool/<Lib>/Title (rok) [tmdbid]/…`) instead of flat
files — **partially done**: a movie only gets its own folder once it has a
second playable version (see "Multiple movie versions" below); a single-file
movie stays flat as before.
- Collections / BoxSets (Bond, Marvel) via Jellyfin API or `<set>` in NFO.
- Optional common umbrella folder above libraries (`pool/Média/<Lib>`) — needs
path-aware `library_of` (currently single-segment).
@@ -280,6 +430,9 @@ Note: libraries hold **flat** files for now (`pool/<Library>/Title (rok)
- Jellyfin pivot phases 1–7 (see Phasing): TMDb identity, tmdbid naming, movie
libraries + move, Jellyfin API (scan/verify), NFO-driven miniDLNA + ČSFD
removed, series model (SxxEyy), pool→TMDb migration, consolidated edit dialogs
- Phase 9 — mixed-library scanning + generation: a `mixed` library's top-level
movies genre-tag as usual, series in its subfolders hardcopy into
`output/Seriály/<název>/…` (no genre tags apply to a series)
- Library organizer (`libraries` unified model with kinds movie/series/mixed/
homevideo; *Pool → Knihovny…*); `movie_libraries`/`copyasis_folders` derived
- Pool naming convention `Title (YYYY).ext` (`naming.py`): year required at
+6 -6
View File
@@ -37,8 +37,8 @@ hardlink-tree machinery is inherited and extended into a full library workflow.
## Running
```bash
poetry install
poetry run python Curator.py
uv sync
uv run python Curator.py
```
The GUI is built with **PySide6**.
@@ -46,15 +46,15 @@ The GUI is built with **PySide6**.
## Development
```bash
poetry run pytest # tests
poetry run ruff check # lint / format
poetry run mypy # type checking
uv run pytest # tests
uv run ruff check # lint / format
uv run mypy # type checking
```
## Building a standalone executable
```bash
poetry run pyinstaller Curator.spec
uv run pyinstaller Curator.spec
```
The resulting `dist/Curator` is the distribution artifact.
Vendored Executable
BIN
View File
Binary file not shown.
+42
View File
@@ -0,0 +1,42 @@
# Curator documentation
Curator is a PySide6 desktop application that manages a Jellyfin-first movie and
series pool: it imports files, identifies them through TMDb, groups multiple
versions of a film, keeps series in Jellyfin's layout and generates a miniDLNA /
Filmotéka tree.
- [Architecture](architecture.md) — layers, on-disk layout, the pool index and the
rules about versions, series and subtitles. Read this first.
- [Module reference](#module-reference) — one document per source module, with a
description of the module and of every class and function.
## Module reference
| Source | Purpose |
|--------|---------|
| [`Curator.py`](modules/entry-point.md) | Application entry point. |
| [`src/_version.py`](modules/_version.md) | Auto-generated — do not edit manually. |
| [`src/constants.py`](modules/constants.md) | Application-level constants for Curator (build/runtime metadata). |
| [`src/core/config.py`](modules/core.config.md) | Configuration for Curator. |
| [`src/core/csfd.py`](modules/core.csfd.md) | ČSFD.cz scraper — a *title bridge* to TMDb. |
| [`src/core/file.py`](modules/core.file.md) | The `File` class: one playable movie file as the UI sees it. |
| [`src/core/file_manager.py`](modules/core.file_manager.md) | `FileManager`: the application's service layer. |
| [`src/core/hardlink_manager.py`](modules/core.hardlink_manager.md) | Hardlink Manager for Curator |
| [`src/core/integrity.py`](modules/core.integrity.md) | Video integrity checking via FFmpeg. |
| [`src/core/jellyfin.py`](modules/core.jellyfin.md) | Jellyfin server API client — thin, hybrid integration. |
| [`src/core/legacy_meta.py`](modules/core.legacy_meta.md) | Readers for Curator's *pre-index* metadata layouts. |
| [`src/core/list_manager.py`](modules/core.list_manager.md) | Sorting of file lists by name or by date added. |
| [`src/core/media_utils.py`](modules/core.media_utils.md) | 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. |
| [`src/core/migrate.py`](modules/core.migrate.md) | One-off migration of an existing pool to the Jellyfin naming convention. |
| [`src/core/movie.py`](modules/core.movie.md) | The movie object — metadata first, files second. |
| [`src/core/naming.py`](modules/core.naming.md) | Pool file naming convention. |
| [`src/core/nfo.py`](modules/core.nfo.md) | NFO reader — Curator's metadata source after ČSFD. |
| [`src/core/pool_index.py`](modules/core.pool_index.md) | Unified metadata index for the Curator pool. |
| [`src/core/series.py`](modules/core.series.md) | Series naming + scanning for the Jellyfin TV layout. |
| [`src/core/subtitles.py`](modules/core.subtitles.md) | External subtitle sidecars — attach, discover, keep in sync on rename. |
| [`src/core/tag.py`](modules/core.tag.md) | The `Tag` value object: a `(category, name)` pair identified by its `full_path` (`Category/Name`). |
| [`src/core/tag_manager.py`](modules/core.tag_manager.md) | `TagManager`: the registry of all tag categories and their tags. |
| [`src/core/tmdb.py`](modules/core.tmdb.md) | TMDb (The Movie Database) client — Curator's identity source. |
| [`src/core/transforms.py`](modules/core.transforms.md) | Tag-schema value transforms for miniDLNA folder names. |
| [`src/core/utils.py`](modules/core.utils.md) | Small filesystem helpers shared across the core modules. |
| [`src/ui/qt_app.py`](modules/ui.qt_app.md) | PySide6 GUI for Curator — reframed around the Filmotéka (movie-library) workflow. |
+196
View File
@@ -0,0 +1,196 @@
# Architecture
This document describes how Curator is put together: the layers, what owns what
on disk, and the handful of rules the rest of the code relies on. The per-module
API reference is in [`modules/`](README.md).
## Layers
```
Curator.py entry point — certificates, State, run()
│
▼
src/ui/qt_app.py PySide6 GUI: QtApp (main window) + dialogs
│ never touches the filesystem directly
▼
src/core/file_manager.py FileManager — the service layer
│ owns: filelist, pool index, library config, global config
│
├── domain model file.py (File), movie.py (Movie, MediaFile),
│ series.py (Series, Episode), tag.py, tag_manager.py
├── persistence pool_index.py (.Curator.!index), config.py
│ legacy_meta.py (reads pre-index layouts)
├── naming naming.py (movie stems, 3D), series.py (SxxEyy),
│ subtitles.py (sidecar names), movie.identity_key
├── external services tmdb.py, csfd.py, jellyfin.py, nfo.py (read-only)
└── output and checks hardlink_manager.py (miniDLNA / Filmotéka),
transforms.py, integrity.py (ffmpeg), migrate.py
```
Dependencies only point downwards. The GUI calls `FileManager` methods and
receives plain objects back; the core modules never import Qt.
### Entry point
`Curator.py` builds a `State` (one `TagManager` and one `FileManager`) and hands
both to `src.ui.qt_app.run`. Before that it asks the OS certificate store for
HTTPS, because ČSFD and TMDb are reached from behind corporate proxies.
### GUI
`QtApp` is the main window: the menu bar, a tag sidebar (an AND filter over the
movie list), the movie and series table, a detail panel and the status bar. Each
menu action is a method on `QtApp`; most of them open a dialog and then call one
`FileManager` method. The dialogs are separate classes in the same module —
import, edit, duplicate check, series overview, library organiser and so on.
### Service layer
`FileManager` is the only object that changes the pool. Everything the user can
do maps onto one of its public methods, for example `import_movie`,
`add_movie_version`, `remove_movie_version`, `merge_as_version`,
`apply_movie_edits`, `import_episodes`, the miniDLNA scope methods (`minidlna_scope`,
`minidlna_prune_targets`) and `find_duplicates`. Methods that change files also keep the pool index and the
in-memory `filelist` in step.
## What lives where on disk
The pool is one folder. Each top-level folder inside it is a **library**, and each
library has a **kind** that decides how Curator treats it:
| Kind | Holds | Layout |
|------|-------|--------|
| `movie` | single-file movies and grouped movies | `Title (rok) [tmdbid].ext` or `Title (rok) [tmdbid]/…` |
| `series` | series trees | `Series (rok) [tmdbid]/Season NN/SxxEyy - Series.ext` |
| `mixed` | movies and series in one folder | movies flat or in a movie folder, series folders with `Season NN` inside |
| `homevideo` | files without TMDb ids | mirrored as-is |
The default pool has `Filmy` (movie) and `Seriály` (series). Library
configuration lives in the pool index, not in the global config.
Two more files sit in the pool root:
- `.Curator.!index` — the pool index (JSON, version 2). See below.
- nothing else: there are no per-file sidecars. Curator's own metadata is in the
index; Jellyfin writes its NFO and artwork next to the media.
Outside the pool, the Filmotéka / miniDLNA output is a tree of hardlinks made by
`hardlink_manager`. It never holds metadata of its own.
## The pool index
`pool_index.py` keeps one JSON document per pool:
```json
{
"version": 2,
"settings": { "libraries": [...], "tag_schema": [...], "minidlna_libraries": null },
"movies": {
"tmdb-19995": {
"title": "Avatar", "year": "2009", "tmdb_id": 19995,
"library": "Filmy",
"tags": ["Žánr/Sci-Fi"], "csfd_url": null, "attributes": {}, "date": null,
"media": [
{"role": "video", "label": "", "file": "Filmy/Avatar (2009) [tmdbid-19995]/Avatar (2009) [tmdbid-19995].mkv"},
{"role": "video", "label": "3D HSBS", "file": "Filmy/Avatar (2009) [tmdbid-19995]/Avatar (2009) [tmdbid-19995] - 3D HSBS.mkv"}
]
}
}
}
```
The key of a movie record is its **identity**:
- `tmdb-<id>` when the TMDb id is known;
- otherwise `name-<slug>` built from the title and year, with diacritics folded
(`identity_key` in `movie.py`).
Everything about a film — title, tags, ČSFD link, attributes, date added — is on
that one record. Its `media` list holds every playable file of the film. A version
therefore never carries metadata of its own, and removing one version never loses
the metadata of the others.
Version 1 of the index was keyed by file path. `PoolIndex.load` migrates it on
first open: metadata only, no file is moved, and the old file is kept as
`.Curator.!index.v1.bak`.
## Movies and versions
A film starts as one flat file. Adding a second file for the same film turns it
into a **grouped movie**: a folder named after the film, with every file in it
named after that folder.
```
Filmy/
Avatar (2009) [tmdbid-19995]/
Avatar (2009) [tmdbid-19995].mkv ← default version, no label
Avatar (2009) [tmdbid-19995] - 3D HSBS.mkv ← version with popisek "3D HSBS"
Avatar (2009) [tmdbid-19995] - 4K.mkv
Avatar (2009) [tmdbid-19995].cs.srt ← subtitles follow their video
```
Rules that the code depends on:
- **Default version.** The plain version has no label. In the UI it is shown as
"Výchozí". A flat movie that has been promoted keeps its 3D formát, if it had one,
as its label; otherwise it becomes the bare default.
- **3D formát.** A version whose popisek is `3D <FORMÁT>` is 3D; a flat movie
carries the same information as `.3D.<FORMÁT>` in its name. Promotion and
demotion carry the formát across, so a movie that ends up with only a 3D version
is flat again with its tag intact.
- **Demotion.** When one version is left, the movie goes back to a flat file named
after the folder (plus the 3D tag of the survivor, if any) and the folder is removed.
- **Identity.** A file belongs to the film whose identity matches its folder name
(grouped) or its own stem minus any `.3D.<FORMAT>` tag (flat). Duplicate checking,
import collision handling and index lookups all use this rule.
- **Mixed libraries.** A movie's folder and a series folder have the same outer
name. They are told apart by content: a series always nests episodes in a
`Season NN` or `Specials` subfolder, a movie folder never does
(`series.looks_like_series_folder`).
## Series
A series is a folder with `Season NN` (and `Specials` for season 0) subfolders.
Episodes are named `SxxEyy - Series.ext`; an episode split across several physical
files gets Jellyfin's stacking suffix `.ptN`. Series carry no index record: they are
scanned from disk each time (`series.scan_series`), so deleting an episode file is
the whole of removing it.
## Subtitles
Subtitles are sidecar files that share the video's stem, with an optional language
and flag: `Name.cs.srt`, `Name.cs.forced.srt`. They are not index entries. Every
operation that renames or moves a video calls `subtitles.rename_subtitles_with`,
so the sidecars keep their match.
## Output and external services
- **miniDLNA / Filmotéka** — `FileManager.minidlna_scope` decides which libraries
take part; `HardlinkManager` builds the genre tree and mirrors the copy-as-is
libraries. Libraries left out of scope have their old links swept on the next run.
- **TMDb** (`tmdb.py`) — the identity source. Turns a TMDb id or a title/year search
into a canonical title and year, and supplies genres and years for tags.
- **ČSFD** (`csfd.py`) — a bridge for Czech titles: filename → ČSFD film → original
title and year → TMDb.
- **Jellyfin** (`jellyfin.py`) — library scan trigger and a read-back of what
Jellyfin sees, used to verify the pool.
- **NFO** (`nfo.py`) — read-only metadata written by Jellyfin, used to fill fields
of files imported without them.
## Configuration
Two stores, by responsibility (see `config.py`):
- the **global config** (`.Curator.!gtag`, next to `Curator.py`) holds application
state only — window geometry, recent folders and the pool and output paths;
- the **pool index** holds everything that describes a pool — libraries, tag schema,
miniDLNA selection and all movie records.
Secrets come from `.env`: `TMDB_API_KEY`, `JELLYFIN_URL`, `JELLYFIN_API_KEY`,
and an optional debug flag read by `constants.py`.
## Testing
Tests live in `tests/` and run with pytest. The core modules are tested directly
against temporary pools; the Qt dialogs are exercised headless
(`QT_QPA_PLATFORM=offscreen`) through their methods.
+5
View File
@@ -0,0 +1,5 @@
# `src/_version.py`
Auto-generated — do not edit manually.
Source: [`src/_version.py`](../../src/_version.py)
+34
View File
@@ -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.
+57
View File
@@ -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
+251
View File
@@ -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"
+227
View File
@@ -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
+440
View File
@@ -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)
+77
View File
@@ -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.
+105
View File
@@ -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.
+83
View File
@@ -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.
+47
View 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.
+27
View File
@@ -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.
+62
View File
@@ -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.
+207
View File
@@ -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.
+119
View File
@@ -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.
+103
View File
@@ -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.
+191
View File
@@ -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.
+227
View File
@@ -0,0 +1,227 @@
# `src/core/series.py`
Series naming + scanning for the Jellyfin TV layout.
Jellyfin wants series laid out as::
Seriály/Series (year) [tmdbid-N]/
tvshow.nfo (Jellyfin writes it)
Season 01/
Series (year) S01E01.ext
Specials/ (season 0)
Episodes are matched by `SxxEyy`. These helpers parse the episode number out of
a raw filename, build the canonical episode/season/series names, and scan a
series library into a `Series → Season → Episode` tree with a naming status.
Source: [`src/core/series.py`](../../src/core/series.py)
## Classes
### `Episode`
*class · line 49*
```python
class Episode:
```
One episode file found in a series folder: its path, its season and episode numbers (`None` when not recognised) and its part number when the episode is split across several files.
#### `Episode.code`
*property · line 58*
```python
code -> str
```
Human-readable episode code, e.g. `S01E02`, with `.ptN` appended for a part (`S01E02.pt3`). `?` when the season or episode is unknown.
### `Series`
*class · line 66*
```python
class Series:
```
One series folder with its title, year, TMDb id, library and the episodes found below it.
#### `Series.seasons`
*method · line 74*
```python
seasons() -> dict[int, list[Episode]]
```
Episodes grouped by season number (`-1` = unrecognised).
## Functions
### `parse_episode`
*function · line 84*
```python
parse_episode(name: str) -> Optional[tuple[int, int]]
```
Extract `(season, episode)` from a filename, or `None`.
### `parse_episode_part`
*function · line 94*
```python
parse_episode_part(name: str) -> Optional[int]
```
Part number implied by a Jellyfin stacking suffix in a filename, or `None`.
Looks for *cd*, *dvd*, *part*, *pt*, *disc* or *disk* + a number sitting
right before the extension (`Show S01E02.pt2.mkv`,
`Show S01E02-part-2.mkv`, …) — the same tokens Jellyfin itself scans for.
### `parse_episode_code`
*function · line 105*
```python
parse_episode_code(text: str) -> Optional[tuple[int, int, Optional[int]]]
```
Parse a compact user-typed code: `SxxEyy`, optionally + a part.
Accepts everything `parse_episode` does, plus a trailing part marker
right after it — `S01E02P3`, `S01E02 pt3`, `S01E02.part.3`,
`S01E02 cd2` — for a file that is only *one part* of that episode.
Returns `(season, episode, part)` with `part` `None` when absent, or
`None` when no `SxxEyy` is found at all.
### `collect_episode_files`
*function · line 126*
```python
collect_episode_files(folder: Path) -> list[Path]
```
Every video file under `folder` (recursively), sidecars excluded, sorted.
Lets the episode-import dialog take a whole `Series/Season NN/` tree (or any
nested layout) in one pick instead of file-by-file. Season/episode numbers
are still read per file from its name by `parse_episode`.
### `season_folder_name`
*function · line 142*
```python
season_folder_name(season: int) -> str
```
Folder name for a season — `Specials` for 0, else `Season NN`.
### `is_season_folder`
*function · line 147*
```python
is_season_folder(name: str) -> bool
```
True for a season folder name — `Specials` or `Season <n>` (any padding).
### `looks_like_series_folder`
*function · line 152*
```python
looks_like_series_folder(folder: Path) -> bool
```
True when `folder` holds at least one `Season NN`/`Specials` subfolder.
That's the shape Curator always gives a series (episodes always land in a
season folder, never loose at the series root) — a movie's version folder
never has one, its files sit flat inside it. This is how a `mixed`
library tells the two apart even though both use the identical outer
`Title (rok) [tmdbid]/` name: `FileManager.is_movie_group_member`
and `scan_series` both check it before deciding which one a
canonical-shaped subfolder is.
### `sanitize_title`
*function · line 173*
```python
sanitize_title(title: str) -> str
```
A series/episode title made safe to sit in a filename (spaces collapsed).
### `episode_filename`
*function · line 179*
```python
episode_filename(season: int, episode: int, series_title: str, ext: str, *, part: Optional[int]=None) -> str
```
Canonical episode filename `SxxEyy - {series title}.ext`.
The release string is deliberately **not** kept — identity is the `SxxEyy`
code, and Jellyfin fetches the episode title/metadata from TMDb (matched by
the code + the series folder's `[tmdbid-N]`). An empty title collapses to
`SxxEyy.ext`. `part` marks one physical part of that episode — an old
recording split across several files — with Jellyfin's own `.ptN`
stacking suffix right before the extension.
### `series_folder_stem`
*function · line 200*
```python
series_folder_stem(title: str, year: object=None, tmdb_id: object=None) -> str
```
Series folder name: `Title (year) [tmdbid-N]` (year/id optional).
Reuses the movie convention when a year is present; otherwise falls back to
`Title [tmdbid-N]` or just `Title` so a year-less series still works.
### `is_canonical_episode`
*function · line 216*
```python
is_canonical_episode(path: Path, series_title: str) -> bool
```
True when `path` is already `SxxEyy - {series title}.ext` in its season.
### `scan_series`
*function · line 229*
```python
scan_series(lib_dir: Path, library: str, *, skip_movie_folders: bool=False) -> list[Series]
```
Scan one series library into a list of `Series`.
Each immediate subfolder of `lib_dir` is a series; its name yields the
title/year/tmdbid. Video files anywhere below it are episodes (season/episode
parsed from the filename). Non-video sidecars are skipped.
`skip_movie_folders=True` (a `mixed` library, where a movie's version
folder and a series folder both use the identical
`Title (rok) [tmdbid]/` shape) additionally skips a canonical-shaped
folder with no `Season NN`/`Specials` inside it — that's a movie's
version folder instead, left for `FileManager.load_pool_movies` to
read. Left `False` (the default, a plain `series`-kind library, which
never holds movies) every subfolder is read as a series regardless of
shape — a raw, not-yet-renamed import may have loose episode files
sitting at the series root, with no season folder yet.
+102
View File
@@ -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.
+37
View File
@@ -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.
+107
View File
@@ -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
+160
View File
@@ -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).
+43
View File
@@ -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.
+17
View File
@@ -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.
+53
View File
@@ -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
+23
View File
@@ -0,0 +1,23 @@
[Unit]
Description=HTTP server pro HLS složku Makov webkamery (pro Jellyfin M3U tuner)
After=network-online.target makov-cam.service
Wants=network-online.target
BindsTo=makov-cam.service
[Service]
Type=simple
# WorkingDirectory je rodič hls/, aby šel přes HTTP servírovat i makov.m3u
# (playlist pro Jellyfin), ne jen samotné HLS segmenty.
WorkingDirectory=/var/lib/jellyfin-iptv
# Bind na IP brány docker sítě, na které běží Jellyfin kontejner (ne na
# 127.0.0.1 hostitele — to by z kontejneru bylo jeho vlastní loopback, ne
# hostitelův). Konkrétní hodnota je specifická pro tenhle server, viz
# setup.md.
ExecStart=/usr/bin/python3 -m http.server 8888 --bind 172.19.0.1
Restart=always
RestartSec=5
User=makov-cam
Group=makov-cam
[Install]
WantedBy=multi-user.target
+21
View File
@@ -0,0 +1,21 @@
[Unit]
Description=Restream Makov webkamery (Krmítko) do lokálního HLS pro Jellyfin
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/makov-cam.sh
Restart=always
RestartSec=5
# Necháme běžet pod samostatným účtem, ne pod root.
User=makov-cam
Group=makov-cam
# Pokud je yt-dlp nainstalované přes snap, ignoruje $HOME nastavené tady —
# zjišťuje domovský adresář přímo z /etc/passwd. Účet je --no-create-home,
# takže je potřeba /home/makov-cam vytvořit a vlastnit ručně (viz setup.md).
# Skript si sám zakládá /var/lib/jellyfin-iptv/hls — účet tam musí mít zápis
# (viz setup.md, sekce "Uživatel a oprávnění").
[Install]
WantedBy=multi-user.target
+49
View File
@@ -0,0 +1,49 @@
#!/bin/bash
# Restreams the Makov webcam (YouTube live "Krmítko") into a local HLS
# playlist that never goes stale for the viewer — Jellyfin always points at
# this fixed local path, not at YouTube's own signed/expiring URL.
#
# When ffmpeg dies (the YouTube URL expired, network hiccup, stream briefly
# ended, ...) this script simply exits; systemd (Restart=always) starts it
# again, which resolves a *fresh* URL via yt-dlp. Nothing needs to be done
# by hand.
set -euo pipefail
YOUTUBE_URL="https://www.youtube.com/watch?v=F0fsV1GGZ3M"
OUT_DIR="/var/lib/jellyfin-iptv/hls"
OUT_FILE="${OUT_DIR}/makov.m3u8"
mkdir -p "$OUT_DIR"
# This live stream only serves separate video-only/audio-only HLS renditions
# (no combined progressive format), so we resolve both and let ffmpeg mux
# them. "bestvideo+bestaudio" makes yt-dlp -g print exactly two URLs, video
# then audio; fall back to "best" in case a progressive format reappears.
mapfile -t STREAM_URLS < <(yt-dlp -g -f 'bestvideo+bestaudio/best' "$YOUTUBE_URL")
VIDEO_URL="${STREAM_URLS[0]:-}"
AUDIO_URL="${STREAM_URLS[1]:-}"
if [ -z "$VIDEO_URL" ]; then
echo "yt-dlp nevrátilo žádnou URL (stream offline, nebo se YouTube brání) — končím, systemd to za chvíli zkusí znovu." >&2
exit 1
fi
if [ -z "$AUDIO_URL" ]; then
# Progressive fallback matched: single URL carries both video and audio.
exec ffmpeg -nostdin -loglevel warning \
-re -i "$VIDEO_URL" \
-c copy \
-f hls -hls_time 4 -hls_list_size 6 \
-hls_flags delete_segments+omit_endlist \
-hls_segment_filename "${OUT_DIR}/makov_%03d.ts" \
"$OUT_FILE"
fi
exec ffmpeg -nostdin -loglevel warning \
-re -i "$VIDEO_URL" -re -i "$AUDIO_URL" \
-map 0:v -map 1:a \
-c copy \
-f hls -hls_time 4 -hls_list_size 6 \
-hls_flags delete_segments+omit_endlist \
-hls_segment_filename "${OUT_DIR}/makov_%03d.ts" \
"$OUT_FILE"
+175
View File
@@ -0,0 +1,175 @@
# Makov webkamera → Jellyfin (Live TV)
Restreamuje YouTube live přenos ("Krmítko", https://www.youtube.com/watch?v=F0fsV1GGZ3M)
do lokálního HLS, na který se Jellyfin dívá přes stabilní adresu. Když YouTube
odkaz vyprší nebo spadne, systemd proces sám restartuje a natáhne novou URL —
divák/Jellyfin si ničeho nevšimne.
## 0) Kam skript patří
Toto **musí běžet na stejném stroji (resp. za stejnou veřejnou IP), jako
Jellyfin** — YouTube vrací streamovací URL svázanou s IP adresou, ze které
byla vyžádána (`yt-dlp -g`). Pokud poběží jinde než Jellyfin, `ffmpeg` dostane
403 a nikdy se nerozjede.
## 1) Závislosti
```bash
sudo apt install -y ffmpeg python3
sudo pip install -U yt-dlp # nebo pipx install yt-dlp — apt verze bývá stará
# a YouTube ji rád rozbije
```
Ověř, že `yt-dlp` a `ffmpeg` jedou z shellu bez chyby:
```bash
yt-dlp --version
ffmpeg -version
```
## 2) Uživatel a oprávnění
Vlastní, neprivilegovaný systémový účet — obě služby pod ním běží:
```bash
sudo useradd --system --no-create-home --shell /usr/sbin/nologin makov-cam
sudo mkdir -p /var/lib/jellyfin-iptv/hls
sudo chown -R makov-cam:makov-cam /var/lib/jellyfin-iptv
```
Pokud je `yt-dlp` nainstalované přes **snap** (`which yt-dlp` ukazuje
`/snap/bin/yt-dlp`), potřebuje při startu zapisovatelný domovský adresář —
a to i přes `--no-create-home`, protože domov si zjišťuje přímo z
`/etc/passwd`, ne z proměnné `$HOME` (tu přenastavenou v service souboru
ignoruje). Bez toho skončí na `Permission denied: mkdir /home/makov-cam`.
Vytvoř mu ho ručně:
```bash
sudo mkdir -p /home/makov-cam
sudo chown makov-cam:makov-cam /home/makov-cam
sudo chmod 750 /home/makov-cam
```
(Pokud `yt-dlp` instaluješ přes `pip`/`pipx` dle kroku 1, tenhle problém
nenastává a krok lze přeskočit.)
## 3) Nasazení souborů
```bash
sudo install -m 755 makov-cam.sh /usr/local/bin/makov-cam.sh
sudo install -m 644 makov-cam.service /etc/systemd/system/
sudo install -m 644 makov-cam-http.service /etc/systemd/system/
sudo systemctl daemon-reload
```
## 4) Spuštění
```bash
sudo systemctl enable --now makov-cam.service
sudo systemctl enable --now makov-cam-http.service
```
Ověření:
```bash
systemctl status makov-cam.service makov-cam-http.service
journalctl -u makov-cam.service -f # živě sleduj log ffmpeg/yt-dlp
# do pár vteřin by se měl objevit playlist a segmenty:
ls -la /var/lib/jellyfin-iptv/hls/
# a mělo by jít stáhnout přes HTTP:
curl -sI http://127.0.0.1:8888/hls/makov.m3u8
```
`makov-cam-http.service` je svázaná (`BindsTo=`) na `makov-cam.service` — když
restream spadne/restartuje, HTTP server padne/naskočí s ním, ať nikdy
neservíruje mrtvý/prázdný playlist. Pozor: `BindsTo=` jen zajišťuje, že HTTP
server *spadne*, když spadne hlavní služba — nezajišťuje, že se s ní i sám
*nastartuje*. Po `enable --now` obou služeb to není problém (obě se spustí),
ale pokud jen restartuješ `makov-cam.service` ručně, zkontroluj/restartuj i
`makov-cam-http.service`.
HTTP server poslouchá jen na jedné IP (`--bind`), ne na `0.0.0.0` — nemá cenu
ho vystavovat celé síti, stačí odkud se na něj bude dívat Jellyfin:
- **Jellyfin běží přímo na hostu** (ne v kontejneru): `--bind 127.0.0.1`
stačí.
- **Jellyfin běží v Docker kontejneru na tomtéž hostu** (běžný případ) —
`127.0.0.1` uvnitř kontejneru je jeho *vlastní* loopback, ne hostitelův,
takže na hostitelův HTTP server se tak nedostane. Zjisti gateway IP té
docker sítě, na které kontejner běží, a naviaž se na ni:
```bash
docker inspect <jellyfin-container> --format '{{.HostConfig.NetworkMode}}'
docker inspect <jellyfin-container> --format '{{json .NetworkSettings.Networks}}'
# z výstupu vezmi "Gateway", např. 172.19.0.1 — tam se z kontejneru dostaneš
```
a použij `--bind <ta-gateway-IP>` (viz `makov-cam-http.service`). Pokud by
Jellyfin běžel v jiné VM, změň `--bind` na IP, na které je pro ni
dosažitelný, a případně otevři firewall jen pro tenhle port a jen z její
IP.
## 5) Playlist pro Jellyfin
`WorkingDirectory` HTTP služby je `/var/lib/jellyfin-iptv` (rodič `hls/`), aby
šel přes stejný server servírovat i samotný `.m3u` playlist — to se hodí,
pokud Jellyfin (typicky v kontejneru) nevidí souborový systém hostitele a
tuner tak musí načíst playlist přes HTTP URL, ne lokální cestou.
Vytvoř `/var/lib/jellyfin-iptv/makov.m3u`:
```
#EXTM3U
#EXTINF:-1 tvg-id="makov-krmitko" tvg-name="Makov - Krmítko",Makov - Krmítko
http://<BIND_IP>:8888/hls/makov.m3u8
```
kde `<BIND_IP>` je stejná adresa, na kterou je navázaný `makov-cam-http.service`
(viz krok 4).
## 6) Nastavení v Jellyfinu
1. **Dashboard → Live TV → Tuner Devices → +** (Add)
2. Typ: **M3U Tuner**
3. File or URL:
- Jellyfin na hostu s přístupem k souborovému systému: lokální cesta
`/var/lib/jellyfin-iptv/makov.m3u`.
- Jellyfin v kontejneru (nevidí hostitelův FS): HTTP URL
`http://<BIND_IP>:8888/makov.m3u` (viz krok 5).
4. **Save** → Jellyfin načte kanál "Makov - Krmítko" do Live TV
5. Doporučeno: **Dashboard → Live TV → Guide Providers** — pro tenhle kanál
žádného není třeba (žádný EPG), Jellyfin ho ukáže jako kanál bez programu,
což nevadí.
6. Zkontroluj přehrávání v **Live TV** sekci hlavního rozhraní.
Protože kanál pořád ukazuje na stejnou lokální adresu, odpadá starost o
obnovování guide dat nebo expiraci URL na Jellyfin straně — o to se stará jen
`makov-cam.service` na pozadí.
## 7) Autostart po restartu serveru
`enable` v kroku 4 už zajistil start při bootu. Rychlá kontrola:
```bash
systemctl is-enabled makov-cam.service makov-cam-http.service
```
## Řešení problémů
- **`ffmpeg` pořád padá / v logu 403 od googlevideo.com** — nejčastěji IP
mismatch (viz bod 0): stroj, co spouští `yt-dlp -g`/`ffmpeg`, musí mít
stejnou veřejnou IP jako to, odkud by se stream normálně přehrával.
Zkontroluj `journalctl -u makov-cam.service -n 50`.
- **`yt-dlp: command not found` v service logu** — systemd nepoužívá tvůj
interaktivní `$PATH`; ověř `which yt-dlp` a případně uprav `ExecStart` v
`makov-cam.sh`/skriptu na plnou cestu (`/usr/local/bin/yt-dlp` apod.), nebo
nainstaluj přes `sudo pip install`, aby šel do `/usr/local/bin`.
- **Playlist v Jellyfinu jede, ale sekundy/minuty výpadku při refreshi** —
normální, `hls_list_size 6` × `hls_time 4s` drží ~24s okno; při restartu
streamu je pár vteřin díra, než naběhnou nové segmenty. Jde zkrátit/prodloužit
úpravou těch dvou hodnot ve skriptu.
- **YouTube někdy dočasně rozbije `yt-dlp` novou verzí přehrávače** — drž
`yt-dlp` aktuální (`sudo pip install -U yt-dlp`, klidně jako týdenní cron).
- **`ERROR: Requested format is not available`** — stream v tu chvíli nenabízí
žádný kombinovaný (progresivní) formát, jen oddělené video-only/audio-only
HLS řady (ověř přes `yt-dlp -F <url>`). Skript proto primárně používá
`-f 'bestvideo+bestaudio/best'` a při dvou vrácených URL pustí `ffmpeg` se
dvěma `-i` vstupy (`-map 0:v -map 1:a`); jedna URL = fallback na progresivní
formát, pokud se zase objeví.
Generated
-861
View File
@@ -1,861 +0,0 @@
# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand.
[[package]]
name = "ast-serialize"
version = "0.5.0"
description = "Python bindings for mypy AST serialization"
optional = false
python-versions = ">=3.7"
groups = ["dev"]
files = [
{file = "ast_serialize-0.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8f5c14f169eb0972c0c21bada5358b23d6047c76583b005234f865b11f1fa00a"},
{file = "ast_serialize-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7d1a2de9de5be04652f0ed60738356ef94f66db37924a9499fffe98dc491aa0b"},
{file = "ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be5173fb66f9b49026d9d5a2ff0fc7c7009077107c0eb285b2d60fdf1fe10bd1"},
{file = "ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8015cd071ac1339924ee2b8098c93e00e155f30a16f40ec9816fcf84f4753f6"},
{file = "ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5499e8797edff2a9186aa313ed382c6b422e798e9332d9953badcee6e69a88f2"},
{file = "ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6848f2a093fb5548751a9a09bff8fcd229e2bbeb0e3331f391b6ae6d26cd9903"},
{file = "ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832d4c998e0b091fd60a6d6bceee535483c4d490de9ba85003af835225719261"},
{file = "ast_serialize-0.5.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:16db7c62ec0b8efe1d7afd283a388d8f74f2605d56032e5a37747d2de8dba027"},
{file = "ast_serialize-0.5.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf5eb061eb5bccade4128ad42da33787d72f6013809cd1b590376ece8b3c937"},
{file = "ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:104e4a35bd7c124173c41760ef9aaea17ddb3f86c65cb643671d59afbe3ee94c"},
{file = "ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:36be371028fc1675acb38a331bde160dbab7ff907fdf00b67eb6911aa106951b"},
{file = "ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:061ee58bdb52341c8201a6df41182a977736bae3b7ded87ca7176ca25a8a47ab"},
{file = "ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b15219e9cdc9f53f6f4cb51c009203507228226148c05c5e8fe451c28b435eb3"},
{file = "ast_serialize-0.5.0-cp314-cp314t-win32.whl", hash = "sha256:842d1c004bb466c7df036f95fabef789570541922b10976b12f5592a69cf0b38"},
{file = "ast_serialize-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b0c06d760909b095cc466356dfccd05a1c7233a6ca191c020dca2c6a6f16c24c"},
{file = "ast_serialize-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:787baedb0262cc49e8ce37cc15c00ae818e46a165a3b36f5e21ed174998104cb"},
{file = "ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101"},
{file = "ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a"},
{file = "ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211"},
{file = "ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf"},
{file = "ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9"},
{file = "ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee"},
{file = "ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809"},
{file = "ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43"},
{file = "ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934"},
{file = "ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759"},
{file = "ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887"},
{file = "ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27"},
{file = "ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d"},
{file = "ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a"},
{file = "ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590"},
{file = "ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642"},
{file = "ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6"},
]
[[package]]
name = "beautifulsoup4"
version = "4.15.0"
description = "Screen-scraping library"
optional = false
python-versions = ">=3.7.0"
groups = ["main"]
files = [
{file = "beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9"},
{file = "beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7"},
]
[package.dependencies]
soupsieve = ">=1.6.1"
typing-extensions = ">=4.0.0"
[package.extras]
cchardet = ["cchardet"]
chardet = ["chardet"]
charset-normalizer = ["charset-normalizer"]
html5lib = ["html5lib"]
lxml = ["lxml"]
[[package]]
name = "certifi"
version = "2026.5.20"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897"},
{file = "certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d"},
]
[[package]]
name = "charset-normalizer"
version = "3.4.7"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"},
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"},
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790"},
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc"},
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393"},
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153"},
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af"},
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34"},
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1"},
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752"},
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53"},
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616"},
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a"},
{file = "charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374"},
{file = "charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943"},
{file = "charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008"},
{file = "charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7"},
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7"},
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e"},
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c"},
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df"},
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265"},
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4"},
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e"},
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38"},
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c"},
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b"},
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c"},
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d"},
{file = "charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad"},
{file = "charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00"},
{file = "charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1"},
{file = "charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46"},
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2"},
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b"},
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a"},
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116"},
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb"},
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1"},
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15"},
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5"},
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d"},
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7"},
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464"},
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49"},
{file = "charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c"},
{file = "charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6"},
{file = "charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d"},
{file = "charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063"},
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c"},
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66"},
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18"},
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd"},
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215"},
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859"},
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8"},
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5"},
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832"},
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6"},
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48"},
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a"},
{file = "charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e"},
{file = "charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110"},
{file = "charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b"},
{file = "charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72"},
{file = "charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10"},
{file = "charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f"},
{file = "charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d"},
{file = "charset_normalizer-3.4.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259"},
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01"},
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3"},
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30"},
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734"},
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60"},
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0"},
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545"},
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5"},
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f"},
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686"},
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706"},
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7"},
{file = "charset_normalizer-3.4.7-cp38-cp38-win32.whl", hash = "sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207"},
{file = "charset_normalizer-3.4.7-cp38-cp38-win_amd64.whl", hash = "sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83"},
{file = "charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217"},
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5"},
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9"},
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a"},
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc"},
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00"},
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776"},
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319"},
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24"},
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42"},
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4"},
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67"},
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274"},
{file = "charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366"},
{file = "charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444"},
{file = "charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c"},
{file = "charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d"},
{file = "charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5"},
]
[[package]]
name = "colorama"
version = "0.4.6"
description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["main", "dev"]
markers = "sys_platform == \"win32\""
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
[[package]]
name = "idna"
version = "3.18"
description = "Internationalized Domain Names in Applications (IDNA)"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"},
{file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"},
]
[package.extras]
all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
[[package]]
name = "iniconfig"
version = "2.3.0"
description = "brain-dead simple config-ini parsing"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
]
[[package]]
name = "librt"
version = "0.11.0"
description = "Mypyc runtime library"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
markers = "platform_python_implementation != \"PyPy\""
files = [
{file = "librt-0.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6e94ebfcfa2d5e9926d6c3b9aa4617ffc42a845b4321fb84021b872358c82a0f"},
{file = "librt-0.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ae627397a2f351560440d872d6f7c8dbb4072e57868e7b2fc5b8b430fe489d45"},
{file = "librt-0.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc329359321b67d24efdf4bc69012b0597001649544db662c001db5a0184794c"},
{file = "librt-0.11.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:7e82e642ab0f7608ce2fe53d76ca2280a9ee33a1b06556142c7c6fe80a86fc33"},
{file = "librt-0.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88145c15c67731d54283d135b03244028c750cc9edc334a96a4f5950ebdb2884"},
{file = "librt-0.11.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d36a51b3d93320b686588e27123f4995804dbf1bce81df78c02fc3c6eea9280"},
{file = "librt-0.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d00f3ac06a2a8b246327f11e186a53a100a4d5c7ed52346367e5ec751d51586c"},
{file = "librt-0.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:461bbceede621f1ffb8839755f8663e886087ee7af16294cab7fb4d782c62eeb"},
{file = "librt-0.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0cad8a4d6a8ff03c9b76f9414caccd78e7cfbc8a2e12fa334d8e1d9932753783"},
{file = "librt-0.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f37aa505b3cf60701562eddb32df74b12a9e380c207fd8b06dd157a943ac7ea0"},
{file = "librt-0.11.0-cp310-cp310-win32.whl", hash = "sha256:94663a21534637f0e787ec2a2a756022df6e5b7b2335a5cdd7d8e33d68a2af89"},
{file = "librt-0.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:dec7db73758c2b54953fd8b7fe348c45188fe26b39ee18446196edd08453a5d4"},
{file = "librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29"},
{file = "librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9"},
{file = "librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5"},
{file = "librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b"},
{file = "librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89"},
{file = "librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc"},
{file = "librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5"},
{file = "librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7"},
{file = "librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d"},
{file = "librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412"},
{file = "librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d"},
{file = "librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73"},
{file = "librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c"},
{file = "librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46"},
{file = "librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3"},
{file = "librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67"},
{file = "librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a"},
{file = "librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a"},
{file = "librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f"},
{file = "librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b"},
{file = "librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766"},
{file = "librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d"},
{file = "librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8"},
{file = "librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a"},
{file = "librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9"},
{file = "librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c"},
{file = "librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894"},
{file = "librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c"},
{file = "librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea"},
{file = "librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230"},
{file = "librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2"},
{file = "librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3"},
{file = "librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21"},
{file = "librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930"},
{file = "librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be"},
{file = "librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e"},
{file = "librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e"},
{file = "librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47"},
{file = "librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44"},
{file = "librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd"},
{file = "librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4"},
{file = "librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8"},
{file = "librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b"},
{file = "librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175"},
{file = "librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03"},
{file = "librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c"},
{file = "librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3"},
{file = "librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96"},
{file = "librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe"},
{file = "librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f"},
{file = "librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7"},
{file = "librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1"},
{file = "librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72"},
{file = "librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa"},
{file = "librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548"},
{file = "librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2"},
{file = "librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f"},
{file = "librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51"},
{file = "librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2"},
{file = "librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085"},
{file = "librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3"},
{file = "librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd"},
{file = "librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8"},
{file = "librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c"},
{file = "librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253"},
{file = "librt-0.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6bd72d903911d995ab666dbd1871f8b1e80925a699af8063fbf50053329fb05f"},
{file = "librt-0.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0ef69ac715f3cd8e5cd252cb2aebfa72c015492aacc339d5d7bf8fef3c62c677"},
{file = "librt-0.11.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:624a40c4a4ad7773315c287276cd024509b2c66ff5904f504bfc08d2c70293ab"},
{file = "librt-0.11.0-cp39-cp39-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:41dc19fe150b69716c8ece4f76773a9e8813fe3e35e032a58b4d46423fb8d7c0"},
{file = "librt-0.11.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e8bd98ea9c47ae90b319a087ab28dac493f1ffbc1ecd1f28fcdbf3b7e1108d1"},
{file = "librt-0.11.0-cp39-cp39-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84308fc49423ce6475d1c5d1985cd69a8ca9f0325fc7d5f81bb690a3f3625d4e"},
{file = "librt-0.11.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ff0fbaf5f44a21beeb0110f2ab64f45135a9536a834b79c0d1ef018f2786bbfa"},
{file = "librt-0.11.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:9c028a9442a18e266955d364ce42259136e79a7ba14d773e0d778d5f70cd56f1"},
{file = "librt-0.11.0-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:9f1692105a02bcf853f355032a5fdc5494358ef83d8fd22d16de375c85cec3f5"},
{file = "librt-0.11.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7a80a71e1fda83cc752a9141e87aae7fef279538597564d670e9ce513f286192"},
{file = "librt-0.11.0-cp39-cp39-win32.whl", hash = "sha256:140695816ddf3c86eb972981a26f35efd871c44b0c3aed44c8cd01749386617f"},
{file = "librt-0.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:92f7ff819c197fc30473190a12c2856f325ac90aabfccbeb2072d28cc2e234e3"},
{file = "librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1"},
]
[[package]]
name = "loguru"
version = "0.7.3"
description = "Python logging made (stupidly) simple"
optional = false
python-versions = "<4.0,>=3.5"
groups = ["main"]
files = [
{file = "loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c"},
{file = "loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6"},
]
[package.dependencies]
colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""}
win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""}
[package.extras]
dev = ["Sphinx (==8.1.3) ; python_version >= \"3.11\"", "build (==1.2.2) ; python_version >= \"3.11\"", "colorama (==0.4.5) ; python_version < \"3.8\"", "colorama (==0.4.6) ; python_version >= \"3.8\"", "exceptiongroup (==1.1.3) ; python_version >= \"3.7\" and python_version < \"3.11\"", "freezegun (==1.1.0) ; python_version < \"3.8\"", "freezegun (==1.5.0) ; python_version >= \"3.8\"", "mypy (==0.910) ; python_version < \"3.6\"", "mypy (==0.971) ; python_version == \"3.6\"", "mypy (==1.13.0) ; python_version >= \"3.8\"", "mypy (==1.4.1) ; python_version == \"3.7\"", "myst-parser (==4.0.0) ; python_version >= \"3.11\"", "pre-commit (==4.0.1) ; python_version >= \"3.9\"", "pytest (==6.1.2) ; python_version < \"3.8\"", "pytest (==8.3.2) ; python_version >= \"3.8\"", "pytest-cov (==2.12.1) ; python_version < \"3.8\"", "pytest-cov (==5.0.0) ; python_version == \"3.8\"", "pytest-cov (==6.0.0) ; python_version >= \"3.9\"", "pytest-mypy-plugins (==1.9.3) ; python_version >= \"3.6\" and python_version < \"3.8\"", "pytest-mypy-plugins (==3.1.0) ; python_version >= \"3.8\"", "sphinx-rtd-theme (==3.0.2) ; python_version >= \"3.11\"", "tox (==3.27.1) ; python_version < \"3.8\"", "tox (==4.23.2) ; python_version >= \"3.8\"", "twine (==6.0.1) ; python_version >= \"3.11\""]
[[package]]
name = "mypy"
version = "2.1.0"
description = "Optional static typing for Python"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "mypy-2.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:11a6beb180257a805961aea9ec591bbd0bd17f1e18d35b8456d57aee5bedfedc"},
{file = "mypy-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ef78c1d306bbf9a8a12f526c44902c9c28dffd6c52c52bf6a72641ce18d3849"},
{file = "mypy-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c209a90853081ff01d01ee895cafe10f7db1474e0d95beaeef0f6c1db9119bbd"},
{file = "mypy-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47cebf61abde7c088a4e27718a8b13a81655686b2e9c251f5c0915a802248166"},
{file = "mypy-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d57a90ae5e872138a425ec328edbc9b235d1934c4377881a33ec05b341acc9a8"},
{file = "mypy-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:aea7f7a8a55b459c34275fc468ada6ca7c173a5e43a68f5dbe588a563d8a06b8"},
{file = "mypy-2.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:c989640253f0d76843e9c6c1bbf4bd48c5e85ada61bde4beb37cb3eca035685e"},
{file = "mypy-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a683016b16fe2f572dc04c72be7ee0504ac1605a265d0200f5cea695fb788f41"},
{file = "mypy-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a293c534adb55271fef24a26da04b855540a8c13cc07bc5917b9fd2c394f2ca"},
{file = "mypy-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7406f4d048e71e576f5356d317e5b0a9e666dfd966bd99f9d14ca06e1a341538"},
{file = "mypy-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0210d626fc8b31ccc90233754c7bc90e1f43205e85d96387f7db1285b55c398"},
{file = "mypy-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3712c20deed54e814eaaa825603bada8ea1c390670a397c95b98405347acc563"},
{file = "mypy-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fcaa0e479066e31f7cceb6a3bea39cb22b2ff51a6b2f24f193d19179ba17c389"},
{file = "mypy-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:0b1a5260c95aa443083f9ed3592662941951bca3d4ca224a5dc517c38b7cf666"},
{file = "mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af"},
{file = "mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6"},
{file = "mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211"},
{file = "mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b"},
{file = "mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22"},
{file = "mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b"},
{file = "mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8"},
{file = "mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5"},
{file = "mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e"},
{file = "mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e"},
{file = "mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285"},
{file = "mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5"},
{file = "mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65"},
{file = "mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d"},
{file = "mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2"},
{file = "mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f"},
{file = "mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4"},
{file = "mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef"},
{file = "mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135"},
{file = "mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21"},
{file = "mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57"},
{file = "mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e"},
{file = "mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780"},
{file = "mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd"},
{file = "mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08"},
{file = "mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081"},
{file = "mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7"},
{file = "mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6"},
{file = "mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289"},
{file = "mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633"},
]
[package.dependencies]
ast-serialize = ">=0.3.0,<1.0.0"
librt = {version = ">=0.11.0", markers = "platform_python_implementation != \"PyPy\""}
mypy_extensions = ">=1.0.0"
pathspec = ">=1.0.0"
typing_extensions = {version = ">=4.6.0", markers = "python_version < \"3.15\""}
[package.extras]
dmypy = ["psutil (>=4.0)"]
faster-cache = ["orjson"]
install-types = ["pip"]
mypyc = ["setuptools (>=50)"]
reports = ["lxml"]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
description = "Type system extensions for programs checked with the mypy type checker."
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"},
{file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"},
]
[[package]]
name = "packaging"
version = "26.2"
description = "Core utilities for Python packages"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"},
{file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"},
]
[[package]]
name = "pathspec"
version = "1.1.1"
description = "Utility library for gitignore style pattern matching of file paths."
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189"},
{file = "pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a"},
]
[package.extras]
hyperscan = ["hyperscan (>=0.7)"]
optional = ["typing-extensions (>=4)"]
re2 = ["google-re2 (>=1.1)"]
[[package]]
name = "pillow"
version = "12.2.0"
description = "Python Imaging Library (fork)"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f"},
{file = "pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97"},
{file = "pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff"},
{file = "pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec"},
{file = "pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136"},
{file = "pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c"},
{file = "pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3"},
{file = "pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa"},
{file = "pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032"},
{file = "pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5"},
{file = "pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024"},
{file = "pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab"},
{file = "pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65"},
{file = "pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7"},
{file = "pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e"},
{file = "pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705"},
{file = "pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176"},
{file = "pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b"},
{file = "pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909"},
{file = "pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808"},
{file = "pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60"},
{file = "pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe"},
{file = "pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5"},
{file = "pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421"},
{file = "pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987"},
{file = "pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76"},
{file = "pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005"},
{file = "pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780"},
{file = "pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5"},
{file = "pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5"},
{file = "pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940"},
{file = "pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5"},
{file = "pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414"},
{file = "pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c"},
{file = "pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2"},
{file = "pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c"},
{file = "pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795"},
{file = "pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f"},
{file = "pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed"},
{file = "pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9"},
{file = "pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed"},
{file = "pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3"},
{file = "pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9"},
{file = "pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795"},
{file = "pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e"},
{file = "pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b"},
{file = "pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06"},
{file = "pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b"},
{file = "pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f"},
{file = "pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612"},
{file = "pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c"},
{file = "pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea"},
{file = "pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4"},
{file = "pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4"},
{file = "pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea"},
{file = "pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24"},
{file = "pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98"},
{file = "pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453"},
{file = "pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8"},
{file = "pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b"},
{file = "pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295"},
{file = "pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed"},
{file = "pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae"},
{file = "pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601"},
{file = "pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be"},
{file = "pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f"},
{file = "pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286"},
{file = "pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50"},
{file = "pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104"},
{file = "pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7"},
{file = "pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150"},
{file = "pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1"},
{file = "pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463"},
{file = "pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3"},
{file = "pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166"},
{file = "pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe"},
{file = "pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd"},
{file = "pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e"},
{file = "pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06"},
{file = "pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43"},
{file = "pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354"},
{file = "pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1"},
{file = "pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb"},
{file = "pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f"},
{file = "pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d"},
{file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f"},
{file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e"},
{file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0"},
{file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1"},
{file = "pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e"},
{file = "pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5"},
]
[package.extras]
docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"]
fpx = ["olefile"]
mic = ["olefile"]
test-arrow = ["arro3-compute", "arro3-core", "nanoarrow", "pyarrow"]
tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma (>=5)", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"]
xmp = ["defusedxml"]
[[package]]
name = "pluggy"
version = "1.6.0"
description = "plugin and hook calling mechanisms for python"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
]
[package.extras]
dev = ["pre-commit", "tox"]
testing = ["coverage", "pytest", "pytest-benchmark"]
[[package]]
name = "pygments"
version = "2.20.0"
description = "Pygments is a syntax highlighting package written in Python."
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"},
{file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"},
]
[package.extras]
windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "pyside6"
version = "6.11.1"
description = "Python bindings for the Qt cross-platform application and UI framework"
optional = false
python-versions = "<3.15,>=3.10"
groups = ["main"]
files = [
{file = "pyside6-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:537682c3b7530817203e667c1f5a2f00486b37bf52c52eeab438544c7a0917f6"},
{file = "pyside6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b1fc521ba2bb5109425ab8add06bddbdd524abcad06cfa012cc39a22a189feb2"},
{file = "pyside6-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:75f0005c3eb95c07cfb65522ec50d0815ac007a96482c21dc3cb4b4c04895d84"},
{file = "pyside6-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:0968877ab1fb4ef3587a284da6fe05e8647ada56a6a3750b6395188e01f4aba6"},
{file = "pyside6-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:acee467cb5f256cc47ebb9d815a054c1d8416da380c191b247a76d164aa3f805"},
]
[package.dependencies]
PySide6_Addons = "6.11.1"
PySide6_Essentials = "6.11.1"
shiboken6 = "6.11.1"
[[package]]
name = "pyside6-addons"
version = "6.11.1"
description = "Python bindings for the Qt cross-platform application and UI framework (Addons)"
optional = false
python-versions = "<3.15,>=3.10"
groups = ["main"]
files = [
{file = "pyside6_addons-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:54733c77f789bef5f03c6aff4ad3bec8b2eff021f0cfcbc53d5e6c250ded24f9"},
{file = "pyside6_addons-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:8e6c65fbd73a512d6f72cda8d8277444a85a34dc99dd1dae9c21d35b8671bb1f"},
{file = "pyside6_addons-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:bf1c6c4e954e5eba3d2a7c661ad4b9689e8f09c7f4a16bdf29713371d11af993"},
{file = "pyside6_addons-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:0d13c4dfd671b050a48e4f8d8ddc724b7248f9c0437e7fc47fdf316278572923"},
{file = "pyside6_addons-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:3494f480dee92f415be2f2d989c0b3f4755ac332b28045cbf4ba0f5c5a22ba37"},
]
[package.dependencies]
PySide6_Essentials = "6.11.1"
shiboken6 = "6.11.1"
[[package]]
name = "pyside6-essentials"
version = "6.11.1"
description = "Python bindings for the Qt cross-platform application and UI framework (Essentials)"
optional = false
python-versions = "<3.15,>=3.10"
groups = ["main"]
files = [
{file = "pyside6_essentials-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:228de53c2bc26b07e5021fbe3614fc44ca08e4dab9999af08c2b389d2c239957"},
{file = "pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:e3ef7027b41e4e55fadb56e3b3257dc8ee92154b639fe67fc4c8e05e9d976c60"},
{file = "pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:a039b6da68a3a4b9d243217b2b98d475eed3f617159ef6be925badab53c11b0d"},
{file = "pyside6_essentials-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:63311bd48e32c584599ab04b9ef7c324082374cd2c9fa533f978fb893bb47e40"},
{file = "pyside6_essentials-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:11253ea52aabecefe9febddbbe78b43a824129e3af1cec98431028fba7fa954f"},
]
[package.dependencies]
shiboken6 = "6.11.1"
[[package]]
name = "pytest"
version = "9.0.3"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9"},
{file = "pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c"},
]
[package.dependencies]
colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""}
iniconfig = ">=1.0.1"
packaging = ">=22"
pluggy = ">=1.5,<2"
pygments = ">=2.7.2"
[package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
[[package]]
name = "python-dotenv"
version = "1.2.2"
description = "Read key-value pairs from a .env file and set them as environment variables"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a"},
{file = "python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"},
]
[package.extras]
cli = ["click (>=5.0)"]
[[package]]
name = "requests"
version = "2.34.2"
description = "Python HTTP for Humans."
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"},
{file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"},
]
[package.dependencies]
certifi = ">=2023.5.7"
charset_normalizer = ">=2,<4"
idna = ">=2.5,<4"
urllib3 = ">=1.26,<3"
[package.extras]
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"]
[[package]]
name = "ruff"
version = "0.15.17"
description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false
python-versions = ">=3.7"
groups = ["dev"]
files = [
{file = "ruff-0.15.17-py3-none-linux_armv6l.whl", hash = "sha256:d9feddb927fc68bd295f5eebc587a7e42cfaf9b65f60ca4a2386febff575da8f"},
{file = "ruff-0.15.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25805a226d741c47d274a35ad5c10a7dde175fcddfa511d7cf3da0a21eb3eab7"},
{file = "ruff-0.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f6ad73b14c2d18a3bf8ad7cb6974294d7f613a7898604826058e6ac64918ef4d"},
{file = "ruff-0.15.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ba0c1e4f95bcb3869d0d30cbd5917071ef2e28665abfec970cdab0492c713ed"},
{file = "ruff-0.15.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81647960f10bff57d2e51cadd0c3950fe598400c852863a038720ef5b8cca91e"},
{file = "ruff-0.15.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e01a84ddbc8c16c23055ba3924476850f1bbc1917cebbb9376665a63e74260d"},
{file = "ruff-0.15.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fe9f653152f8f294f9f7e03bf3a453d8b4a27f7a59c78c8666167f2b17b96c"},
{file = "ruff-0.15.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c0fe88a7676e7a05b73174d4d4a59cb2ac21ff8263583f87a81a6018475a978"},
{file = "ruff-0.15.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecfc3c7878fff94633ab0348524e093f9ce3243080416dd7d14f8ba400174719"},
{file = "ruff-0.15.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b8461180b22420b1bdc289909410930761629fddf2a5aaf60fae1ab26cedc4c4"},
{file = "ruff-0.15.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6eccbe50a038b503e7140b441aa9c7fc8c1f36edf23ebef9f4165c2f28f568b7"},
{file = "ruff-0.15.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:382fc0521025f5a8ad447d8bdd523545d0d7646adb718eb1c2dac5065ec27c0f"},
{file = "ruff-0.15.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:456d41fcd1b2777ad63f09a6e7121d43f7b688bbc76a800c10f7f8fb1f912c3f"},
{file = "ruff-0.15.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1a04bcc94ae6194e9db05d16ad31f298a7194bfbcb08258bbe589cee1d587b8"},
{file = "ruff-0.15.17-py3-none-win32.whl", hash = "sha256:596065960ab1ff593f744220c9fe6580eda00a95003cffa9f4048bb5b1bf0392"},
{file = "ruff-0.15.17-py3-none-win_amd64.whl", hash = "sha256:6769e5fa1710b179b92e0bfa5a51735b35baea9013dadb06d5f44cbcf9547084"},
{file = "ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456"},
{file = "ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219"},
]
[[package]]
name = "shiboken6"
version = "6.11.1"
description = "Python/C++ bindings helper module"
optional = false
python-versions = "<3.15,>=3.10"
groups = ["main"]
files = [
{file = "shiboken6-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:1a16867f103ef1c662a5f09dfed03273a9f81688b174555162c58e83650a3f02"},
{file = "shiboken6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9a8bccfafc8805254cabcfa1edfaf55cd52889f4998c91ad0d9a4433fb1bcdbe"},
{file = "shiboken6-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:1bd2f4314414df2d122d9f646e03b731bc6d6b5f77a5f53f99a4fe4e97d84e6f"},
{file = "shiboken6-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:c2c6863aa80ec18c0f82cea3417837b279cdc60024ac17123461dc9042577df7"},
{file = "shiboken6-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:7c8d9af17db4495d4fa5b1c393f218311c4855546b9dfa6a0bd21bcd66b55e9d"},
]
[[package]]
name = "soupsieve"
version = "2.8.4"
description = "A modern CSS selector implementation for Beautiful Soup."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65"},
{file = "soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e"},
]
[[package]]
name = "truststore"
version = "0.10.4"
description = "Verify certificates using native system trust stores"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981"},
{file = "truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301"},
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
description = "Backported and Experimental Type Hints for Python 3.9+"
optional = false
python-versions = ">=3.9"
groups = ["main", "dev"]
files = [
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
]
[[package]]
name = "urllib3"
version = "2.7.0"
description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"},
{file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"},
]
[package.extras]
brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""]
h2 = ["h2 (>=4,<5)"]
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""]
[[package]]
name = "win32-setctime"
version = "1.2.0"
description = "A small Python utility to set file creation time on Windows"
optional = false
python-versions = ">=3.5"
groups = ["main"]
markers = "sys_platform == \"win32\""
files = [
{file = "win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390"},
{file = "win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0"},
]
[package.extras]
dev = ["black (>=19.3b0) ; python_version >= \"3.6\"", "pytest (>=4.6.2)"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.14,<3.15"
content-hash = "5906affa631fe91b3b93e881e52420a2e591110023722a7a2d5264c96706dcf2"
+4 -3
View File
@@ -1,7 +1,9 @@
import os
import sys
from pathlib import Path
from dotenv import load_dotenv
from src.constants import VERSION
load_dotenv()
@@ -52,8 +54,7 @@ if spec_path.exists():
if f"console={not console_mode}" in spec_content:
new_spec_content = spec_content.replace(
f"console={not console_mode}",
f"console={console_mode}"
f"console={not console_mode}", f"console={console_mode}"
)
with open(spec_path, "w", encoding="utf-8") as f:
f.write(new_spec_content)
@@ -63,4 +64,4 @@ if spec_path.exists():
else:
print(f"✗ {spec_filename} not found!")
print(f"{'-' * 50}\n")
print(f"{'-' * 50}\n")
+16 -27
View File
@@ -1,35 +1,24 @@
[project]
name = "curator"
version = "1.12.0"
description = ""
authors = [
{name = "jan.doubravsky@gmail.com"}
]
readme = "README.md"
requires-python = ">=3.14,<3.15"
version = "1.13.0"
requires-python = ">=3.14"
dependencies = [
"pyside6 (>=6.11.1,<7.0.0)",
"requests (>=2.34.2,<3.0.0)",
"beautifulsoup4 (>=4.15.0,<5.0.0)",
"python-dotenv (>=1.2.2,<2.0.0)",
"pillow (>=12.2.0,<13.0.0)",
"loguru (>=0.7.3,<0.8.0)",
"truststore (>=0.10.4,<0.11.0)"
"beautifulsoup4>=4.15.0",
"loguru>=0.7.3",
"pillow>=12.3.0",
"pyside6>=6.12.0",
"python-dotenv>=1.2.4",
"requests>=2.34.2",
"truststore>=0.10.4",
]
[tool.poetry]
package-mode = false
[tool.pytest.ini_options]
testpaths = ["tests"]
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"
[dependency-groups]
dev = [
"ruff (>=0.15.17,<0.16.0)",
"mypy (>=2.1.0,<3.0.0)",
"pytest (>=9.0.3,<10.0.0)"
"mypy>=2.4.0",
"pyinstaller>=6.22.3",
"pytest>=9.1.1",
"ruff>=0.17.0",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
+7 -7
View File
@@ -6,7 +6,7 @@ leaving the tag name untouched. A timestamped backup of the index is written
before saving.
Usage:
poetry run python scripts/migrate_tag_category.py <pool_dir> \
uv run python scripts/migrate_tag_category.py <pool_dir> \
--old "Země" --new "Země původu"
If ``<pool_dir>`` is omitted, the pool from the global config is used.
@@ -14,20 +14,20 @@ If ``<pool_dir>`` is omitted, the pool from the global config is used.
from __future__ import annotations
import sys
import argparse
import json
import shutil
import argparse
from pathlib import Path
import sys
from datetime import datetime
from pathlib import Path
from loguru import logger
# Allow running as a plain script (``python scripts/...``) by exposing the repo root.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.core.config import load_global_config # noqa: E402
from src.core.pool_index import INDEX_FILENAME # noqa: E402
from src.core.config import load_global_config
from src.core.pool_index import INDEX_FILENAME
def _rename_in_tags(tags: list[str], old: str, new: str) -> tuple[list[str], int]:
@@ -37,7 +37,7 @@ def _rename_in_tags(tags: list[str], old: str, new: str) -> tuple[list[str], int
result: list[str] = []
for tag in tags:
if isinstance(tag, str) and tag.startswith(prefix):
result.append(f"{new}/{tag[len(prefix):]}")
result.append(f"{new}/{tag[len(prefix) :]}")
changed += 1
else:
result.append(tag)
+12 -8
View File
@@ -7,27 +7,27 @@ Británie"``), de-duplicating within each record. A timestamped backup of the
index is written before saving.
Usage:
poetry run python scripts/split_country_tags.py [<pool_dir>] [--category "Země původu"]
uv run python scripts/split_country_tags.py [<pool_dir>] [--category "Země původu"]
If ``<pool_dir>`` is omitted, the pool from the global config is used.
"""
from __future__ import annotations
import sys
import argparse
import json
import shutil
import argparse
from pathlib import Path
import sys
from datetime import datetime
from pathlib import Path
from loguru import logger
# Allow running as a plain script (``python scripts/...``) by exposing the repo root.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.core.config import load_global_config # noqa: E402
from src.core.pool_index import INDEX_FILENAME # noqa: E402
from src.core.config import load_global_config
from src.core.pool_index import INDEX_FILENAME
def _split_record_tags(tags: list[str], category: str) -> tuple[list[str], int]:
@@ -46,8 +46,12 @@ def _split_record_tags(tags: list[str], category: str) -> tuple[list[str], int]:
result.append(tag)
for tag in tags:
if isinstance(tag, str) and tag.startswith(prefix) and "/" in tag[len(prefix):]:
value = tag[len(prefix):]
if (
isinstance(tag, str)
and tag.startswith(prefix)
and "/" in tag[len(prefix) :]
):
value = tag[len(prefix) :]
countries = [c.strip() for c in value.split("/") if c.strip()]
for country in countries:
_add(f"{prefix}{country}")
+12 -10
View File
@@ -7,26 +7,26 @@ This removes the legacy band tags (``Hodnocení/<x>–<y> %``) so re-fetching fr
backup of the index is written first.
Usage:
poetry run python scripts/strip_rating_bands.py [<pool_dir>] [--category "Hodnocení"]
uv run python scripts/strip_rating_bands.py [<pool_dir>] [--category "Hodnocení"]
"""
from __future__ import annotations
import re
import sys
import json
import shutil
import argparse
from pathlib import Path
import json
import re
import shutil
import sys
from datetime import datetime
from pathlib import Path
from loguru import logger
# Allow running as a plain script (``python scripts/...``) by exposing the repo root.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.core.config import load_global_config # noqa: E402
from src.core.pool_index import INDEX_FILENAME # noqa: E402
from src.core.config import load_global_config
from src.core.pool_index import INDEX_FILENAME
# A band tag looks like "Hodnocení/90–100 %" — value has a dash range and a "%".
_BAND_RE = re.compile(r"\d+\s*[–-]\s*\d+\s*%")
@@ -36,7 +36,8 @@ def _strip_bands(tags: list[str], category: str) -> tuple[list[str], int]:
"""Return (kept tags, removed count), dropping ``category`` band tags."""
prefix = f"{category}/"
kept = [
t for t in tags
t
for t in tags
if not (isinstance(t, str) and t.startswith(prefix) and _BAND_RE.search(t))
]
return kept, len(tags) - len(kept)
@@ -58,7 +59,8 @@ def migrate(index_path: Path, category: str) -> int:
# also drop them from the ČSFD provenance set, if present
if isinstance(record.get("csfd_tags"), list):
record["csfd_tags"] = [
t for t in record["csfd_tags"]
t
for t in record["csfd_tags"]
if not (t.startswith(f"{category}/") and _BAND_RE.search(t))
]
total_removed += removed
+6 -6
View File
@@ -5,26 +5,26 @@ Herec produced far too many folders). Cached ČSFD data is left intact — only
``tags`` lists are pruned. A timestamped backup of the index is written first.
Usage:
poetry run python scripts/strip_tag_categories.py [<pool_dir>] \
uv run python scripts/strip_tag_categories.py [<pool_dir>] \
--categories "Režie" "Herec"
"""
from __future__ import annotations
import sys
import argparse
import json
import shutil
import argparse
from pathlib import Path
import sys
from datetime import datetime
from pathlib import Path
from loguru import logger
# Allow running as a plain script (``python scripts/...``) by exposing the repo root.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.core.config import load_global_config # noqa: E402
from src.core.pool_index import INDEX_FILENAME # noqa: E402
from src.core.config import load_global_config
from src.core.pool_index import INDEX_FILENAME
def _strip(tags: list[str], prefixes: tuple[str, ...]) -> tuple[list[str], int]:
Regular → Executable
+26 -13
View File
@@ -16,11 +16,11 @@ Examples:
from __future__ import annotations
import os
import sys
import json
import random
import argparse
import json
import os
import random
import sys
from pathlib import Path
INDEX_FILENAME = ".Curator.!index"
@@ -34,7 +34,7 @@ def _load_config_paths(config_path: Path) -> tuple[Path | None, Path | None]:
try:
with open(config_path, "r", encoding="utf-8") as f:
cfg = json.load(f)
except (OSError, json.JSONDecodeError):
except OSError, json.JSONDecodeError:
return None, None
pool = cfg.get("pool_dir")
out = cfg.get("filmoteka_dir")
@@ -99,20 +99,33 @@ def generate_tips(pool_dir: Path, output_dir: Path, count: int, subfolder: str)
def main() -> None:
parser = argparse.ArgumentParser(description="Vygeneruje složku Tipy dne.")
parser.add_argument("--pool", type=Path, help="Pool root (obsahuje .Curator.!index)")
parser.add_argument(
"--pool", type=Path, help="Pool root (obsahuje .Curator.!index)"
)
parser.add_argument("--output", type=Path, help="Výstupní složka Filmotéky")
parser.add_argument("--count", type=int, default=DEFAULT_COUNT,
help=f"Počet náhodných titulů (výchozí {DEFAULT_COUNT})")
parser.add_argument("--subfolder", default=DEFAULT_SUBFOLDER,
help=f'Název složky tipů (výchozí "{DEFAULT_SUBFOLDER}")')
parser.add_argument("--config", type=Path,
help="Cesta ke global configu (.Curator.!gtag) pro doplnění cest")
parser.add_argument(
"--count",
type=int,
default=DEFAULT_COUNT,
help=f"Počet náhodných titulů (výchozí {DEFAULT_COUNT})",
)
parser.add_argument(
"--subfolder",
default=DEFAULT_SUBFOLDER,
help=f'Název složky tipů (výchozí "{DEFAULT_SUBFOLDER}")',
)
parser.add_argument(
"--config",
type=Path,
help="Cesta ke global configu (.Curator.!gtag) pro doplnění cest",
)
args = parser.parse_args()
pool, output = args.pool, args.output
if pool is None or output is None:
config_path = args.config or (
Path(__file__).resolve().parent.parent / GLOBAL_CONFIG_FILENAME)
Path(__file__).resolve().parent.parent / GLOBAL_CONFIG_FILENAME
)
cfg_pool, cfg_out = _load_config_paths(config_path)
pool = pool or cfg_pool
output = output or cfg_out
+1 -1
View File
@@ -1,2 +1,2 @@
"""Auto-generated — do not edit manually."""
__version__ = "1.12.0"
__version__ = "1.13.0"
+2 -1
View File
@@ -38,12 +38,13 @@ def get_version() -> str:
except OSError:
pass
return version
except (FileNotFoundError, KeyError):
except FileNotFoundError, KeyError:
pass
# 2. generated _version.py
try:
from src._version import __version__ # type: ignore[import]
return __version__
except ImportError:
pass
+29 -9
View File
@@ -11,6 +11,7 @@ Two stores, by responsibility:
describing a particular library: per-movie metadata **and** library-level
settings (``tag_schema``, ``copyasis_folders``). It travels with the pool.
"""
import json
from pathlib import Path
@@ -41,12 +42,30 @@ GLOBAL_CONFIG_FILE = Path(__file__).parent.parent.parent / ".Curator.!gtag"
# This lives in the code as the default; the effective schema is stored per-pool
# in the index (``PoolIndex.settings["tag_schema"]``).
DEFAULT_TAG_SCHEMA = [
{"category": "Žánr", "csfd_field": "genres", "transform": None, "filmoteka_root": ""},
{"category": "Rok", "csfd_field": "year", "transform": None, "filmoteka_root": "- Dle roku"},
{"category": "Země původu", "csfd_field": "countries", "transform": None,
"filmoteka_root": "- Dle země původu"},
{"category": "Hodnocení", "csfd_field": "rating", "transform": "decade_band",
"filmoteka_root": "- Dle hodnocení"},
{
"category": "Žánr",
"csfd_field": "genres",
"transform": None,
"filmoteka_root": "",
},
{
"category": "Rok",
"csfd_field": "year",
"transform": None,
"filmoteka_root": "- Dle roku",
},
{
"category": "Země původu",
"csfd_field": "countries",
"transform": None,
"filmoteka_root": "- Dle země původu",
},
{
"category": "Hodnocení",
"csfd_field": "rating",
"transform": "decade_band",
"filmoteka_root": "- Dle hodnocení",
},
]
@@ -60,8 +79,8 @@ DEFAULT_GLOBAL_CONFIG = {
"last_folder": None,
"sidebar_width": 250,
"recent_folders": [],
"pool_dir": None, # managed pool root (single source of truth)
"filmoteka_dir": None, # generated Filmotéka output (hardlink tree)
"pool_dir": None, # managed pool root (single source of truth)
"filmoteka_dir": None, # generated Filmotéka output (hardlink tree)
}
@@ -76,7 +95,7 @@ def load_global_config() -> dict:
if key not in config:
config[key] = value
return config
except Exception:
except OSError, ValueError, TypeError:
return DEFAULT_GLOBAL_CONFIG.copy()
return DEFAULT_GLOBAL_CONFIG.copy()
@@ -91,6 +110,7 @@ def save_global_config(cfg: dict):
# BACKWARDS COMPATIBILITY
# =============================================================================
def load_config():
"""Legacy function - returns global config"""
return load_global_config()
+471
View File
@@ -0,0 +1,471 @@
"""Č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
:mod:`src.core.file_manager` (:meth:`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 :class:`requests.Session` (so a batch pays the PoW
cost only once).
"""
from __future__ import annotations
import hashlib
import json
import re
import time
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import urlencode, urljoin
from loguru import logger
try:
import requests
from bs4 import BeautifulSoup
_HAS_DEPS = True
except ImportError: # pragma: no cover - exercised only without the extras
_HAS_DEPS = False
requests = None # type: ignore
BeautifulSoup = None # type: ignore
CSFD_BASE_URL = "https://www.csfd.cz"
CSFD_SEARCH_URL = "https://www.csfd.cz/hledat/"
HEADERS = {
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36"
),
"Accept-Language": "cs,en;q=0.9",
}
# Anubis anti-bot wall: a plain GET returns a 200 challenge page, not the movie.
ANUBIS_CHALLENGE_MARKER = 'id="anubis_challenge"'
ANUBIS_PASS_PATH = "/.within.website/x/cmd/anubis/api/pass-challenge"
# Safety cap so a difficulty bump can never spin forever (difficulty 1 needs ~16).
ANUBIS_MAX_NONCE = 50_000_000
class CsfdError(RuntimeError):
"""A ČSFD lookup could not be completed (deps missing, network, anti-bot)."""
@dataclass
class CsfdFilm:
"""The slice of a ČSFD film page Curator needs to reach a TMDb id."""
url: str
csfd_id: int | None = None
czech_title: str = ""
original_title: str = ""
year: str | None = None # four-digit string, or None
@property
def tmdb_query(self) -> tuple[str, str | None]:
"""``(title, year)`` best suited for a TMDb search — original title first."""
return (self.original_title or self.czech_title, self.year)
def dependencies_available() -> bool:
"""True when ``requests`` + ``beautifulsoup4`` are importable."""
return _HAS_DEPS
def _check_dependencies() -> None:
if not _HAS_DEPS:
raise CsfdError(
"ČSFD vyžaduje balíčky 'requests' a 'beautifulsoup4' (uv sync je doplní)."
)
def _extract_csfd_id(url: str) -> int | None:
"""Pull the numeric film id out of a ČSFD URL, or ``None``."""
match = re.search(r"/film/(\d+)", url)
return int(match.group(1)) if match else None
# ---------------------------------------------------------------------------
# Anubis proof-of-work (ported verbatim from the pre-Jellyfin csfd.py)
# ---------------------------------------------------------------------------
def _extract_json_blob(html: str, element_id: str):
"""Return the parsed JSON from an Anubis ``<script id=...>`` blob, or None."""
match = re.search(
rf'<script id="{re.escape(element_id)}" type="application/json">(.*?)</script>',
html,
re.DOTALL,
)
if not match:
return None
try:
return json.loads(match.group(1))
except json.JSONDecodeError:
return None
def _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)``.
"""
full_zero_bytes = difficulty // 2
needs_half_byte = difficulty % 2 != 0
start = time.monotonic()
for nonce in range(ANUBIS_MAX_NONCE):
digest = hashlib.sha256(f"{random_data}{nonce}".encode()).digest()
if any(digest[i] != 0 for i in range(full_zero_bytes)):
continue
if needs_half_byte and digest[full_zero_bytes] >> 4 != 0:
continue
elapsed_ms = int((time.monotonic() - start) * 1000)
return digest.hex(), nonce, elapsed_ms
raise CsfdError(
f"Anubis PoW nevyřešen do {ANUBIS_MAX_NONCE} pokusů (obtížnost {difficulty})"
)
def _solve_anubis_challenge(session, html: str, url: str):
"""Solve the Anubis challenge in ``html`` and return the real page response."""
payload = _extract_json_blob(html, "anubis_challenge")
if not payload:
raise CsfdError("ČSFD anti-bot stránka bez čitelné Anubis challenge.")
rules = payload.get("rules", {})
challenge = payload.get("challenge", {})
random_data = challenge.get("randomData")
difficulty = int(rules.get("difficulty", 1))
if not random_data:
raise CsfdError("Anubis challenge neobsahuje randomData.")
base_prefix = _extract_json_blob(html, "anubis_base_prefix") or ""
logger.debug(f"Solving Anubis challenge (difficulty {difficulty}) for {url}")
hash_hex, nonce, elapsed_ms = _solve_anubis_pow(random_data, difficulty)
logger.debug(f"Anubis solved: nonce={nonce}, elapsed={elapsed_ms}ms")
pass_url = urljoin(CSFD_BASE_URL, f"{base_prefix}{ANUBIS_PASS_PATH}")
response = session.get(
pass_url,
params={
"id": challenge.get("id"),
"response": hash_hex,
"nonce": nonce,
"redir": url,
"elapsedTime": elapsed_ms,
},
headers=HEADERS,
timeout=10,
)
response.raise_for_status()
if ANUBIS_CHALLENGE_MARKER in response.text:
raise CsfdError("ČSFD Anubis challenge se nepodařilo vyřešit (odmítnuto).")
return response
def _get_page(session, url: str):
"""GET ``url`` through ``session``, transparently clearing an Anubis wall."""
response = session.get(url, headers=HEADERS, timeout=10)
response.raise_for_status()
if ANUBIS_CHALLENGE_MARKER in response.text:
response = _solve_anubis_challenge(session, response.text, url)
return response
# ---------------------------------------------------------------------------
# Page parsing
# ---------------------------------------------------------------------------
def _parse_json_ld(soup) -> tuple[str, str | None]:
"""``(name, year)`` from the film page's JSON-LD (name is the Czech title)."""
for script in soup.find_all("script", type="application/ld+json"):
try:
data = json.loads(script.string or "")
except json.JSONDecodeError, TypeError:
continue
if isinstance(data, list):
data = next((d for d in data if d.get("@type") == "Movie"), None)
if not isinstance(data, dict) or data.get("@type") != "Movie":
continue
name = (data.get("name") or "").strip()
year: str | None = None
for key in ("dateCreated", "datePublished"):
match = re.search(r"(19|20)\d{2}", str(data.get(key) or ""))
if match:
year = match.group(0)
break
return name, year
return "", None
def _parse_origin(soup) -> tuple[str | None, str | None]:
"""``(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.
"""
elem = soup.select_one(".origin")
if not elem:
return None, None
raw = elem.get_text(separator="|", strip=True)
tokens = [t.strip() for part in raw.split("|") for t in part.split(",")]
country: str | None = None
year: str | None = None
for token in tokens:
if not token:
continue
if year is None and re.fullmatch(r"(19|20)\d{2}", token):
year = token
continue
if country is None and not token[0].isdigit() and re.search(r"[^\W\d_]", token):
country = token.split("/")[0].strip()
return country, year
# ČSFD flags older Czech films "Československo" while ``.origin`` may read "Česko".
_CZ_ORIGIN_ALIASES = {"česko", "československo"}
def _parse_original_title(soup, origin_country: str | None) -> str | None:
"""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.
"""
names: list[tuple[str, str]] = []
for li in soup.select(".film-names li"):
flag = li.select_one("img.flag")
country = str(flag.get("title") or "").strip() if flag else ""
for junk in li.select("span, img"):
junk.extract()
text = " ".join(li.get_text(" ").split()).strip(" ·")
if text:
names.append((country, text))
if not names or not origin_country:
return None
wanted = origin_country.casefold()
for country, text in names:
here = country.casefold()
if here == wanted or (
wanted in _CZ_ORIGIN_ALIASES and here in _CZ_ORIGIN_ALIASES
):
return text
return None
def fetch_film(url: str, session=None) -> CsfdFilm:
"""Fetch a ČSFD film page → :class:`CsfdFilm` (Czech + original title, year).
Pass a shared ``requests.Session`` to reuse the Anubis auth cookie across a
batch. Raises :class:`CsfdError` on a network / anti-bot / parse failure.
"""
_check_dependencies()
own_session = session is None
if own_session:
session = requests.Session()
try:
html = _get_page(session, url).text
except requests.RequestException as exc:
raise CsfdError(f"Nelze načíst ČSFD stránku: {exc}") from exc
finally:
if own_session:
session.close()
soup = BeautifulSoup(html, "html.parser")
czech_title, year = _parse_json_ld(soup)
origin_country, origin_year = _parse_origin(soup)
year = year or origin_year
if not czech_title:
header = soup.select_one(".film-header-name h1")
czech_title = header.get_text(strip=True) if header else ""
original_title = _parse_original_title(soup, origin_country) or czech_title
return CsfdFilm(
url=url,
csfd_id=_extract_csfd_id(url),
czech_title=czech_title,
original_title=original_title,
year=str(year) if year else None,
)
def _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.
"""
for section in soup.find_all("section"):
header = section.select_one(".box-header h2") or section.find("h2")
if header and header.get_text(strip=True).casefold() == "filmy":
return section
return soup
def _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."""
parts = [p for p in href.split("/") if p and p != "prehled"]
return len(parts) == 2 and parts[0] == "film"
def search_films(query: str, limit: int = 5, session=None) -> list[CsfdFilm]:
"""Search ČSFD for ``query``; film hits only (no series/episodes), best first."""
_check_dependencies()
if not query.strip():
return []
search_url = f"{CSFD_SEARCH_URL}?{urlencode({'q': query.strip()})}"
own_session = session is None
if own_session:
session = requests.Session()
try:
html = _get_page(session, search_url).text
except requests.RequestException as exc:
raise CsfdError(f"ČSFD hledání selhalo: {exc}") from exc
finally:
if own_session:
session.close()
soup = BeautifulSoup(html, "html.parser")
films: list[CsfdFilm] = []
for anchor in _films_section(soup).select("a.film-title-name"):
href = str(anchor.get("href") or "")
if not _is_film_href(href):
continue
films.append(
CsfdFilm(
url=urljoin(CSFD_BASE_URL, href),
csfd_id=_extract_csfd_id(href),
czech_title=anchor.get_text(strip=True),
)
)
if len(films) >= limit:
break
return films
def find_csfd_url(query: str, session=None) -> str | None:
"""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.
"""
if not query or not query.strip():
return None
query = query.strip()
films = search_films(query, limit=1, session=session)
if not films:
without_year = re.sub(r"\s+(?:19|20)\d{2}$", "", query)
if without_year != query:
films = search_films(without_year, limit=1, session=session)
return films[0].url if films else None
# Release-name tokens that mark the end of the actual title in a filename.
_RELEASE_MARKERS = {
"bluray",
"blu-ray",
"brrip",
"bdrip",
"bdremux",
"remux",
"webrip",
"web",
"web-dl",
"webdl",
"hdtv",
"dvdrip",
"dvd",
"dvd5",
"dvd9",
"hdrip",
"cam",
"ts",
"tc",
"x264",
"x265",
"h264",
"h265",
"hevc",
"avc",
"xvid",
"divx",
"aac",
"ac3",
"eac3",
"dts",
"dd5",
"ddp5",
"truehd",
"atmos",
"flac",
"10bit",
"8bit",
"hdr",
"hdr10",
"dolby",
"sdr",
"proper",
"repack",
"extended",
"unrated",
"remastered",
"imax",
"multi",
"dual",
"complete",
"internal",
"limited",
"uncut",
}
_YEAR_RE = re.compile(r"^(19|20)\d{2}$")
_RESOLUTION_RE = re.compile(r"^\d{3,4}p$|^[24]k$", re.IGNORECASE)
def 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"
"""
stem = Path(filename).stem
tokens = [t for t in re.split(r"[.\s_]+", stem) if t]
title_words: list[str] = []
year: str | None = None
for token in tokens:
bare = token.strip("()[]{}")
if _YEAR_RE.match(bare):
year = bare
break
if _RESOLUTION_RE.match(bare) or bare.lower() in _RELEASE_MARKERS:
break
title_words.append(token)
title = " ".join(title_words).strip() or re.sub(r"[.\s_]+", " ", stem).strip()
return f"{title} {year}".strip() if year else title
+178 -28
View File
@@ -1,4 +1,6 @@
import re
from pathlib import Path
from .tag import Tag
@@ -33,15 +35,80 @@ class File:
# free-form per-movie attributes (e.g. "collection_sort"); usable in
# miniDLNA filename templates via name_context.
self.attributes: dict[str, str] = {}
# Identity key of the movie record this file belongs to (set on load /
# first save); ``None`` only for an index-less, in-memory File.
self._movie_key: str | None = None
self.get_metadata()
def _identity_from_path(self) -> 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.
"""
from .movie import identity_key
from .naming import (
is_canonical_pool_stem,
parse_3d_format,
parse_pool_stem,
parse_tmdb_id,
)
parent = self.file_path.parent.name
grouped = is_canonical_pool_stem(parent)
identity_stem = parent if grouped else self.file_path.stem
parsed = parse_pool_stem(identity_stem)
year = str(parsed[1]) if parsed else None
tmdb_id = parse_tmdb_id(identity_stem)
title = self.title or (parsed[0] if parsed else self.file_path.stem)
label = ""
part = (
re.fullmatch(
rf"{re.escape(parent)}[\s._-]*(?:part|pt|cd|dvd|disc|disk)[\s._-]*(\d{{1,2}})",
self.file_path.stem,
re.IGNORECASE,
)
if grouped
else None
)
if part:
# one part of a film split across files: "<folder>-part2"
label = f"part{int(part.group(1))}"
elif grouped and self.file_path.stem.startswith(f"{parent} - "):
label = self.file_path.stem[len(parent) + 3 :]
elif parse_3d_format(self.file_path.stem):
label = parse_3d_format(self.file_path.stem) or ""
return identity_key(tmdb_id, title, year), title, year, tmdb_id, label
def get_metadata(self) -> None:
record = self.index.get(self.file_path) if self.index is not None else None
if record is None:
"""Load this file's metadata from the movie record that owns it.
Since the pool index became identity-keyed (see :mod:`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.
"""
if self.index is None:
self._init_new_metadata()
self.save_metadata()
else:
return
found = self.index.find_movie_by_media(self.file_path)
if found is not None:
self._movie_key, record = found
self._apply_record(record)
return
self._init_new_metadata()
key, *_ = self._identity_from_path()
existing = self.index.get_movie(key)
if existing is not None:
self._movie_key = key
self._apply_record(existing)
self.save_metadata()
def _init_new_metadata(self) -> None:
self.new = True
@@ -60,7 +127,9 @@ class File:
"new": self.new,
"ignored": self.ignored,
# ukládáme full_path tagů
"tags": [tag.full_path if isinstance(tag, Tag) else tag for tag in self.tags],
"tags": [
tag.full_path if isinstance(tag, Tag) else tag for tag in self.tags
],
# which of the tags were derived automatically (from the NFO)
"auto_tags": sorted(self.auto_tag_paths),
# date může být None
@@ -88,7 +157,9 @@ class File:
raw_meta = data.get("meta")
if raw_meta is None: # legacy: pull year/rating out of the old csfd cache
old = data.get("csfd_cache") or {}
raw_meta = {"year": old.get("year"), "rating": old.get("rating")} if old else None
raw_meta = (
{"year": old.get("year"), "rating": old.get("rating")} if old else None
)
self.meta = dict(raw_meta) if raw_meta else None
if not self.tagmanager:
@@ -101,9 +172,42 @@ class File:
self.tags.append(tag)
def save_metadata(self):
"""Persist metadata to the pool index; a no-op for an index-less File."""
if self.index is not None:
self.index.set(self.file_path, self._build_record())
"""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.
"""
if self.index is None:
return
from .movie import ROLE_VIDEO
derived_key, title, year, tmdb_id, label = self._identity_from_path()
key = self._movie_key or derived_key
record = self.index.get_movie(key) or {}
record.update(self._build_record())
# The file's actual location is the truth — a move must update this.
record["library"] = self._library_name()
record["title"] = title
record["year"] = year
record["tmdb_id"] = tmdb_id
# Keep this file listed exactly once, with the label its name implies.
rel = self.index._key(self.file_path)
media = [m for m in record.get("media", []) if m.get("file") != rel]
media.append({"role": ROLE_VIDEO, "label": label, "file": rel})
record["media"] = media
self.index.set_movie(key, record)
self._movie_key = key
def _library_name(self) -> str:
"""Top-level pool folder this file sits in (its library), or ``""``."""
if self.index is None:
return ""
rel = self.index._key(self.file_path)
parts = Path(rel).parts
return parts[0] if len(parts) > 1 else ""
def added_timestamp(self) -> float:
"""Epoch seconds for 'date added' — the stored ``added`` or the file mtime.
@@ -116,6 +220,7 @@ class File:
if self.added:
try:
from datetime import datetime
return datetime.fromisoformat(self.added).timestamp()
except ValueError:
pass
@@ -141,20 +246,23 @@ class File:
if year is None:
# Last resort: recover it from a canonical "Title (YYYY)" filename.
from .naming import parse_pool_stem
parsed = parse_pool_stem(self.file_path.stem)
if parsed is not None:
year = parsed[1]
rating = cache.get("rating")
# user attributes first; core fields take precedence over same-named keys
context = dict(self.attributes)
context.update({
"title": self.title or self.file_path.stem,
"year": "" if year is None else str(year),
"rating": "" if rating is None else rating,
"ext": self.file_path.suffix,
"stem": self.file_path.stem,
"filename": self.filename,
})
context.update(
{
"title": self.title or self.file_path.stem,
"year": "" if year is None else str(year),
"rating": "" if rating is None else rating,
"ext": self.file_path.suffix,
"stem": self.file_path.stem,
"filename": self.filename,
}
)
return context
def set_attribute(self, key: str, value: str | None) -> None:
@@ -168,20 +276,57 @@ class File:
self.attributes.pop(key, None)
self.save_metadata()
def save_location(self) -> None:
"""Persist a rename/move of this file without touching shared metadata.
Every version of a film holds its own in-memory copy of the movie
record's shared fields (title, tags, ČSFD, …). A sibling loaded before
someone changed those fields would write its stale copy back on a plain
:meth:`save_metadata`. Re-reading the record first means only this
file's own media entry — path and popisek — actually changes.
"""
if self.index is not None and self.index.find_movie_by_media(self.file_path):
self.get_metadata()
self.save_metadata()
def delete_metadata(self) -> None:
"""Remove this file's metadata from the pool index (no-op without one)."""
if self.index is not None:
self.index.delete(self.file_path)
"""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.
"""
if self.index is None:
return
found = self.index.find_movie_by_media(self.file_path)
if found is None:
return
key, record = found
rel = self.index._key(self.file_path)
record["media"] = [m for m in record.get("media", []) if m.get("file") != rel]
if record["media"]:
self.index.set_movie(key, record)
else:
self.index.delete_movie(key)
def relocate(self, new_path: Path) -> None:
"""Point this File at a new path, moving its metadata along.
"""Point this File at a new path, moving its media entry along.
The physical file must already have been moved/renamed by the caller.
Drops the metadata under the old index key and rebinds to the new path;
call ``save_metadata()`` afterwards to write it back.
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.
"""
if self.index is not None:
self.index.delete(self.file_path)
found = self.index.find_movie_by_media(self.file_path)
if found is not None:
key, record = found
old_rel = self.index._key(self.file_path)
new_rel = self.index._key(Path(new_path))
for media in record.get("media", []):
if media.get("file") == old_rel:
media["file"] = new_rel
self.index.set_movie(key, record)
self._movie_key = key
self.file_path = Path(new_path)
self.filename = self.file_path.name
@@ -210,6 +355,7 @@ class File:
if schema is None:
from .config import DEFAULT_TAG_SCHEMA
schema = DEFAULT_TAG_SCHEMA
self.meta = {"year": nfo.year, "rating": nfo.rating}
@@ -225,8 +371,11 @@ class File:
continue # user-only category
category = entry["category"]
for value in nfo.field_values(field):
tag_obj = (self.tagmanager.add_tag(category, value)
if self.tagmanager else Tag(category, value))
tag_obj = (
self.tagmanager.add_tag(category, value)
if self.tagmanager
else Tag(category, value)
)
if tag_obj not in self.tags:
self.tags.append(tag_obj)
tags_added.append(tag_obj.full_path)
@@ -241,6 +390,7 @@ class File:
def add_tag(self, tag):
# tag může být Tag nebo string
from .tag import Tag as TagClass
if isinstance(tag, str):
if "/" in tag and self.tagmanager:
category, name = tag.split("/", 1)
@@ -269,4 +419,4 @@ class File:
return
if tag_obj in self.tags:
self.tags.remove(tag_obj)
self.save_metadata()
self.save_metadata()
+1508 -121
View File
File diff suppressed because it is too large Load Diff
+149 -95
View File
@@ -17,11 +17,13 @@ Example:
└── 1988/
└── film.mkv (hardlink)
"""
import os
import random
import shutil
from collections import defaultdict
from pathlib import Path
from typing import List, Tuple, Optional, Dict, Set
from .file import File
from .naming import is_valid_year, parse_pool_stem
@@ -29,7 +31,7 @@ from .naming import is_valid_year, parse_pool_stem
class _SafeDict(dict):
"""dict for str.format_map that leaves unknown fields as an empty string."""
def __missing__(self, key): # noqa: ANN001
def __missing__(self, key):
return ""
@@ -53,14 +55,14 @@ class HardlinkManager:
output_dir: Base directory where the tag-based structure will be created
"""
self.output_dir = Path(output_dir)
self.created_links: List[Path] = []
self.errors: List[Tuple[Path, str]] = []
self.created_links: list[Path] = []
self.errors: list[tuple[Path, str]] = []
def _resolve_roots(
self,
categories: Optional[List[str]],
category_roots: Optional[Dict[str, str]],
) -> Optional[Dict[str, str]]:
categories: list[str] | None,
category_roots: dict[str, str] | None,
) -> dict[str, str] | None:
"""Normalize the two filter styles into a category → root-folder map.
``None`` means "all categories", folder == category name.
@@ -85,7 +87,7 @@ class HardlinkManager:
"""Filmotéka hardlink name: the clean title + extension (no year)."""
return f"{self._clean_title(file_obj)}{file_obj.file_path.suffix}"
def _year_link_name(self, file_obj: File) -> Optional[str]:
def _year_link_name(self, file_obj: File) -> str | None:
"""``Title (YYYY).ext`` used to disambiguate a name collision, or None."""
year = str(file_obj.name_context().get("year") or "").strip()
if not is_valid_year(year):
@@ -96,18 +98,18 @@ class HardlinkManager:
"""Render a per-category filename template; fall back to the clean name."""
try:
rendered = template.format_map(_SafeDict(file_obj.name_context()))
except (ValueError, KeyError, IndexError, AttributeError):
except ValueError, KeyError, IndexError, AttributeError:
return self._default_link_name(file_obj)
rendered = rendered.replace("/", "-").replace("\\", "-").strip()
return rendered or self._default_link_name(file_obj)
def _plan_links(
self,
files: List[File],
roots: Optional[Dict[str, str]],
transforms: Optional[Dict[str, str]],
templates: Optional[Dict[str, str]],
) -> List[Tuple[File, Path]]:
files: list[File],
roots: dict[str, str] | None,
transforms: dict[str, str] | None,
templates: dict[str, str] | None,
) -> 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
@@ -121,7 +123,7 @@ class HardlinkManager:
can never disagree on a name (which is what caused hardlink churn before).
"""
# (file_obj, target_dir, name, is_templated), one per file+folder
entries: List[List] = []
entries: list[list] = []
seen_fd: set[tuple[str, str]] = set()
for file_obj in files:
for tag in file_obj.tags:
@@ -135,10 +137,17 @@ class HardlinkManager:
template = templates.get(tag.category) if templates else None
if template:
entries.append(
[file_obj, target_dir, self._render_template(file_obj, template), True])
[
file_obj,
target_dir,
self._render_template(file_obj, template),
True,
]
)
else:
entries.append(
[file_obj, target_dir, self._default_link_name(file_obj), False])
[file_obj, target_dir, self._default_link_name(file_obj), False]
)
# Disambiguate default-named collisions (different movies) by their year.
groups: dict[tuple[Path, str], set[str]] = defaultdict(set)
@@ -153,10 +162,10 @@ class HardlinkManager:
entry[2] = year_name
# Final pass: guarantee unique paths (stable numeric suffix on any tie).
by_path: dict[tuple[Path, str], List[List]] = defaultdict(list)
by_path: dict[tuple[Path, str], list[list]] = defaultdict(list)
for entry in entries:
by_path[(entry[1], entry[2])].append(entry)
plan: List[Tuple[File, Path]] = []
plan: list[tuple[File, Path]] = []
for (target_dir, name), group in by_path.items():
if len(group) == 1:
plan.append((group[0][0], target_dir / name))
@@ -168,7 +177,7 @@ class HardlinkManager:
plan.append((entry[0], target_dir / fname))
return plan
def _folder_value(self, tag, transforms: Optional[Dict[str, str]]) -> str:
def _folder_value(self, tag, transforms: dict[str, str] | None) -> 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
@@ -176,13 +185,16 @@ class HardlinkManager:
"""
if transforms and tag.category in transforms:
from .transforms import apply_transform
return apply_transform(tag.name, transforms[tag.category])
return tag.name
def _target_dir(
self, tag, roots: Optional[Dict[str, str]],
transforms: Optional[Dict[str, str]] = None,
) -> Optional[Path]:
self,
tag,
roots: dict[str, str] | None,
transforms: dict[str, str] | None = None,
) -> Path | None:
"""Output directory for a tag, or None if its category is excluded."""
if roots is None:
folder = tag.category
@@ -194,10 +206,12 @@ class HardlinkManager:
return base / self._folder_value(tag, transforms)
def _managed_top_dirs(
self, files: List[File], roots: Optional[Dict[str, str]],
transforms: Optional[Dict[str, str]] = None,
reserved_subfolders: Optional[Set[str]] = None,
) -> Optional[Set[str]]:
self,
files: list[File],
roots: dict[str, str] | None,
transforms: dict[str, str] | None = None,
reserved_subfolders: set[str] | None = None,
) -> set[str] | None:
"""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
@@ -212,7 +226,7 @@ class HardlinkManager:
return None
grouping_roots = {folder for folder in roots.values() if folder}
has_root_level = any(not folder for folder in roots.values())
tops: Set[str] = set(grouping_roots)
tops: set[str] = set(grouping_roots)
# genre folders currently present on the movies
for cat, folder in roots.items():
@@ -227,20 +241,23 @@ class HardlinkManager:
if has_root_level and self.output_dir.exists():
reserved = set(reserved_subfolders or set())
for entry in self.output_dir.iterdir():
if (entry.is_dir() and entry.name not in grouping_roots
and entry.name not in reserved):
if (
entry.is_dir()
and entry.name not in grouping_roots
and entry.name not in reserved
):
tops.add(entry.name)
return tops
def create_structure_for_files(
self,
files: List[File],
categories: Optional[List[str]] = None,
files: list[File],
categories: list[str] | None = 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]:
category_roots: dict[str, str] | None = None,
category_transforms: dict[str, str] | None = None,
category_filename_templates: dict[str, str] | None = None,
) -> tuple[int, int]:
"""
Create hardlink structure for given files based on their tags.
@@ -266,7 +283,8 @@ class HardlinkManager:
fail_count = 0
plan = self._plan_links(
files, roots, category_transforms, category_filename_templates)
files, roots, category_transforms, category_filename_templates
)
for file_obj, target_file in plan:
try:
if not dry_run:
@@ -291,11 +309,8 @@ class HardlinkManager:
return success_count, fail_count
def mirror_as_is(
self,
source_dir: Path,
subfolder: str | None = None,
dry_run: bool = False
) -> Tuple[int, int]:
self, 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
@@ -341,9 +356,29 @@ class HardlinkManager:
return success_count, fail_count
def remove_mirror(self, 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.
"""
target = self.output_dir / name
if not target.exists():
return False
if target.is_dir():
shutil.rmtree(target)
else:
target.unlink()
return True
def _fill_special_folder(
self, chosen: List[File], subfolder: str, dry_run: bool
) -> Tuple[int, int]:
self, chosen: list[File], subfolder: str, dry_run: bool
) -> tuple[int, int]:
"""Empty ``subfolder`` and hardlink ``chosen`` files into it."""
base = self.output_dir / subfolder
@@ -383,11 +418,11 @@ class HardlinkManager:
def generate_recently_added(
self,
files: List[File],
files: list[File],
count: int = 10,
subfolder: str = "- Nově přidané",
dry_run: bool = False,
) -> Tuple[int, int]:
) -> tuple[int, int]:
"""(Re)fill ``subfolder`` with the ``count`` most recently added movies.
Ordering is by ``File.added_timestamp()`` (import time, ctime fallback),
@@ -398,11 +433,11 @@ class HardlinkManager:
def generate_random_tips(
self,
files: List[File],
files: list[File],
count: int = 10,
subfolder: str = "Tipy dne",
dry_run: bool = False,
) -> Tuple[int, int]:
) -> 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.
@@ -470,12 +505,12 @@ class HardlinkManager:
def get_preview(
self,
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]]:
files: list[File],
categories: list[str] | None = None,
category_roots: dict[str, str] | None = None,
category_transforms: dict[str, str] | None = None,
category_filename_templates: dict[str, str] | None = None,
) -> list[tuple[Path, Path]]:
"""
Get a preview of what links would be created.
@@ -492,18 +527,19 @@ class HardlinkManager:
"""
roots = self._resolve_roots(categories, category_roots)
plan = self._plan_links(
files, roots, category_transforms, category_filename_templates)
files, roots, category_transforms, category_filename_templates
)
return [(file_obj.file_path, target_file) for file_obj, target_file in plan]
def find_obsolete_links(
self,
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]]:
files: list[File],
categories: list[str] | None = None,
category_roots: dict[str, str] | None = None,
category_transforms: dict[str, str] | None = None,
category_filename_templates: dict[str, str] | None = None,
reserved_subfolders: set[str] | None = None,
) -> list[tuple[Path, Path]]:
"""
Find hardlinks in the output directory that no longer match file tags.
@@ -522,7 +558,7 @@ class HardlinkManager:
Returns:
List of tuples (link_path, source_path) for obsolete links
"""
obsolete: List[Tuple[Path, Path]] = []
obsolete: list[tuple[Path, Path]] = []
if not self.output_dir.exists():
return obsolete
@@ -559,7 +595,8 @@ class HardlinkManager:
# a tag was removed, the movie was replaced (orphan inode), or the folder
# itself is a leftover from a renamed layout.
top_dirs = self._managed_top_dirs(
files, roots, category_transforms, reserved_subfolders)
files, roots, category_transforms, reserved_subfolders
)
for top in self.output_dir.iterdir():
if not top.is_dir():
continue
@@ -578,22 +615,20 @@ class HardlinkManager:
if link_inode in expected.get(link_file, ()):
continue
source = inode_to_file.get(link_inode)
obsolete.append(
(link_file, source.file_path if source else link_file)
)
obsolete.append((link_file, source.file_path if source else link_file))
return obsolete
def remove_obsolete_links(
self,
files: List[File],
categories: Optional[List[str]] = None,
files: list[File],
categories: list[str] | None = 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]]:
category_roots: dict[str, str] | None = None,
category_transforms: dict[str, str] | None = None,
category_filename_templates: dict[str, str] | None = None,
reserved_subfolders: set[str] | None = None,
) -> tuple[int, list[Path]]:
"""
Remove hardlinks that no longer match file tags.
@@ -612,8 +647,13 @@ class HardlinkManager:
Tuple of (removed_count, list_of_removed_paths)
"""
obsolete = self.find_obsolete_links(
files, categories, category_roots, category_transforms,
category_filename_templates, reserved_subfolders)
files,
categories,
category_roots,
category_transforms,
category_filename_templates,
reserved_subfolders,
)
removed_paths = []
if dry_run:
@@ -633,14 +673,14 @@ class HardlinkManager:
def sync_structure(
self,
files: List[File],
categories: Optional[List[str]] = None,
files: list[File],
categories: list[str] | None = 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]:
category_roots: dict[str, str] | None = None,
category_transforms: dict[str, str] | None = None,
category_filename_templates: dict[str, str] | None = None,
reserved_subfolders: set[str] | None = None,
) -> tuple[int, int, int, int]:
"""
Synchronize hardlink structure with current file tags.
@@ -663,31 +703,45 @@ class HardlinkManager:
Tuple of (created, create_failed, removed, remove_failed)
"""
# First find how many obsolete links there are
obsolete_count = len(self.find_obsolete_links(
files, categories, category_roots, category_transforms,
category_filename_templates, reserved_subfolders))
obsolete_count = len(
self.find_obsolete_links(
files,
categories,
category_roots,
category_transforms,
category_filename_templates,
reserved_subfolders,
)
)
# Remove obsolete links
removed, removed_paths = self.remove_obsolete_links(
files, categories, dry_run, category_roots, category_transforms,
category_filename_templates, reserved_subfolders
removed, _removed_paths = self.remove_obsolete_links(
files,
categories,
dry_run,
category_roots,
category_transforms,
category_filename_templates,
reserved_subfolders,
)
remove_failed = obsolete_count - removed if not dry_run else 0
# Then create new links
created, create_failed = self.create_structure_for_files(
files, categories, dry_run, category_roots, category_transforms,
category_filename_templates
files,
categories,
dry_run,
category_roots,
category_transforms,
category_filename_templates,
)
return created, create_failed, removed, remove_failed
def create_hardlink_structure(
files: List[File],
output_dir: Path,
categories: Optional[List[str]] = None
) -> Tuple[int, int, List[Tuple[Path, str]]]:
files: list[File], output_dir: Path, categories: list[str] | None = None
) -> tuple[int, int, list[tuple[Path, str]]]:
"""
Convenience function to create hardlink structure.
+18 -5
View File
@@ -13,11 +13,11 @@ Detects corrupted or unreadable video files. Two depths:
Both tools ship with FFmpeg; ``ffmpeg_available`` reports whether they are on the
PATH so callers can degrade gracefully.
"""
import shutil
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
FFMPEG = "ffmpeg"
FFPROBE = "ffprobe"
@@ -48,7 +48,7 @@ def ffmpeg_available(deep: bool = True) -> bool:
def check_video_integrity(
path: Path, deep: bool = True, timeout: Optional[int] = None
path: Path, deep: bool = True, timeout: int | None = None
) -> IntegrityResult:
"""Check a single video file for corruption.
@@ -76,13 +76,26 @@ def check_video_integrity(
cmd = [FFMPEG, "-v", "error", "-xerror", "-i", str(path), "-f", "null", "-"]
else:
cmd = [
FFPROBE, "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=codec_type", "-of", "csv=p=0", str(path),
FFPROBE,
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=codec_type",
"-of",
"csv=p=0",
str(path),
]
try:
result = subprocess.run(
cmd, capture_output=True, text=True, errors="replace", timeout=timeout
cmd,
capture_output=True,
text=True,
errors="replace",
timeout=timeout,
check=False,
)
except subprocess.TimeoutExpired:
return IntegrityResult(path, ok=False, error="Kontrola vypršela (timeout)")
+40 -27
View File
@@ -6,17 +6,19 @@ 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.
"""
import contextlib
import os
from dataclasses import dataclass
from typing import Optional
import requests
try: # the values can also come from the real environment
with contextlib.suppress(
Exception
): # the values can also come from the real environment
from dotenv import load_dotenv
load_dotenv()
except Exception: # noqa: BLE001
pass
_TIMEOUT = 30
@@ -27,22 +29,22 @@ class JellyfinMovie:
jellyfin_id: str
title: str
year: Optional[int]
tmdb_id: Optional[int]
path: Optional[str]
year: int | None
tmdb_id: int | None
path: str | None
class JellyfinError(RuntimeError):
"""A Jellyfin request could not be completed (no config, network, status)."""
def base_url() -> Optional[str]:
def base_url() -> str | None:
"""The configured Jellyfin base URL (no trailing slash), or None."""
url = os.getenv("JELLYFIN_URL")
return url.strip().rstrip("/") if url and url.strip() else None
def api_key() -> Optional[str]:
def api_key() -> str | None:
"""The configured Jellyfin API key, or None."""
key = os.getenv("JELLYFIN_API_KEY")
return key.strip() if key and key.strip() else None
@@ -53,15 +55,20 @@ def api_available() -> bool:
return bool(base_url() and api_key())
def _request(method: str, path: str, params: Optional[dict] = None,
timeout: Optional[int] = None) -> requests.Response:
def _request(
method: str, path: str, params: dict | None = None, timeout: int | None = None
) -> requests.Response:
url, key = base_url(), api_key()
if not (url and key):
raise JellyfinError("JELLYFIN_URL / JELLYFIN_API_KEY nejsou nastaveny (.env).")
try:
resp = requests.request(
method, f"{url}{path}", headers={"X-Emby-Token": key},
params=params, timeout=timeout or _TIMEOUT)
method,
f"{url}{path}",
headers={"X-Emby-Token": key},
params=params,
timeout=timeout or _TIMEOUT,
)
except requests.RequestException as exc:
raise JellyfinError(f"Jellyfin nedostupný: {exc}") from exc
if resp.status_code in (401, 403):
@@ -71,7 +78,7 @@ def _request(method: str, path: str, params: Optional[dict] = None,
return resp
def server_version(timeout: Optional[int] = None) -> str:
def server_version(timeout: int | None = None) -> str:
"""Return the Jellyfin server version (a quick connectivity check)."""
data = _request("GET", "/System/Info", timeout=timeout).json()
return str(data.get("Version") or "?")
@@ -84,22 +91,28 @@ def scan_all() -> None:
def fetch_movies() -> list[JellyfinMovie]:
"""Read every movie Jellyfin currently has, with its TMDb id and path."""
data = _request("GET", "/Items", params={
"IncludeItemTypes": "Movie",
"Recursive": "true",
"Fields": "ProviderIds,Path,ProductionYear",
"EnableTotalRecordCount": "false",
}).json()
data = _request(
"GET",
"/Items",
params={
"IncludeItemTypes": "Movie",
"Recursive": "true",
"Fields": "ProviderIds,Path,ProductionYear",
"EnableTotalRecordCount": "false",
},
).json()
movies: list[JellyfinMovie] = []
for item in data.get("Items", []):
provider = item.get("ProviderIds") or {}
raw_tmdb = provider.get("Tmdb") or provider.get("tmdb")
tmdb_id = int(raw_tmdb) if raw_tmdb and str(raw_tmdb).isdigit() else None
movies.append(JellyfinMovie(
jellyfin_id=str(item.get("Id")),
title=item.get("Name") or "",
year=item.get("ProductionYear"),
tmdb_id=tmdb_id,
path=item.get("Path"),
))
movies.append(
JellyfinMovie(
jellyfin_id=str(item.get("Id")),
title=item.get("Name") or "",
year=item.get("ProductionYear"),
tmdb_id=tmdb_id,
path=item.get("Path"),
)
)
return movies
+110
View File
@@ -0,0 +1,110 @@
"""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.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from .pool_index import INDEX_FILENAME
SIDECAR_SUFFIX = ".!tag"
_MAX_INDEX_WALK_UP = 4
@dataclass
class LegacyMeta:
"""What an old sidecar / index record can still tell us about a movie."""
csfd_url: str | None = None
title: str | None = None
year: str | None = None # four-digit string
source: str = "" # "sidecar" | "index"
def _record_to_meta(record: object, source: str) -> LegacyMeta | None:
"""Pull the useful fields out of a stored movie record (any schema version)."""
if not isinstance(record, dict):
return None
csfd = record.get("csfd_url") or record.get("csfd_link") # new | Tagger-era key
title = record.get("title") or None
meta_raw = record.get("meta")
meta = meta_raw if isinstance(meta_raw, dict) else {}
cache_raw = record.get("csfd_cache")
cache = cache_raw if isinstance(cache_raw, dict) else {}
raw_year = meta.get("year") or cache.get("year")
year = (
str(raw_year).strip()
if raw_year is not None and str(raw_year).strip().isdigit()
else None
)
if not (csfd or title or year):
return None
return LegacyMeta(csfd_url=csfd or None, title=title, year=year, source=source)
def _from_sidecar(video_path: Path) -> LegacyMeta | None:
sidecar = video_path.parent / f".{video_path.name}{SIDECAR_SUFFIX}"
if not sidecar.is_file():
return None
try:
record = json.loads(sidecar.read_text(encoding="utf-8"))
except json.JSONDecodeError, OSError, ValueError:
return None
return _record_to_meta(record, "sidecar")
def _lookup_in_index(movies: dict, video_path: Path, index_dir: Path) -> dict | None:
"""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).
"""
try:
rel = video_path.relative_to(index_dir).as_posix()
if rel in movies:
return movies[rel]
except ValueError:
pass
name = video_path.name
matches = [v for k, v in movies.items() if PurePosixPath(k).name == name]
return matches[0] if len(matches) == 1 else None
def _from_legacy_index(video_path: Path) -> LegacyMeta | None:
directory = video_path.parent
for _ in range(_MAX_INDEX_WALK_UP + 1):
index_path = directory / INDEX_FILENAME
if index_path.is_file():
try:
data = json.loads(index_path.read_text(encoding="utf-8"))
except json.JSONDecodeError, OSError, ValueError:
return None
movies = data.get("movies", data) if isinstance(data, dict) else None
if not isinstance(movies, dict):
return None
record = _lookup_in_index(movies, video_path, directory)
return _record_to_meta(record, "index") if record is not None else None
if directory.parent == directory:
break
directory = directory.parent
return None
def read_legacy_meta(video_path: Path) -> LegacyMeta | None:
"""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.
"""
video_path = Path(video_path)
return _from_sidecar(video_path) or _from_legacy_index(video_path)
+4 -3
View File
@@ -1,6 +1,6 @@
from typing import List
from .file import File
class ListManager:
def __init__(self):
# 'name' nebo 'date'
@@ -10,11 +10,12 @@ class ListManager:
if mode in ("name", "date"):
self.sort_mode = mode
def sort_files(self, files: List[File]) -> List[File]:
def sort_files(self, files: list[File]) -> list[File]:
if self.sort_mode == "name":
return sorted(files, key=lambda f: f.filename.lower())
else:
# sort by date (None last) — nejnovější nahoře? Zde dávám None jako ""
def date_key(f):
return (f.date is None, f.date or "")
return sorted(files, key=date_key)
return sorted(files, key=date_key)
+20 -6
View File
@@ -1,5 +1,6 @@
# Module header
import sys
from .file import File
from .tag_manager import TagManager
@@ -8,14 +9,17 @@ if __name__ == "__main__":
# Imports
import subprocess
from PIL import Image, ImageTk
# Functions
def load_icon(path) -> ImageTk.PhotoImage:
img = Image.open(path)
img = img.resize((16, 16), Image.Resampling.LANCZOS)
return ImageTk.PhotoImage(img)
def add_video_resolution_tag(file_obj: File, tagmanager: TagManager):
"""
Zjistí vertikální rozlišení videa a přiřadí tag Rozlišení/{výška}p.
@@ -25,19 +29,29 @@ def add_video_resolution_tag(file_obj: File, tagmanager: TagManager):
try:
# ffprobe vrátí width a height ve formátu JSON
result = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=width,height", "-of", "csv=p=0:s=x", path],
[
"ffprobe",
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height",
"-of",
"csv=p=0:s=x",
path,
],
capture_output=True,
text=True,
check=True
check=True,
)
res = result.stdout.strip() # např. "1920x1080"
if "x" not in res:
return
width, height = map(int, res.split("x"))
_width, height = map(int, res.split("x"))
tag_name = f"Rozlišení/{height}p"
tag_obj = tagmanager.add_tag("Rozlišení", f"{height}p")
file_obj.add_tag(tag_obj)
print(f"Přiřazen tag {tag_name} k {file_obj.filename}")
except Exception as e:
print(f"Chyba při získávání rozlišení videa {file_obj.filename}: {e}")
except (OSError, subprocess.SubprocessError, ValueError) as e:
print(f"Chyba při získávání rozlišení videa {file_obj.filename}: {e}")
+9 -9
View File
@@ -9,24 +9,25 @@ 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.
"""
import re
import unicodedata
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class MatchProposal:
"""One movie's proposed TMDb match."""
file: object # core.file.File
file: object # core.file.File
current_name: str
title: str
year: Optional[str]
status: str # "auto" | "ambiguous" | "no_match" | "error"
chosen: object = None # tmdb.TmdbResult preselected for the user, or None
year: str | None
status: str # "auto" | "ambiguous" | "no_match" | "error"
chosen: object = None # tmdb.TmdbResult preselected for the user, or None
candidates: list = field(default_factory=list) # list[tmdb.TmdbResult]
error: str = ""
note: str = "" # e.g. "přes ČSFD: The Shawshank Redemption (1994)"
def normalize(text: str) -> str:
@@ -37,13 +38,13 @@ def normalize(text: str) -> str:
return re.sub(r"\s+", " ", text).strip()
def _year_close(a: Optional[str], b: Optional[str]) -> bool:
def _year_close(a: str | None, b: str | None) -> bool:
if not (a and b and str(a).isdigit() and str(b).isdigit()):
return False
return abs(int(a) - int(b)) <= 1
def classify(title: str, year: Optional[str], candidates: list) -> tuple[str, object]:
def classify(title: str, year: str | None, 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
@@ -54,8 +55,7 @@ def classify(title: str, year: Optional[str], candidates: list) -> tuple[str, ob
return "no_match", None
nt = normalize(title)
exact_year = [
c for c in candidates
if normalize(c.title) == nt and _year_close(c.year, year)
c for c in candidates if normalize(c.title) == nt and _year_close(c.year, year)
]
if len(exact_year) == 1:
return "auto", exact_year[0]
+286
View File
@@ -0,0 +1,286 @@
"""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 :class:`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 —
:meth:`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 :mod:`src.core.subtitles`).
"""
from __future__ import annotations
import re
import unicodedata
from dataclasses import dataclass, field
from pathlib import Path
from .naming import THREE_D_FORMATS, canonical_pool_stem, is_valid_year
# Roles a media file can have. Only playable video for now — artwork stays
# Jellyfin's job (it writes poster/fanart into the folder itself).
ROLE_VIDEO = "video"
# A grouped movie needs a label on every file (all of Jellyfin's documented
# examples label every version); this is what an unlabelled one gets.
DEFAULT_VERSION_LABEL = "Výchozí"
# "HSBS" / "hsbs" / "3D HSBS" / "3d.hsbs" / "3D_HSBS" → "HSBS"
_LABEL_3D_RE = re.compile(
rf"^(?:3d[\s._-]*)?({'|'.join(THREE_D_FORMATS)})$", re.IGNORECASE
)
def normalize_3d_label(label: object) -> str | None:
"""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.
"""
match = _LABEL_3D_RE.match(str(label or "").strip())
return match.group(1).upper() if match else None
# Jellyfin's multi-part ("stacking") keywords + a number: "part1", "CD 2", "pt-3".
_LABEL_PART_RE = re.compile(
r"^(?:part|pt|cd|dvd|disc|disk)[\s._-]*(\d{1,2})$", re.IGNORECASE
)
def normalize_part_label(label: object) -> int | None:
"""The part number a version label denotes, or ``None``.
A *part* is one piece of a film split across several files (played back
to back as one movie), unlike a *version*, which is an alternative. Any of
Jellyfin's stacking spellings counts — ``part2``, ``CD2``, ``pt 2`` — and
Curator always writes it back as ``-part<N>`` (see :func:`part_label`).
"""
match = _LABEL_PART_RE.match(str(label or "").strip())
return int(match.group(1)) if match else None
def part_label(number: int) -> str:
"""Canonical version label of part ``number`` — ``"part<N>"``."""
return f"part{int(number)}"
def 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 :data:`DEFAULT_VERSION_LABEL`.
"""
label = (label or "").strip()
if grouped and not label:
label = DEFAULT_VERSION_LABEL
if not label:
return f"{stem}{ext}"
fmt = normalize_3d_label(label)
if fmt:
return f"{stem}.3D.{fmt}{ext}"
return f"{stem} - {label}{ext}"
def 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.
"""
id_s = str(tmdb_id).strip() if tmdb_id is not None else ""
if id_s.isdigit():
return f"tmdb-{int(id_s)}"
raw = f"{(title or '').strip()} {str(year or '').strip()}".strip().lower()
# Fold diacritics so "Pelíšky" and "Pelisky" can't become two movies.
raw = "".join(
c for c in unicodedata.normalize("NFD", raw) if not unicodedata.combining(c)
)
slug = re.sub(r"[^0-9a-z]+", "-", raw).strip("-")
return f"name-{slug or 'bez-nazvu'}"
@dataclass
class MediaFile:
"""One file belonging to a movie (today always a playable video)."""
path: Path
label: str = ""
role: str = ROLE_VIDEO
@property
def format_3d(self) -> str | None:
"""The 3D format this version's label denotes, if any."""
return normalize_3d_label(self.label)
def to_record(self, pool_dir: Path) -> dict:
"""Serialise for the pool index (path stored pool-relative, POSIX)."""
try:
rel = Path(self.path).relative_to(pool_dir).as_posix()
except ValueError:
rel = Path(self.path).as_posix()
return {"role": self.role, "label": self.label, "file": rel}
@classmethod
def from_record(cls, data: dict, pool_dir: Path) -> MediaFile:
return cls(
path=pool_dir / str(data.get("file", "")),
label=str(data.get("label", "") or ""),
role=str(data.get("role") or ROLE_VIDEO),
)
@dataclass
class Movie:
"""A film in the pool: its identity, its metadata, and its media files."""
key: str
title: str = ""
year: str | None = None
tmdb_id: int | None = None
library: str = ""
media: list[MediaFile] = field(default_factory=list)
# --- metadata that used to live on the "primary" File ---
tags: list = field(default_factory=list)
auto_tag_paths: set[str] = field(default_factory=set)
csfd_url: str | None = None
date: str | None = None
added: str | None = None
attributes: dict[str, str] = field(default_factory=dict)
meta: dict | None = None
# ------------------------------------------------------------------
# Identity / shape
# ------------------------------------------------------------------
@property
def videos(self) -> list[MediaFile]:
"""Playable versions, in a stable order (labelled ones by label)."""
return sorted(
(m for m in self.media if m.role == ROLE_VIDEO),
key=lambda m: m.label.casefold(),
)
@property
def is_grouped(self) -> bool:
"""True when the movie needs its own folder (more than one version)."""
return len(self.videos) > 1
@property
def stem(self) -> 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).
"""
title = (self.title or "").replace("/", "-").replace("\\", "-").strip()
if not title:
title = self.key
if is_valid_year(self.year or ""):
return canonical_pool_stem(title, self.year, self.tmdb_id)
return title
@property
def primary(self) -> MediaFile | None:
"""The version that represents the movie (first by :attr:`videos`)."""
videos = self.videos
return videos[0] if videos else None
def desired_layout(self, 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.
"""
base = Path(pool_dir) / self.library
grouped = self.is_grouped
folder = base / self.stem if grouped else base
return [
(
m,
folder
/ media_filename(
self.stem, m.label, Path(m.path).suffix, grouped=grouped
),
)
for m in self.videos
]
# ------------------------------------------------------------------
# Index serialisation
# ------------------------------------------------------------------
def to_record(self, pool_dir: Path) -> dict:
data = {
"title": self.title,
"year": self.year,
"tmdb_id": self.tmdb_id,
"library": self.library,
"media": [m.to_record(pool_dir) for m in self.media],
"tags": [getattr(t, "full_path", t) for t in self.tags],
"auto_tags": sorted(self.auto_tag_paths),
"csfd_url": self.csfd_url,
"date": self.date,
"added": self.added,
"attributes": dict(self.attributes),
}
if self.meta is not None:
data["meta"] = dict(self.meta)
return data
@classmethod
def from_record(
cls, key: str, data: dict, pool_dir: Path, tagmanager=None
) -> Movie:
movie = cls(
key=key,
title=str(data.get("title") or ""),
year=(str(data["year"]) if data.get("year") else None),
tmdb_id=(
int(data["tmdb_id"])
if str(data.get("tmdb_id") or "").isdigit()
else None
),
library=str(data.get("library") or ""),
media=[
MediaFile.from_record(m, pool_dir) for m in (data.get("media") or [])
],
auto_tag_paths=set(data.get("auto_tags") or []),
csfd_url=data.get("csfd_url") or None,
date=data.get("date") or None,
added=data.get("added") or None,
attributes=dict(data.get("attributes") or {}),
meta=(dict(data["meta"]) if data.get("meta") else None),
)
if tagmanager is not None:
for full_path in data.get("tags") or []:
if "/" in str(full_path):
category, name = str(full_path).split("/", 1)
movie.tags.append(tagmanager.add_tag(category, name))
return movie
+71 -10
View File
@@ -4,16 +4,34 @@ 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. These
helpers build, validate and parse the stem (the filename without its extension).
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
:data:`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).
"""
import re
# A trailing four-digit year in parentheses, then an optional ``[tmdbid-N]``. The
# title is non-greedy so a title that itself contains parentheses keeps them:
# "Kurz (special) (2019)" → title "Kurz (special)", year 2019.
# Jellyfin's recognised 3D format tags (case-insensitive on read; Curator
# always writes upper-case). hsbs/fsbs = half/full side-by-side, htab/ftab =
# half/full top-and-bottom, mvc = Multiview Video Coding.
THREE_D_FORMATS = ("HSBS", "FSBS", "HTAB", "FTAB", "MVC")
# A trailing four-digit year in parentheses, then an optional ``[tmdbid-N]`` and
# an optional ``.3D.<FORMAT>`` tag. The title is non-greedy so a title that
# itself contains parentheses keeps them: "Kurz (special) (2019)" → title
# "Kurz (special)", year 2019.
_POOL_STEM_RE = re.compile(
r"^(?P<title>.+?) \((?P<year>\d{4})\)(?: \[tmdbid-(?P<id>\d+)\])?$")
r"^(?P<title>.+?) \((?P<year>\d{4})\)(?: \[tmdbid-(?P<id>\d+)\])?"
r"(?:\.3D\.(?P<format3d>hsbs|fsbs|htab|ftab|mvc))?$",
re.IGNORECASE,
)
def is_valid_year(year: object) -> bool:
@@ -21,12 +39,19 @@ def is_valid_year(year: object) -> bool:
return bool(re.fullmatch(r"\d{4}", str(year).strip()))
def canonical_pool_stem(title: str, year: object, tmdb_id: object = None) -> str:
"""Build the canonical pool stem ``Title (YYYY)`` (+ `` [tmdbid-N]`` if given).
def 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, or a given
``tmdb_id`` is not a positive integer.
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 :data:`THREE_D_FORMATS`.
"""
title = (title or "").strip()
year_s = str(year).strip()
@@ -40,6 +65,14 @@ def canonical_pool_stem(title: str, year: object, tmdb_id: object = None) -> str
if not id_s.isdigit():
raise ValueError(f"TMDb ID musí být číslo, ne „{tmdb_id}“.")
stem += f" [tmdbid-{id_s}]"
if format_3d is not None and str(format_3d).strip() != "":
fmt = str(format_3d).strip().upper()
if fmt not in THREE_D_FORMATS:
raise ValueError(
f"3D formát musí být jedno z {', '.join(THREE_D_FORMATS)}, "
f"ne „{format_3d}“."
)
stem += f".3D.{fmt}"
return stem
@@ -63,6 +96,15 @@ def parse_tmdb_id(stem: str) -> int | None:
return int(match.group("id"))
def parse_3d_format(stem: str) -> str | None:
"""Read the ``.3D.<FORMAT>`` tag from a canonical stem (upper-cased), or
``None`` if absent."""
match = _POOL_STEM_RE.match((stem or "").strip())
if not match or not match.group("format3d"):
return None
return match.group("format3d").upper()
def is_canonical_pool_stem(stem: str) -> bool:
"""True when ``stem`` matches the ``Title (YYYY)`` convention."""
return _POOL_STEM_RE.match((stem or "").strip()) is not None
@@ -80,3 +122,22 @@ def year_from_filename(name: str) -> str | None:
return paren.group(1)
loose = re.findall(r"(?<!\d)(19\d{2}|20\d{2})(?!\d)", name)
return loose[-1] if loose else None
# "3D" + one of THREE_D_FORMATS, anywhere in a raw filename, bordered by a
# non-alphanumeric character (or the string's edge) on each side so it can't
# match inside an unrelated word.
_THREE_D_ANYWHERE_RE = re.compile(
rf"(?<![A-Za-z0-9])3d[\s._-]*({'|'.join(THREE_D_FORMATS)})(?![A-Za-z0-9])",
re.IGNORECASE,
)
def 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 :func:`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.
"""
match = _THREE_D_ANYWHERE_RE.search(name or "")
return match.group(1).upper() if match else None
+35 -10
View File
@@ -10,22 +10,23 @@ For a flat pool file ``Title (rok) [tmdbid].mkv`` the sidecar is
Both are found. Missing or unparsable NFO → ``None`` (the movie simply has no
genres until Jellyfin has scanned it).
"""
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
@dataclass
class NfoData:
"""The bits of a movie ``.nfo`` Curator uses."""
title: Optional[str] = None
year: Optional[str] = None # four-digit string
title: str | None = None
year: str | None = None # four-digit string
genres: list[str] = field(default_factory=list)
countries: list[str] = field(default_factory=list)
rating: Optional[int] = None # 0–100 (NFO 0–10 scaled up), or None
tmdb_id: Optional[int] = None
rating: int | None = None # 0–100 (NFO 0–10 scaled up), or None
tmdb_id: int | None = None
csfd_url: str | None = None # ČSFD film URL, if the NFO carries a csfd id
def field_values(self, name: str) -> list[str]:
"""Values for a tag-schema source field (genres/countries/year/rating)."""
@@ -40,7 +41,7 @@ class NfoData:
return []
def find_nfo(video_path: Path) -> Optional[Path]:
def find_nfo(video_path: Path) -> Path | None:
"""Locate the ``.nfo`` for a movie: sibling ``<stem>.nfo`` or ``movie.nfo``."""
video_path = Path(video_path)
sibling = video_path.with_suffix(".nfo")
@@ -56,7 +57,7 @@ def _texts(root: ET.Element, tag: str) -> list[str]:
return [e.text.strip() for e in root.findall(tag) if e.text and e.text.strip()]
def _parse_rating(root: ET.Element) -> Optional[int]:
def _parse_rating(root: ET.Element) -> int | None:
"""Read a 0–10 NFO rating and scale it to 0–100 (to match the old bands)."""
raw = root.findtext("rating")
if raw is None:
@@ -70,7 +71,7 @@ def _parse_rating(root: ET.Element) -> Optional[int]:
return None
def _parse_tmdb_id(root: ET.Element) -> Optional[int]:
def _parse_tmdb_id(root: ET.Element) -> int | None:
for uid in root.findall("uniqueid"):
if uid.get("type") == "tmdb" and uid.text and uid.text.strip().isdigit():
return int(uid.text.strip())
@@ -78,14 +79,37 @@ def _parse_tmdb_id(root: ET.Element) -> Optional[int]:
return int(raw.strip()) if raw and raw.strip().isdigit() else None
def read_nfo(video_path: Path) -> Optional[NfoData]:
def _parse_csfd_url(root: ET.Element) -> str | None:
"""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>/``.
"""
candidates: list[str] = []
for uid in root.findall("uniqueid"):
if uid.get("type") == "csfd" and uid.text and uid.text.strip():
candidates.append(uid.text.strip())
for tag in ("csfdid", "csfd"):
raw = root.findtext(tag)
if raw and raw.strip():
candidates.append(raw.strip())
for value in candidates:
if value.isdigit():
return f"https://www.csfd.cz/film/{value}/"
if "csfd.cz" in value:
return value if value.startswith("http") else f"https://{value.lstrip('/')}"
return None
def read_nfo(video_path: Path) -> NfoData | None:
"""Read the movie's ``.nfo`` into :class:`NfoData`, or None if absent/broken."""
path = find_nfo(video_path)
if path is None:
return None
try:
root = ET.parse(path).getroot()
except (ET.ParseError, OSError):
except ET.ParseError, OSError:
return None
year = root.findtext("year")
@@ -98,4 +122,5 @@ def read_nfo(video_path: Path) -> Optional[NfoData]:
countries=_texts(root, "country"),
rating=_parse_rating(root),
tmdb_id=_parse_tmdb_id(root),
csfd_url=_parse_csfd_url(root),
)
+139 -17
View File
@@ -10,17 +10,27 @@ The index also carries **library-level settings** (``tag_schema``,
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::
On-disk shape (**v2**)::
{"settings": {...}, "movies": {"Filmy/Matrix.mkv": {...}, ...}}
{"version": 2,
"settings": {...},
"movies": {"tmdb-603": {"title": …, "media": [{"role", "label", "file"}, …]}}}
Records are keyed by the file's path relative to the pool root (POSIX form),
e.g. ``"Filmy/Matrix.mkv"`` — stable and portable across machines.
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 :mod:`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; :meth:`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.
"""
import json
import shutil
from pathlib import Path
INDEX_FILENAME = ".Curator.!index"
INDEX_VERSION = 2
class PoolIndex:
@@ -42,7 +52,11 @@ class PoolIndex:
return path.as_posix()
def load(self) -> None:
"""Load the index from disk (missing/corrupt index = empty)."""
"""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.
"""
if not self.index_path.exists():
self.records = {}
self.settings = {}
@@ -52,17 +66,92 @@ class PoolIndex:
data = json.load(f)
self.records = data.get("movies", {})
self.settings = data.get("settings", {})
except (json.JSONDecodeError, OSError):
except json.JSONDecodeError, OSError:
self.records = {}
self.settings = {}
return
if int(data.get("version", 1)) < INDEX_VERSION:
self._migrate_v1_to_v2()
def _migrate_v1_to_v2(self) -> 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.
"""
from .movie import ROLE_VIDEO, identity_key
from .naming import is_canonical_pool_stem, parse_pool_stem, parse_tmdb_id
migrated: dict[str, dict] = {}
for rel_path, record in self.records.items():
if not isinstance(record, dict) or "media" in record:
continue # already v2-shaped, leave it alone
path = Path(rel_path)
parts = path.parts
library = parts[0] if len(parts) > 1 else ""
# A file inside its own "Title (rok) [tmdbid]" folder is one
# version of that movie; its own stem carries the " - label".
parent = path.parent.name
grouped = len(parts) > 2 and is_canonical_pool_stem(parent)
identity_stem = parent if grouped else path.stem
parsed = parse_pool_stem(identity_stem)
title = record.get("title") or (parsed[0] if parsed else path.stem)
year = str(parsed[1]) if parsed else None
tmdb_id = parse_tmdb_id(identity_stem)
label = ""
if grouped and path.stem.startswith(f"{parent} - "):
label = path.stem[len(parent) + 3 :]
elif grouped:
label = path.stem[len(parent) :].lstrip(". -") or ""
key = identity_key(tmdb_id, title, year)
entry = {"role": ROLE_VIDEO, "label": label, "file": rel_path}
if key in migrated:
migrated[key]["media"].append(entry)
continue
migrated[key] = {
"title": title,
"year": year,
"tmdb_id": tmdb_id,
"library": library,
"media": [entry],
"tags": record.get("tags", []),
"auto_tags": record.get("auto_tags", record.get("csfd_tags", [])),
"csfd_url": record.get("csfd_url") or record.get("csfd_link"),
"date": record.get("date"),
"added": record.get("added"),
"attributes": record.get("attributes", {}),
}
if record.get("meta"):
migrated[key]["meta"] = record["meta"]
try:
shutil.copy2(
self.index_path,
self.index_path.with_name(self.index_path.name + ".v1.bak"),
)
except OSError:
pass
self.records = migrated
self.save()
def save(self) -> None:
"""Persist the index to disk."""
self.pool_dir.mkdir(parents=True, exist_ok=True)
with open(self.index_path, "w", encoding="utf-8") as f:
json.dump(
{"settings": self.settings, "movies": self.records},
f, indent=2, ensure_ascii=False,
{
"version": INDEX_VERSION,
"settings": self.settings,
"movies": self.records,
},
f,
indent=2,
ensure_ascii=False,
)
def get_setting(self, key: str, default=None):
@@ -74,16 +163,49 @@ class PoolIndex:
self.settings[key] = value
self.save()
def get(self, file_path: Path) -> dict | None:
"""Return the record for a file, or None if it is not indexed."""
return self.records.get(self._key(file_path))
# ------------------------------------------------------------------
# Movie records (v2 — keyed by identity, own their media files)
# ------------------------------------------------------------------
def set(self, file_path: Path, data: dict) -> None:
"""Upsert a record and persist the index."""
self.records[self._key(file_path)] = data
def get_movie(self, key: str) -> dict | None:
"""Return the movie record under ``key``, or None."""
return self.records.get(key)
def set_movie(self, key: str, data: dict) -> None:
"""Upsert a movie record and persist the index."""
self.records[key] = data
self.save()
def delete(self, file_path: Path) -> None:
"""Remove a record (if present) and persist the index."""
if self.records.pop(self._key(file_path), None) is not None:
def delete_movie(self, key: str) -> None:
"""Remove a movie record (if present) and persist the index."""
if self.records.pop(key, None) is not None:
self.save()
def rekey_movie(self, old_key: str, new_key: str) -> None:
"""Move a record to a new identity key (e.g. its TMDb id was set/fixed)."""
if old_key == new_key or old_key not in self.records:
return
self.records[new_key] = self.records.pop(old_key)
self.save()
def all_movies(self) -> dict[str, dict]:
"""Every movie record, keyed by identity."""
return dict(self.records)
def record_for(self, file_path: Path) -> dict | None:
"""The movie record that owns ``file_path``, or None if untracked."""
found = self.find_movie_by_media(file_path)
return found[1] if found is not None else None
def find_movie_by_media(self, 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.
"""
rel = self._key(file_path)
for key, record in self.records.items():
for media in record.get("media", []):
if media.get("file") == rel:
return key, record
return None
+186 -33
View File
@@ -12,42 +12,82 @@ Episodes are matched by ``SxxEyy``. These helpers parse the episode number out o
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.
"""
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
from .naming import canonical_pool_stem, parse_pool_stem, parse_tmdb_id
from .naming import (
canonical_pool_stem,
is_canonical_pool_stem,
parse_pool_stem,
parse_tmdb_id,
)
# SxxEyy / s1e2 / 1x02 — the episode marker in a filename. First match wins.
_EPISODE_RE = re.compile(r"(?:[Ss](\d{1,2})[\s._-]*[Ee](\d{1,3}))|(?:(\d{1,2})x(\d{1,3}))")
_EPISODE_RE = re.compile(
r"(?:[Ss](\d{1,2})[\s._-]*[Ee](\d{1,3}))|(?:(\d{1,2})x(\d{1,3}))"
)
# Jellyfin's own "stacking" tokens for one episode split across several
# physical files (an old rip cut into CDs, a two-tape recording, …) — as
# opposed to E01-E02, which is *two different* episodes in one file.
_PART_TYPES = r"cd|dvd|part|pt|disc|disk"
# Scanning a real filename: the token must sit at its own boundary right
# before the end of the stem, so a title that happens to contain "cd" or
# "part" as part of a word is never misread as a stacking suffix.
_PART_RE = re.compile(
rf"(?<![A-Za-z0-9])(?:{_PART_TYPES})[\s._-]?(\d{{1,2}})$", re.IGNORECASE
)
# The compact SxxEyy field a user types by hand (e.g. "S01E05P2") may also use
# a bare "P" and no separator at all — safe only because it is matched against
# just the tail *after* the recognised SxxEyy, never against a whole filename.
_PART_RE_LOOSE = re.compile(
rf"[\s._-]?(?:{_PART_TYPES}|p)[\s._-]?(\d{{1,2}})$", re.IGNORECASE
)
# Files next to an episode that are not video (NFO / artwork / subtitles).
_SIDECARS = {
".nfo", ".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif", ".tbn",
".srt", ".ass", ".ssa", ".sub", ".idx", ".vtt",
".nfo",
".jpg",
".jpeg",
".png",
".webp",
".bmp",
".gif",
".tbn",
".srt",
".ass",
".ssa",
".sub",
".idx",
".vtt",
}
@dataclass
class Episode:
path: Path
season: Optional[int]
episode: Optional[int]
season: int | None
episode: int | None
# Which physical part of the episode this file is (Jellyfin stacking,
# e.g. an old recording split into two files) — None for a whole episode.
part: int | None = None
@property
def code(self) -> str:
if self.season is None or self.episode is None:
return "?"
return f"S{self.season:02d}E{self.episode:02d}"
base = f"S{self.season:02d}E{self.episode:02d}"
return f"{base}.pt{self.part}" if self.part else base
@dataclass
class Series:
folder: Path
title: str
year: Optional[str]
tmdb_id: Optional[int]
year: str | None
tmdb_id: int | None
library: str
episodes: list[Episode] = field(default_factory=list)
@@ -55,13 +95,15 @@ class Series:
"""Episodes grouped by season number (``-1`` = unrecognised)."""
grouped: dict[int, list[Episode]] = {}
for ep in self.episodes:
grouped.setdefault(ep.season if ep.season is not None else -1, []).append(ep)
grouped.setdefault(ep.season if ep.season is not None else -1, []).append(
ep
)
for eps in grouped.values():
eps.sort(key=lambda e: (e.episode if e.episode is not None else 1_000_000))
eps.sort(key=lambda e: e.episode if e.episode is not None else 1_000_000)
return dict(sorted(grouped.items()))
def parse_episode(name: str) -> Optional[tuple[int, int]]:
def parse_episode(name: str) -> tuple[int, int] | None:
"""Extract ``(season, episode)`` from a filename, or ``None``."""
match = _EPISODE_RE.search(name)
if not match:
@@ -71,32 +113,115 @@ def parse_episode(name: str) -> Optional[tuple[int, int]]:
return int(match.group(3)), int(match.group(4))
def parse_episode_part(name: str) -> int | None:
"""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.
"""
match = _PART_RE.search(Path(name).stem)
return int(match.group(1)) if match else None
def parse_episode_code(text: str) -> tuple[int, int, int | None] | None:
"""Parse a compact user-typed code: ``SxxEyy``, optionally + a part.
Accepts everything :func:`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.
"""
text = (text or "").strip()
match = _EPISODE_RE.search(text)
if not match:
return None
if match.group(1) is not None:
season, episode = int(match.group(1)), int(match.group(2))
else:
season, episode = int(match.group(3)), int(match.group(4))
part_match = _PART_RE_LOOSE.match(text[match.end() :])
return season, episode, int(part_match.group(1)) if part_match else None
def 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 :func:`parse_episode`.
"""
folder = Path(folder)
if not folder.is_dir():
return []
return sorted(
f
for f in folder.rglob("*")
if f.is_file() and f.suffix.lower() not in _SIDECARS
)
def season_folder_name(season: int) -> str:
"""Folder name for a season — ``Specials`` for 0, else ``Season NN``."""
return "Specials" if season == 0 else f"Season {season:02d}"
def episode_body(stem: str) -> str:
"""The descriptive part of an episode filename, minus a leading ``SxxEyy`` code.
def is_season_folder(name: str) -> bool:
"""True for a season folder name — ``Specials`` or ``Season <n>`` (any padding)."""
return name == "Specials" or re.fullmatch(r"Season \d{1,3}", name) is not None
Keeps the original (release) filename so it survives into the canonical name,
while stripping an already-applied ``SxxEyy - `` prefix so repeated renames
don't double it. Returns ``""`` when the stem was only the code.
def 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: :meth:`FileManager.is_movie_group_member`
and :func:`scan_series` both check it before deciding which one a
canonical-shaped subfolder is.
"""
s = _EPISODE_RE.sub("", stem, count=1) # drop the SxxEyy/NxNN token
s = re.sub(r"([\s._-])[\s._-]+", r"\1", s) # collapse separator runs
return s.strip(" ._-")
folder = Path(folder)
if not folder.is_dir():
return False
return any(p.is_dir() and is_season_folder(p.name) for p in folder.iterdir())
def episode_filename(season: int, episode: int, orig_stem: str, ext: str) -> str:
"""Canonical episode filename ``SxxEyy - {original name}.ext``.
# Characters not allowed in a filename on the common filesystems.
_FS_UNSAFE = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
Keeps the original filename after the code; when the original was only the
code (nothing descriptive left) it collapses to ``SxxEyy.ext``.
def sanitize_title(title: str) -> str:
"""A series/episode title made safe to sit in a filename (spaces collapsed)."""
cleaned = _FS_UNSAFE.sub("", title or "")
return re.sub(r"\s+", " ", cleaned).strip(" .")
def episode_filename(
season: int,
episode: int,
series_title: str,
ext: str,
*,
part: int | None = 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.
"""
code = f"S{season:02d}E{episode:02d}"
body = episode_body(orig_stem)
return f"{code} - {body}{ext}" if body else f"{code}{ext}"
body = sanitize_title(series_title)
name = f"{code} - {body}" if body else code
if part:
name += f".pt{part}"
return f"{name}{ext}"
def series_folder_stem(title: str, year: object = None, tmdb_id: object = None) -> str:
@@ -115,30 +240,52 @@ def series_folder_stem(title: str, year: object = None, tmdb_id: object = None)
return f"{title} [tmdbid-{id_s}]" if id_s.isdigit() else title
def is_canonical_episode(path: Path) -> bool:
"""True when ``path`` already follows ``SxxEyy - name`` in the right season."""
def is_canonical_episode(path: Path, series_title: str) -> bool:
"""True when ``path`` is already ``SxxEyy - {series title}.ext`` in its season."""
parsed = parse_episode(path.name)
if parsed is None:
return False
season, episode = parsed
expected = episode_filename(season, episode, path.stem, path.suffix)
part = parse_episode_part(path.name)
expected = episode_filename(season, episode, series_title, path.suffix, part=part)
if path.name != expected:
return False
return path.parent.name == season_folder_name(season)
def scan_series(lib_dir: Path, library: str) -> list[Series]:
def scan_series(
lib_dir: Path,
library: str,
*,
skip_movie_folders: bool = False,
) -> list[Series]:
"""Scan one series library into a list of :class:`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 :meth:`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.
"""
lib_dir = Path(lib_dir)
if not lib_dir.is_dir():
return []
series_list: list[Series] = []
for folder in sorted(p for p in lib_dir.iterdir() if p.is_dir()):
if (
skip_movie_folders
and is_canonical_pool_stem(folder.name)
and not looks_like_series_folder(folder)
):
continue
parsed = parse_pool_stem(folder.name)
if parsed is not None:
title, year = parsed[0], str(parsed[1])
@@ -151,7 +298,13 @@ def scan_series(lib_dir: Path, library: str) -> list[Series]:
if not f.is_file() or f.suffix.lower() in _SIDECARS:
continue
code = parse_episode(f.name)
episodes.append(Episode(
f, code[0] if code else None, code[1] if code else None))
episodes.append(
Episode(
f,
code[0] if code else None,
code[1] if code else None,
parse_episode_part(f.name),
)
)
series_list.append(Series(folder, title, year, tmdb_id, library, episodes))
return series_list
+157
View File
@@ -0,0 +1,157 @@
"""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 (:func:`attach_subtitle`), and moving a subtitle along whenever its
video gets renamed so it never silently detaches (:func:`rename_subtitles_with`).
"""
from __future__ import annotations
import re
import shutil
from dataclasses import dataclass
from pathlib import Path
SUBTITLE_EXTENSIONS = {".srt", ".ass", ".ssa", ".sub", ".vtt"}
_FLAGS = {"forced", "sdh", "default"}
_LANG_RE = re.compile(r"[a-z]{2,3}")
@dataclass
class SubtitleFile:
"""One subtitle sidecar found next to a video."""
path: Path
language: str | None = None # ISO 639-1/2 code, e.g. "cs"
flag: str | None = None # "forced" | "sdh" | "default"
@property
def label(self) -> str:
"""Human-readable ``jazyk (flag)`` for display, e.g. ``cs (forced)``."""
text = self.language or "?"
return f"{text} ({self.flag})" if self.flag else text
def subtitle_filename(
video_stem: str,
ext: str,
language: str | None = None,
flag: str | None = None,
) -> str:
"""Canonical subtitle filename: ``{video_stem}[.language][.flag]{ext}``."""
parts = [video_stem]
if language:
parts.append(language.strip().lower())
if flag:
parts.append(flag.strip().lower())
return ".".join(parts) + ext.lower()
def _parse_subtitle_name(
video_stem: str, filename: str
) -> tuple[str | None, str | None] | None:
"""``(language, flag)`` if ``filename`` is a subtitle sidecar of ``video_stem``."""
candidate = Path(filename)
if candidate.suffix.lower() not in SUBTITLE_EXTENSIONS:
return None
stem = candidate.stem
if stem == video_stem:
return None, None
if not stem.startswith(f"{video_stem}."):
return None
tokens = [t for t in stem[len(video_stem) + 1 :].split(".") if t]
if not tokens:
return None, None
language: str | None = None
flag: str | None = None
for token in tokens:
low = token.lower()
if low in _FLAGS:
flag = low
elif language is None and _LANG_RE.fullmatch(low):
language = low
return language, flag
def find_subtitles(video_path: Path) -> list[SubtitleFile]:
"""Every external subtitle sidecar sitting next to ``video_path``, sorted."""
video_path = Path(video_path)
folder = video_path.parent
if not folder.is_dir():
return []
found: list[SubtitleFile] = []
for f in sorted(folder.iterdir()):
if not f.is_file():
continue
parsed = _parse_subtitle_name(video_path.stem, f.name)
if parsed is None:
continue
language, flag = parsed
found.append(SubtitleFile(f, language, flag))
return found
def attach_subtitle(
video_path: Path,
source: Path,
*,
language: str | None = None,
flag: str | None = 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.
"""
video_path = Path(video_path)
source = Path(source)
ext = source.suffix.lower()
if ext not in SUBTITLE_EXTENSIONS:
raise ValueError(f"Nepodporovaný formát titulků: {source.suffix}")
target = video_path.parent / subtitle_filename(video_path.stem, ext, language, flag)
if target.exists():
target.unlink()
if move:
shutil.move(str(source), str(target))
else:
shutil.copy2(source, target)
return target
def 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.
"""
old_video_path = Path(old_video_path)
new_video_path = Path(new_video_path)
if old_video_path == new_video_path:
return []
moved: list[Path] = []
for sub in find_subtitles(old_video_path):
target = new_video_path.parent / subtitle_filename(
new_video_path.stem, sub.path.suffix, sub.language, sub.flag
)
if sub.path == target:
continue
target.parent.mkdir(parents=True, exist_ok=True)
if target.exists():
target.unlink()
sub.path.rename(target)
moved.append(target)
return moved
+1 -1
View File
@@ -19,4 +19,4 @@ class Tag:
return False
def __hash__(self):
return hash((self.category, self.name))
return hash((self.category, self.name))
+4 -2
View File
@@ -47,7 +47,9 @@ class TagManager:
def get_all_tags(self):
"""Vrací list všech tagů full_path"""
return [tag.full_path for tags in self.tags_by_category.values() for tag in tags]
return [
tag.full_path for tags in self.tags_by_category.values() for tag in tags
]
def get_categories(self):
return list(self.tags_by_category.keys())
@@ -64,4 +66,4 @@ class TagManager:
# Sort alphabetically for custom categories
tags.sort(key=lambda t: t.name)
return tags
return tags
+75 -20
View File
@@ -8,17 +8,19 @@ Needs a free API key in ``TMDB_API_KEY`` (loaded from ``.env``). Everything
degrades gracefully when the key or the network is missing: :func:`api_available`
reports readiness and the lookups raise :class:`TmdbError` with a clear message.
"""
import contextlib
import os
from dataclasses import dataclass
from typing import Optional
import requests
try: # optional — the key can also come from the real environment
# optional — the key can also come from the real environment; dotenv is a
# convenience, never required
with contextlib.suppress(Exception):
from dotenv import load_dotenv
load_dotenv()
except Exception: # noqa: BLE001 — dotenv is a convenience, never required
pass
_BASE = "https://api.themoviedb.org/3"
_LANG = os.getenv("TMDB_LANGUAGE", "cs-CZ")
@@ -31,15 +33,42 @@ class TmdbResult:
tmdb_id: int
title: str
year: Optional[str] # four-digit string, or None when TMDb has no date
year: str | None # four-digit string, or None when TMDb has no date
kind: str # "movie" | "tv"
@dataclass
class TmdbDetails:
"""Full TMDb data used to (re)generate a movie's tags (genres etc.).
Exposes :meth:`field_values` so it plugs into the tag schema the same way the
NFO reader does (fields: genres / countries / year / rating).
"""
tmdb_id: int
title: str
year: str | None
genres: list[str]
countries: list[str]
rating: int | None # 0–100 (TMDb vote_average 0–10 scaled up)
def field_values(self, name: str) -> list[str]:
if name == "genres":
return list(self.genres)
if name == "countries":
return list(self.countries)
if name == "year":
return [self.year] if self.year else []
if name == "rating":
return [str(self.rating)] if self.rating is not None else []
return []
class TmdbError(RuntimeError):
"""A TMDb request could not be completed (no key, network, bad status)."""
def api_key() -> Optional[str]:
def api_key() -> str | None:
"""The configured TMDb API key, or None."""
key = os.getenv("TMDB_API_KEY")
return key.strip() if key and key.strip() else None
@@ -68,37 +97,61 @@ def _get(path: str, params: dict) -> dict:
return resp.json()
def _year_of(date_str: Optional[str]) -> Optional[str]:
def _year_of(date_str: str | None) -> str | None:
if date_str and len(date_str) >= 4 and date_str[:4].isdigit():
return date_str[:4]
return None
def lookup_movie(tmdb_id: int) -> Optional[TmdbResult]:
def lookup_movie(tmdb_id: int) -> TmdbResult | None:
"""Resolve a movie by its TMDb id → title + year, or None if it has no title."""
data = _get(f"/movie/{int(tmdb_id)}", {})
title = data.get("title") or data.get("original_title")
if not title:
return None
return TmdbResult(int(data["id"]), title, _year_of(data.get("release_date")), "movie")
return TmdbResult(
int(data["id"]), title, _year_of(data.get("release_date")), "movie"
)
def lookup_tv(tmdb_id: int) -> Optional[TmdbResult]:
def movie_details(tmdb_id: int) -> TmdbDetails:
"""Full movie data from TMDb (genres/countries/rating/year), localized to cs."""
data = _get(f"/movie/{int(tmdb_id)}", {})
title = data.get("title") or data.get("original_title") or ""
genres = [g["name"] for g in (data.get("genres") or []) if g.get("name")]
countries = [
c["name"] for c in (data.get("production_countries") or []) if c.get("name")
]
vote = data.get("vote_average")
rating = round(float(vote) * 10) if vote else None
return TmdbDetails(
tmdb_id=int(data["id"]),
title=title,
year=_year_of(data.get("release_date")),
genres=genres,
countries=countries,
rating=rating,
)
def lookup_tv(tmdb_id: int) -> TmdbResult | None:
"""Resolve a series by its TMDb id → name + first-air year, or None."""
data = _get(f"/tv/{int(tmdb_id)}", {})
title = data.get("name") or data.get("original_name")
if not title:
return None
return TmdbResult(int(data["id"]), title, _year_of(data.get("first_air_date")), "tv")
return TmdbResult(
int(data["id"]), title, _year_of(data.get("first_air_date")), "tv"
)
def lookup(tmdb_id: int, kind: str = "movie") -> Optional[TmdbResult]:
def lookup(tmdb_id: int, kind: str = "movie") -> TmdbResult | None:
"""Resolve a TMDb id as a movie (default) or a series (``kind="tv"``)."""
return lookup_tv(tmdb_id) if kind == "tv" else lookup_movie(tmdb_id)
def search_movie(
query: str, year: Optional[str] = None, limit: int = 5
query: str, year: str | None = None, limit: int = 5
) -> list[TmdbResult]:
"""Search movies by title (optionally narrowed by year); best matches first."""
if not query.strip():
@@ -112,14 +165,14 @@ def search_movie(
title = item.get("title") or item.get("original_title")
if title and item.get("id"):
results.append(
TmdbResult(int(item["id"]), title,
_year_of(item.get("release_date")), "movie"))
TmdbResult(
int(item["id"]), title, _year_of(item.get("release_date")), "movie"
)
)
return results
def search_tv(
query: str, year: Optional[str] = None, limit: int = 5
) -> list[TmdbResult]:
def search_tv(query: str, year: str | None = None, limit: int = 5) -> list[TmdbResult]:
"""Search series by name (optionally narrowed by first-air year)."""
if not query.strip():
return []
@@ -132,6 +185,8 @@ def search_tv(
title = item.get("name") or item.get("original_name")
if title and item.get("id"):
results.append(
TmdbResult(int(item["id"]), title,
_year_of(item.get("first_air_date")), "tv"))
TmdbResult(
int(item["id"]), title, _year_of(item.get("first_air_date")), "tv"
)
)
return results
+3 -3
View File
@@ -4,8 +4,8 @@ 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``.
"""
import re
from typing import Optional
def rating_band(rating: int) -> str:
@@ -32,10 +32,10 @@ TAG_TRANSFORMS = {
}
def apply_transform(value: str, transform: Optional[str]) -> str:
def apply_transform(value: str, transform: str | None) -> str:
"""Apply a named tag-schema transform to a value, returning a string."""
fn = TAG_TRANSFORMS.get(transform, str)
try:
return str(fn(value))
except (ValueError, TypeError, AttributeError):
except ValueError, TypeError, AttributeError:
return str(value)
+2 -1
View File
@@ -1,7 +1,8 @@
from pathlib import Path
def list_files(folder_path: str | Path) -> list[Path]:
folder = Path(folder_path)
if not folder.is_dir():
raise NotADirectoryError(f"{folder} není platná složka.")
return [file_path for file_path in folder.rglob("*") if file_path.is_file()]
return [file_path for file_path in folder.rglob("*") if file_path.is_file()]
+2403 -375
View File
File diff suppressed because it is too large Load Diff
+6 -5
View File
@@ -1,11 +1,14 @@
"""
Konfigurace pytest - sdílené fixtures a nastavení pro všechny testy
"""
import pytest
import tempfile
import contextlib
import shutil
import tempfile
from pathlib import Path
import pytest
@pytest.fixture(scope="session")
def session_temp_dir():
@@ -22,7 +25,5 @@ def cleanup_config_files():
# Cleanup po testu
config_file = Path("config.json")
if config_file.exists():
try:
with contextlib.suppress(Exception):
config_file.unlink()
except Exception:
pass
+16 -11
View File
@@ -1,8 +1,13 @@
import pytest
import json
import pytest
from src.core.config import (
load_global_config, save_global_config, DEFAULT_GLOBAL_CONFIG,
load_config, save_config # Legacy functions
DEFAULT_GLOBAL_CONFIG,
load_config, # Legacy functions
load_global_config,
save_config,
save_global_config,
)
@@ -14,7 +19,8 @@ class TestGlobalConfig:
"""Fixture pro dočasný globální config soubor"""
config_path = tmp_path / "config.json"
import src.core.config as config_module
monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path)
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", config_path)
return config_path
def test_default_global_config_structure(self):
@@ -136,7 +142,8 @@ class TestLegacyFunctions:
"""Fixture pro dočasný globální config soubor"""
config_path = tmp_path / "config.json"
import src.core.config as config_module
monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path)
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", config_path)
return config_path
def test_load_config_legacy(self, temp_global_config):
@@ -166,14 +173,15 @@ class TestConfigEdgeCases:
"""Fixture pro dočasný globální config soubor"""
config_path = tmp_path / "config.json"
import src.core.config as config_module
monkeypatch.setattr(config_module, 'GLOBAL_CONFIG_FILE', config_path)
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", config_path)
return config_path
def test_config_path_with_spaces(self, temp_global_config):
"""Test s cestou obsahující mezery"""
test_config = {
**DEFAULT_GLOBAL_CONFIG,
"last_folder": "/path/with spaces/in name"
"last_folder": "/path/with spaces/in name",
}
save_global_config(test_config)
@@ -215,10 +223,7 @@ class TestConfigEdgeCases:
def test_config_ensure_ascii_false(self, temp_global_config):
"""Test že ensure_ascii=False funguje správně"""
test_config = {
**DEFAULT_GLOBAL_CONFIG,
"last_folder": "/cesta/čeština"
}
test_config = {**DEFAULT_GLOBAL_CONFIG, "last_folder": "/cesta/čeština"}
save_global_config(test_config)
+11 -6
View File
@@ -15,7 +15,6 @@ from src.constants import (
get_version,
)
# ---------------------------------------------------------------------------
# get_version()
# ---------------------------------------------------------------------------
@@ -42,15 +41,19 @@ def test_get_version_fallback_when_toml_missing(tmp_path: Path) -> None:
assert len(result) > 0
def test_get_version_unknown_fallback(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
def test_get_version_unknown_fallback(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""get_version() returns '0.0.0-unknown' when all sources are unavailable."""
missing = tmp_path / "nonexistent.toml"
monkeypatch.setattr("src.constants._PYPROJECT_PATH", missing)
# Patch _version import to also fail
with patch("src.constants.Path.write_text", side_effect=OSError):
with patch.dict("sys.modules", {"src._version": None}):
result = get_version()
with (
patch("src.constants.Path.write_text", side_effect=OSError),
patch.dict("sys.modules", {"src._version": None}),
):
result = get_version()
assert isinstance(result, str)
@@ -131,7 +134,9 @@ def test_app_title_dev_suffix_when_debug(monkeypatch: pytest.MonkeyPatch) -> Non
assert title.endswith("-DEV")
def test_app_title_no_dev_suffix_when_not_debug(monkeypatch: pytest.MonkeyPatch) -> None:
def test_app_title_no_dev_suffix_when_not_debug(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""APP_TITLE does not end with '-DEV' when ENV_DEBUG is False."""
import src.constants as consts
+279
View File
@@ -0,0 +1,279 @@
"""Tests for the lean ČSFD title-bridge scraper (`src.core.csfd`)."""
import hashlib
from unittest.mock import patch
import pytest
from src.core import csfd
from src.core.csfd import (
CsfdError,
CsfdFilm,
_extract_csfd_id,
_is_film_href,
_parse_origin,
_parse_original_title,
_solve_anubis_pow,
clean_filename_to_query,
fetch_film,
find_csfd_url,
search_films,
)
# A trimmed ČSFD film page: Czech title in JSON-LD, USA origin, and a
# ``.film-names`` list whose USA-flagged entry is the original title.
FILM_HTML = """
<html><head>
<script type="application/ld+json">
{"@context":"https://schema.org/","@type":"Movie","name":"Vykoupení z věznice Shawshank",
"dateCreated":"1994","duration":"PT142M"}
</script>
</head><body>
<div class="film-header-name">
<h1>Vykoupení z věznice Shawshank</h1>
<ul class="film-names">
<li><img src="//x/us.svg" class="flag" title="USA" alt="USA"/> The Shawshank Redemption
<span class="normal more-name-link"><a class="more">více</a></span></li>
<li class="more-names hidden"><img src="//x/sk.svg" class="flag" title="Slovensko"/>
Vykúpenie z väznice Shawshank</li>
</ul>
</div>
<div class="origin">USA <span class="bullet"></span>
<span>1994 <span class="bullet"></span> </span>142 min</div>
</body></html>
"""
# A Czech film: ČSFD omits the Czech title from ``.film-names`` (it equals the
# <h1>) and lists only a foreign festival title — the original title must fall
# back to the <h1>, not "Cosy Dens".
CZECH_FILM_HTML = """
<html><head>
<script type="application/ld+json">
{"@type":"Movie","name":"Pelíšky","datePublished":"1999-04-01"}
</script>
</head><body>
<div class="film-header-name"><h1>Pelíšky</h1>
<ul class="film-names">
<li><img class="flag" title="angličtina"/> Cosy Dens</li>
<li><img class="flag" title="Slovensko"/> Pelíšky</li>
</ul></div>
<div class="origin">Česko <span class="bullet"></span><span>1999</span></div>
</body></html>
"""
# The real search page groups hits into <section> blocks; a.film-title-name also
# matches series episodes, so search_films must scope to "Filmy" and drop the
# two-segment episode hrefs.
SEARCH_HTML = """
<html><body>
<section><div class="box-header"><h2>Filmy</h2></div>
<a href="/film/2294-vykoupeni-z-veznice-shawshank/prehled/" class="film-title-name">Vykoupení z věznice Shawshank</a>
<a href="/film/248803-film-o-filmu/prehled/" class="film-title-name">Film o filmu</a>
</section>
<section><div class="box-header"><h2>Seriály</h2></div>
<a href="/film/318290-arrow/620181-x/prehled/" class="film-title-name">Arrow - Vykoupení</a>
</section>
</body></html>
"""
class TestCleanFilenameToQuery:
def test_strips_release_tags_and_keeps_year(self):
assert (
clean_filename_to_query("Matrix.1999.1080p.BluRay.x264-GROUP.mkv")
== "Matrix 1999"
)
def test_handles_spaces_and_parens_year(self):
assert (
clean_filename_to_query("Forrest Gump (1994) 2160p HDR.mkv")
== "Forrest Gump 1994"
)
def test_no_year_no_markers(self):
assert clean_filename_to_query("Amelie.mkv") == "Amelie"
def test_underscores_and_resolution(self):
assert clean_filename_to_query("Sam_doma_720p.mkv") == "Sam doma"
def test_falls_back_to_stem_when_starting_with_marker(self):
assert clean_filename_to_query("1080p.mkv") == "1080p"
class TestExtractCsfdId:
def test_extract_from_various_urls(self):
assert (
_extract_csfd_id("https://www.csfd.cz/film/2294-vykoupeni/prehled/") == 2294
)
assert _extract_csfd_id("/film/999/") == 999
def test_none_when_absent(self):
assert _extract_csfd_id("https://www.csfd.cz/") is None
class TestAnubisPoW:
def test_solve_difficulty_one_gives_one_leading_zero_nibble(self):
hash_hex, nonce, _ = _solve_anubis_pow("abc123", difficulty=1)
assert hash_hex[0] == "0"
assert hashlib.sha256(f"abc123{nonce}".encode()).hexdigest() == hash_hex
def test_solve_difficulty_two_gives_leading_zero_byte(self):
hash_hex, _, _ = _solve_anubis_pow("seed", difficulty=2)
assert hash_hex[:2] == "00"
class TestParseOrigin:
def test_country_and_year_from_bullet_format(self):
from bs4 import BeautifulSoup
soup = BeautifulSoup(FILM_HTML, "html.parser")
assert _parse_origin(soup) == ("USA", "1994")
def test_first_country_of_coproduction(self):
from bs4 import BeautifulSoup
html = (
'<div class="origin">Francie / Německo <span class="bullet"></span>'
"<span>2001</span> 120 min</div>"
)
soup = BeautifulSoup(html, "html.parser")
assert _parse_origin(soup) == ("Francie", "2001")
class TestParseOriginalTitle:
def test_picks_name_matching_origin_country(self):
from bs4 import BeautifulSoup
soup = BeautifulSoup(FILM_HTML, "html.parser")
assert _parse_original_title(soup, "USA") == "The Shawshank Redemption"
def test_none_without_country_match_so_caller_uses_czech_title(self):
# A Czech film lists only foreign/festival alt-titles here — none match
# the origin country, so we must NOT return one of them.
from bs4 import BeautifulSoup
html = (
'<ul class="film-names">'
'<li><img class="flag" title="angličtina"/> Loners</li>'
'<li><img class="flag" title="Slovinsko"/> Samotarji</li></ul>'
)
soup = BeautifulSoup(html, "html.parser")
assert _parse_original_title(soup, "Česko") is None
def test_cesko_matches_ceskoslovensko_flag(self):
from bs4 import BeautifulSoup
html = (
'<ul class="film-names">'
'<li><img class="flag" title="Československo"/> Ostře sledované vlaky</li>'
"</ul>"
)
soup = BeautifulSoup(html, "html.parser")
assert _parse_original_title(soup, "Česko") == "Ostře sledované vlaky"
def test_none_when_no_names(self):
from bs4 import BeautifulSoup
soup = BeautifulSoup("<div>x</div>", "html.parser")
assert _parse_original_title(soup, "USA") is None
class TestFetchFilm:
def test_parses_czech_and_original_title_and_year(self):
class _Resp:
text = FILM_HTML
with patch("src.core.csfd._get_page", return_value=_Resp()):
film = fetch_film("https://www.csfd.cz/film/2294-x/")
assert film.czech_title == "Vykoupení z věznice Shawshank"
assert film.original_title == "The Shawshank Redemption"
assert film.year == "1994"
assert film.csfd_id == 2294
assert film.tmdb_query == ("The Shawshank Redemption", "1994")
def test_czech_film_original_title_falls_back_to_czech(self):
class _Resp:
text = CZECH_FILM_HTML
with patch("src.core.csfd._get_page", return_value=_Resp()):
film = fetch_film("https://www.csfd.cz/film/15387-pelisky/")
assert film.czech_title == "Pelíšky"
assert film.original_title == "Pelíšky"
assert film.year == "1999"
assert film.tmdb_query == ("Pelíšky", "1999")
def test_wraps_network_error_in_csfd_error(self):
import requests
with (
patch(
"src.core.csfd._get_page", side_effect=requests.RequestException("boom")
),
pytest.raises(CsfdError),
):
fetch_film("https://www.csfd.cz/film/1/")
class TestIsFilmHref:
def test_accepts_single_segment_film(self):
assert _is_film_href("/film/2294-vykoupeni/prehled/") is True
assert _is_film_href("/film/2294-vykoupeni/") is True
def test_rejects_series_episode(self):
assert _is_film_href("/film/318290-arrow/620181-vykoupeni/prehled/") is False
def test_rejects_non_film(self):
assert _is_film_href("/tvurce/123-nekdo/") is False
class TestSearchFilms:
def test_scopes_to_films_section_and_drops_episodes(self):
class _Resp:
text = SEARCH_HTML
with patch("src.core.csfd._get_page", return_value=_Resp()):
films = search_films("shawshank", limit=5)
# the Seriály-section episode link is excluded
assert [f.csfd_id for f in films] == [2294, 248803]
assert films[0].url == (
"https://www.csfd.cz/film/2294-vykoupeni-z-veznice-shawshank/prehled/"
)
def test_empty_query_returns_empty(self):
assert search_films(" ") == []
class TestFindCsfdUrl:
def test_returns_first_hit_url(self):
hit = CsfdFilm(url="https://www.csfd.cz/film/2294-x/", csfd_id=2294)
with patch("src.core.csfd.search_films", return_value=[hit]):
assert find_csfd_url("shawshank 1994") == "https://www.csfd.cz/film/2294-x/"
def test_retries_without_trailing_year_when_first_query_misses(self):
hit = CsfdFilm(
url="https://www.csfd.cz/film/221006-vratne-lahve/", csfd_id=221006
)
calls: list[str] = []
def fake_search(query, limit=1, session=None):
calls.append(query)
return [] if query.endswith("2006") else [hit]
with patch("src.core.csfd.search_films", side_effect=fake_search):
assert find_csfd_url("Vratne lahve 2006") == hit.url
assert calls == ["Vratne lahve 2006", "Vratne lahve"]
def test_none_for_blank_query(self):
assert find_csfd_url(" ") is None
def test_none_when_no_results(self):
with patch("src.core.csfd.search_films", return_value=[]):
assert find_csfd_url("nic") is None
class TestDependencies:
def test_dependencies_available_in_test_env(self):
assert csfd.dependencies_available() is True
+40 -19
View File
@@ -1,4 +1,5 @@
import pytest
from src.core.file import File
from src.core.pool_index import PoolIndex
from src.core.tag import Tag
@@ -37,7 +38,7 @@ class TestFile:
def test_file_metadata_saved_to_index(self, test_file, tag_manager, index):
"""Metadata jsou zapsána do indexu už při vytvoření."""
file_obj = File(test_file, tag_manager, index=index)
assert index.get(file_obj.file_path) is not None
assert index.record_for(file_obj.file_path) is not None
def test_file_no_sidecar_written(self, test_file, tag_manager, index):
"""Žádný .!tag sidecar už nevzniká."""
@@ -57,7 +58,7 @@ class TestFile:
file_obj.ignored = True
file_obj.save_metadata()
data = index.get(file_obj.file_path)
data = index.record_for(file_obj.file_path)
assert data["new"] is False
assert data["ignored"] is True
@@ -78,7 +79,7 @@ class TestFile:
file_obj = File(test_file, tag_manager, index=index)
file_obj.set_date("2025-12-25")
assert file_obj.date == "2025-12-25"
assert index.get(file_obj.file_path)["date"] == "2025-12-25"
assert index.record_for(file_obj.file_path)["date"] == "2025-12-25"
def test_file_set_date_to_none(self, test_file, tag_manager, index):
file_obj = File(test_file, tag_manager, index=index)
@@ -130,7 +131,9 @@ class TestFile:
file_obj.remove_tag("Video/HD")
assert "Video/HD" not in {t.full_path for t in file_obj.tags}
def test_file_remove_tag_string_without_category(self, test_file, tag_manager, index):
def test_file_remove_tag_string_without_category(
self, test_file, tag_manager, index
):
file_obj = File(test_file, tag_manager, index=index)
file_obj.add_tag("SimpleTag")
file_obj.remove_tag("SimpleTag")
@@ -169,7 +172,7 @@ class TestFile:
file_obj.add_tag("Test/Tag")
file_obj.set_date("2025-06-15")
data = index.get(file_obj.file_path)
data = index.record_for(file_obj.file_path)
assert "new" in data
assert "ignored" in data
assert "tags" in data
@@ -227,8 +230,13 @@ class TestApplyNfoTags:
def test_apply_nfo_assigns_expected_categories(self, movie_file):
from src.core.nfo import NfoData
nfo = NfoData(title="Matrix", year="1999", genres=["Akční", "Sci-Fi"],
countries=["USA"], rating=90)
nfo = NfoData(
title="Matrix",
year="1999",
genres=["Akční", "Sci-Fi"],
countries=["USA"],
rating=90,
)
result = movie_file.apply_nfo(nfo)
assert result["success"]
@@ -263,12 +271,16 @@ class TestApplyNfoTags:
def test_added_timestamp_uses_stored_added(self, movie_file):
movie_file.added = "2026-06-16T12:00:00"
from datetime import datetime
assert movie_file.added_timestamp() == datetime.fromisoformat(
"2026-06-16T12:00:00").timestamp()
assert (
movie_file.added_timestamp()
== datetime.fromisoformat("2026-06-16T12:00:00").timestamp()
)
def test_added_timestamp_falls_back_to_mtime(self, movie_file):
movie_file.added = None
import os
expected = os.stat(movie_file.file_path).st_mtime
assert movie_file.added_timestamp() == expected
@@ -277,8 +289,9 @@ class TestApplyNfoTags:
assert movie_file.attributes["collection_sort"] == "03"
assert movie_file.name_context()["collection_sort"] == "03"
# reload (from index) keeps it
reloaded = File(movie_file.file_path, movie_file.tagmanager,
index=movie_file.index)
reloaded = File(
movie_file.file_path, movie_file.tagmanager, index=movie_file.index
)
assert reloaded.attributes["collection_sort"] == "03"
def test_set_attribute_empty_removes_it(self, movie_file):
@@ -290,15 +303,23 @@ class TestApplyNfoTags:
movie_file.title = "Dr. No"
movie_file.set_attribute("collection_sort", "01")
ctx = movie_file.name_context()
assert "{collection_sort} - {title}{ext}".format(**ctx) == \
f"01 - Dr. No{ctx['ext']}"
assert (
"{collection_sort} - {title}{ext}".format(**ctx)
== f"01 - Dr. No{ctx['ext']}"
)
def test_apply_nfo_honors_custom_schema(self, movie_file):
"""A schema without the rating entry produces no Hodnocení tags."""
from src.core.nfo import NfoData
schema = [{"category": "Žánr", "csfd_field": "genres",
"transform": None, "filmoteka_root": ""}]
schema = [
{
"category": "Žánr",
"csfd_field": "genres",
"transform": None,
"filmoteka_root": "",
}
]
movie_file.apply_nfo(NfoData(genres=["Drama"], rating=90), schema)
paths = {t.full_path for t in movie_file.tags}
@@ -314,9 +335,9 @@ class TestApplyNfoTags:
movie_file.apply_nfo(NfoData(year="2009", genres=["Drama"]))
paths = {t.full_path for t in movie_file.tags}
assert "Sbírka/Oblíbené" in paths # user tag kept
assert "Žánr/Drama" in paths # new NFO tag
assert "Sbírka/Oblíbené" in paths # user tag kept
assert "Žánr/Drama" in paths # new NFO tag
assert "Rok/2009" in paths
assert "Žánr/Akční" not in paths # old NFO tag regenerated away
assert "Žánr/Akční" not in paths # stale ČSFD tag dropped
assert "Žánr/Akční" not in paths # old NFO tag regenerated away
assert "Žánr/Akční" not in paths # stale ČSFD tag dropped
assert "Rok/1999" not in paths
+1872 -62
View File
File diff suppressed because it is too large Load Diff
+78 -33
View File
@@ -1,7 +1,9 @@
import pytest
import os
from src.core.hardlink_manager import HardlinkManager, create_hardlink_structure
import pytest
from src.core.file import File
from src.core.hardlink_manager import HardlinkManager, create_hardlink_structure
from src.core.tag import Tag
from src.core.tag_manager import TagManager
@@ -84,7 +86,9 @@ class TestHardlinkManager:
assert (temp_output_dir / "rok" / "1988" / "file1.txt").exists()
assert (temp_output_dir / "žánr" / "Drama" / "file2.txt").exists()
def test_hardlinks_are_same_inode(self, files_with_tags, temp_output_dir, temp_source_dir):
def test_hardlinks_are_same_inode(
self, files_with_tags, temp_output_dir, temp_source_dir
):
"""Test že vytvořené soubory jsou opravdu hardlinky (stejný inode)"""
manager = HardlinkManager(temp_output_dir)
manager.create_structure_for_files(files_with_tags)
@@ -95,10 +99,14 @@ class TestHardlinkManager:
# Same inode = hardlink
assert original.stat().st_ino == hardlink.stat().st_ino
def test_create_structure_with_category_filter(self, files_with_tags, temp_output_dir):
def test_create_structure_with_category_filter(
self, files_with_tags, temp_output_dir
):
"""Test vytvoření struktury jen pro vybrané kategorie"""
manager = HardlinkManager(temp_output_dir)
success, fail = manager.create_structure_for_files(files_with_tags, categories=["žánr"])
success, fail = manager.create_structure_for_files(
files_with_tags, categories=["žánr"]
)
# Only "žánr" tags should be processed (3 links)
assert success == 3
@@ -107,7 +115,9 @@ class TestHardlinkManager:
assert (temp_output_dir / "žánr" / "Komedie" / "file1.txt").exists()
assert not (temp_output_dir / "rok").exists()
def test_create_structure_with_category_roots(self, files_with_tags, temp_output_dir):
def test_create_structure_with_category_roots(
self, files_with_tags, temp_output_dir
):
"""category_roots: genres sit at the output root, rok under 'Dle roku'."""
manager = HardlinkManager(temp_output_dir)
roots = {"žánr": "", "rok": "Dle roku"}
@@ -137,8 +147,8 @@ class TestHardlinkManager:
os.link(temp_source_dir / "file1.txt", mirror_link)
manager.sync_structure(
files_with_tags, category_roots=roots,
reserved_subfolders={"Seriály"})
files_with_tags, category_roots=roots, reserved_subfolders={"Seriály"}
)
# The mirror (reserved folder) is left alone
assert mirror_link.exists()
@@ -208,9 +218,7 @@ class TestHardlinkManager:
created2, _, removed2, _ = manager.sync_structure([f], category_roots=roots)
assert (created2, removed2) == (0, 0)
def test_link_name_strips_year_by_default(
self, tmp_path, tag_manager
):
def test_link_name_strips_year_by_default(self, tmp_path, tag_manager):
"""A pooled 'Title (YYYY).ext' hardlinks into the tag tree as 'Title.ext'."""
source = tmp_path / "source"
source.mkdir()
@@ -242,9 +250,7 @@ class TestHardlinkManager:
assert (output / "Sci-Fi" / "Matrix.mkv").exists() # year + id stripped
def test_link_name_keeps_year_on_collision(
self, tmp_path, tag_manager
):
def test_link_name_keeps_year_on_collision(self, tmp_path, tag_manager):
"""Two same-title films in one folder keep the year to disambiguate."""
source = tmp_path / "source"
source.mkdir()
@@ -276,9 +282,7 @@ class TestHardlinkManager:
assert (output / "Dle roku" / "1972" / "Solaris.mkv").exists()
assert (output / "Dle roku" / "2002" / "Solaris.mkv").exists()
def test_clean_naming_cleanup_is_consistent(
self, tmp_path, tag_manager
):
def test_clean_naming_cleanup_is_consistent(self, tmp_path, tag_manager):
"""Clean-named links are stable: a second sync removes/creates nothing."""
source = tmp_path / "source"
source.mkdir()
@@ -349,7 +353,9 @@ class TestHardlinkManager:
)
# Templated name inside the collection folder
assert (temp_output_dir / "Dle kolekce" / "James Bond" / "1962 - Dr. No.txt").exists()
assert (
temp_output_dir / "Dle kolekce" / "James Bond" / "1962 - Dr. No.txt"
).exists()
# Other categories use the default clean title (no year, not the pool name)
assert (temp_output_dir / "Akční" / "Dr. No.txt").exists()
@@ -365,9 +371,12 @@ class TestHardlinkManager:
roots = {"Kolekce": "Dle kolekce"}
templates = {"Kolekce": "{year} - {title}{ext}"}
manager = HardlinkManager(temp_output_dir)
manager.sync_structure([f], category_roots=roots, category_filename_templates=templates)
created, _, removed, _ = manager.sync_structure(
[f], category_roots=roots, category_filename_templates=templates)
manager.sync_structure(
[f], category_roots=roots, category_filename_templates=templates
)
_created, _, removed, _ = manager.sync_structure(
[f], category_roots=roots, category_filename_templates=templates
)
folder = temp_output_dir / "Dle kolekce" / "James Bond"
assert [p.name for p in folder.iterdir()] == ["1962 - Dr. No.txt"]
@@ -417,7 +426,8 @@ class TestHardlinkManager:
files_with_tags[2].added = "2026-01-01T00:00:00"
manager = HardlinkManager(temp_output_dir)
created, fail = manager.generate_recently_added(
files_with_tags, count=2, subfolder="- Nově přidané")
files_with_tags, count=2, subfolder="- Nově přidané"
)
recent = temp_output_dir / "- Nově přidané"
assert created == 2 and fail == 0
@@ -427,7 +437,9 @@ class TestHardlinkManager:
def test_dry_run(self, files_with_tags, temp_output_dir):
"""Test dry run (bez skutečného vytváření)"""
manager = HardlinkManager(temp_output_dir)
success, fail = manager.create_structure_for_files(files_with_tags, dry_run=True)
success, fail = manager.create_structure_for_files(
files_with_tags, dry_run=True
)
assert success == 4
assert fail == 0
@@ -497,7 +509,7 @@ class TestHardlinkManager:
manager = HardlinkManager(temp_output_dir)
# Create first time
success1, _ = manager.create_structure_for_files(files_with_tags)
_success1, _ = manager.create_structure_for_files(files_with_tags)
# Create second time - should skip existing
manager2 = HardlinkManager(temp_output_dir)
@@ -507,7 +519,9 @@ class TestHardlinkManager:
assert success2 == 0
assert fail2 == 0
def test_unique_name_on_conflict(self, temp_source_dir, temp_output_dir, tag_manager):
def test_unique_name_on_conflict(
self, temp_source_dir, temp_output_dir, tag_manager
):
"""Two different movies with the same clean name (no year to tell them
apart) in one folder get distinct links via a numeric suffix."""
f1 = File(temp_source_dir / "file1.txt", tag_manager)
@@ -530,7 +544,9 @@ class TestHardlinkManager:
assert (folder / "file1.txt").exists()
assert (folder / "file1_2.txt").exists()
def test_czech_characters_in_tags(self, temp_source_dir, temp_output_dir, tag_manager):
def test_czech_characters_in_tags(
self, temp_source_dir, temp_output_dir, tag_manager
):
"""Test českých znaků v názvech tagů"""
f1 = File(temp_source_dir / "file1.txt", tag_manager)
f1.tags.clear()
@@ -593,7 +609,9 @@ class TestConvenienceFunction:
output = tmp_path / "output"
output.mkdir()
success, fail, errors = create_hardlink_structure([f], output, categories=["include"])
success, _fail, _errors = create_hardlink_structure(
[f], output, categories=["include"]
)
assert success == 1
assert (output / "include" / "yes" / "file.txt").exists()
@@ -677,7 +695,7 @@ class TestSyncStructure:
f.add_tag(Tag("cat", "tag1"))
# Remove obsolete links
removed, paths = manager.remove_obsolete_links([f])
removed, _paths = manager.remove_obsolete_links([f])
assert removed == 1
assert not (output / "cat" / "tag2" / "file.txt").exists()
@@ -699,7 +717,7 @@ class TestSyncStructure:
f.tags.clear()
f.add_tag(Tag("cat", "tag1"))
removed, paths = manager.remove_obsolete_links([f], dry_run=True)
removed, _paths = manager.remove_obsolete_links([f], dry_run=True)
assert removed == 1
# File should still exist (dry run)
@@ -747,7 +765,7 @@ class TestSyncStructure:
manager.create_structure_for_files([f])
# Sync again without changes
created, c_fail, removed, r_fail = manager.sync_structure([f])
_created, _c_fail, removed, _r_fail = manager.sync_structure([f])
# Nothing should change (existing links are skipped)
assert removed == 0
@@ -821,7 +839,7 @@ class TestEdgeCases:
# output doesn't exist
manager = HardlinkManager(output)
success, fail = manager.create_structure_for_files([f])
success, _fail = manager.create_structure_for_files([f])
assert success == 1
assert (output / "cat" / "tag" / "file.txt").exists()
@@ -842,7 +860,7 @@ class TestEdgeCases:
output.mkdir()
manager = HardlinkManager(output)
success, fail = manager.create_structure_for_files([f])
success, _fail = manager.create_structure_for_files([f])
assert success == 1
assert (output / "test" / "tag" / "file with spaces (part 2).txt").exists()
@@ -862,7 +880,7 @@ class TestEdgeCases:
manager = HardlinkManager(output)
# Empty list = no categories = no links
success, fail = manager.create_structure_for_files([f], categories=[])
success, _fail = manager.create_structure_for_files([f], categories=[])
assert success == 0
@@ -942,3 +960,30 @@ class TestMirrorAsIs:
"""Neexistující zdroj nic neudělá"""
manager = HardlinkManager(tmp_path / "out")
assert manager.mirror_as_is(tmp_path / "missing", "Seriály") == (0, 0)
class TestRemoveMirror:
"""Removing a whole mirrored library (or one bucket entry) from the output."""
def test_removes_a_whole_mirrored_folder(self, tmp_path):
output = tmp_path / "out"
(output / "Dokumenty" / "sub").mkdir(parents=True)
(output / "Dokumenty" / "sub" / "clip.mp4").write_bytes(b"x")
manager = HardlinkManager(output)
assert manager.remove_mirror("Dokumenty") is True
assert not (output / "Dokumenty").exists()
def test_removes_one_bucket_entry_by_compound_name(self, tmp_path):
output = tmp_path / "out"
(output / "Seriály" / "Show A").mkdir(parents=True)
(output / "Seriály" / "Show B").mkdir(parents=True)
manager = HardlinkManager(output)
assert manager.remove_mirror("Seriály/Show A") is True
assert not (output / "Seriály" / "Show A").exists()
assert (output / "Seriály" / "Show B").exists() # sibling untouched
def test_missing_target_returns_false(self, tmp_path):
manager = HardlinkManager(tmp_path / "out")
assert manager.remove_mirror("Nope") is False
+16 -11
View File
@@ -3,6 +3,7 @@
The FFmpeg subprocess call is monkeypatched so the tests don't need ffmpeg
installed and don't touch real video data.
"""
import subprocess
from pathlib import Path
from types import SimpleNamespace
@@ -23,6 +24,7 @@ def video_file(tmp_path) -> Path:
def _fake_run(returncode=0, stdout="", stderr=""):
def run(cmd, **kwargs):
return SimpleNamespace(returncode=returncode, stdout=stdout, stderr=stderr)
return run
@@ -36,8 +38,10 @@ def test_deep_clean_file_is_ok(monkeypatch, video_file):
def test_deep_reports_decode_errors(monkeypatch, video_file):
monkeypatch.setattr(integrity.shutil, "which", lambda _: "/usr/bin/ffmpeg")
monkeypatch.setattr(
integrity.subprocess, "run",
_fake_run(1, "", "[matroska @ 0x..] Read error\nmore noise"))
integrity.subprocess,
"run",
_fake_run(1, "", "[matroska @ 0x..] Read error\nmore noise"),
)
result = check_video_integrity(video_file, deep=True)
assert not result.ok and result.checked
assert result.error == "[matroska @ 0x..] Read error" # first line only
@@ -46,8 +50,7 @@ def test_deep_reports_decode_errors(monkeypatch, video_file):
def test_deep_stderr_without_nonzero_rc_still_fails(monkeypatch, video_file):
"""ffmpeg can print decode errors while returning rc 0 — must still fail."""
monkeypatch.setattr(integrity.shutil, "which", lambda _: "/usr/bin/ffmpeg")
monkeypatch.setattr(
integrity.subprocess, "run", _fake_run(0, "", "corrupt packet"))
monkeypatch.setattr(integrity.subprocess, "run", _fake_run(0, "", "corrupt packet"))
result = check_video_integrity(video_file, deep=True)
assert not result.ok
@@ -99,9 +102,9 @@ def test_ffmpeg_available_reflects_which(monkeypatch):
def test_scan_collects_problems_and_progress(monkeypatch, tmp_path):
"""FileManager.scan_video_integrity aggregates per-file results."""
from src.core.tag_manager import TagManager
from src.core.file_manager import FileManager
from src.core.file import File
from src.core.file_manager import FileManager
from src.core.tag_manager import TagManager
good = tmp_path / "good.mkv"
bad = tmp_path / "bad.mkv"
@@ -121,7 +124,8 @@ def test_scan_collects_problems_and_progress(monkeypatch, tmp_path):
seen: list[int] = []
result = fm.scan_video_integrity(
files, deep=True, on_progress=lambda d, t, f: seen.append(d))
files, deep=True, on_progress=lambda d, t, f: seen.append(d)
)
assert result["ok"] and result["checked"] == 2
assert [r.path.name for r in result["problems"]] == ["bad.mkv"]
@@ -129,9 +133,9 @@ def test_scan_collects_problems_and_progress(monkeypatch, tmp_path):
def test_scan_stops_on_cancel(monkeypatch, tmp_path):
from src.core.tag_manager import TagManager
from src.core.file_manager import FileManager
from src.core.file import File
from src.core.file_manager import FileManager
from src.core.tag_manager import TagManager
files = []
for i in range(3):
@@ -142,7 +146,8 @@ def test_scan_stops_on_cancel(monkeypatch, tmp_path):
monkeypatch.setattr("src.core.file_manager.ffmpeg_available", lambda deep: True)
monkeypatch.setattr(
"src.core.file_manager.check_video_integrity",
lambda path, deep=True: IntegrityResult(Path(path), ok=True))
lambda path, deep=True: IntegrityResult(Path(path), ok=True),
)
fm = FileManager(TagManager())
result = fm.scan_video_integrity(files, should_cancel=lambda: True)
@@ -150,8 +155,8 @@ def test_scan_stops_on_cancel(monkeypatch, tmp_path):
def test_scan_reports_missing_ffmpeg(monkeypatch, tmp_path):
from src.core.tag_manager import TagManager
from src.core.file_manager import FileManager
from src.core.tag_manager import TagManager
monkeypatch.setattr("src.core.file_manager.ffmpeg_available", lambda deep: False)
fm = FileManager(TagManager())
+30 -14
View File
@@ -1,4 +1,5 @@
"""Tests for the Jellyfin client + pool verification. Network is monkeypatched."""
from types import SimpleNamespace
import pytest
@@ -14,8 +15,9 @@ def _configured(monkeypatch):
def _resp(status=200, payload=None):
return SimpleNamespace(status_code=status, ok=(200 <= status < 300),
json=lambda: (payload or {}))
return SimpleNamespace(
status_code=status, ok=(200 <= status < 300), json=lambda: payload or {}
)
def test_base_url_strips_trailing_slash():
@@ -29,8 +31,9 @@ def test_api_available(monkeypatch):
def test_server_version(monkeypatch):
monkeypatch.setattr(jellyfin.requests, "request",
lambda *a, **k: _resp(200, {"Version": "10.9.11"}))
monkeypatch.setattr(
jellyfin.requests, "request", lambda *a, **k: _resp(200, {"Version": "10.9.11"})
)
assert jellyfin.server_version() == "10.9.11"
@@ -40,21 +43,32 @@ def test_scan_all_posts_refresh(monkeypatch):
def fake(method, url, **k):
seen["method"], seen["url"] = method, url
return _resp(200, {})
monkeypatch.setattr(jellyfin.requests, "request", fake)
jellyfin.scan_all()
assert seen["method"] == "POST" and seen["url"].endswith("/Library/Refresh")
def test_fetch_movies_maps_tmdb_id(monkeypatch):
payload = {"Items": [
{"Id": "a1", "Name": "Matrix", "ProductionYear": 1999,
"ProviderIds": {"Tmdb": "603"}, "Path": "/m/Matrix (1999) [tmdbid-603].mkv"},
{"Id": "a2", "Name": "Bez ID", "ProductionYear": 2000, "ProviderIds": {}},
]}
monkeypatch.setattr(jellyfin.requests, "request", lambda *a, **k: _resp(200, payload))
payload = {
"Items": [
{
"Id": "a1",
"Name": "Matrix",
"ProductionYear": 1999,
"ProviderIds": {"Tmdb": "603"},
"Path": "/m/Matrix (1999) [tmdbid-603].mkv",
},
{"Id": "a2", "Name": "Bez ID", "ProductionYear": 2000, "ProviderIds": {}},
]
}
monkeypatch.setattr(
jellyfin.requests, "request", lambda *a, **k: _resp(200, payload)
)
movies = jellyfin.fetch_movies()
assert movies[0] == JellyfinMovie("a1", "Matrix", 1999, 603,
"/m/Matrix (1999) [tmdbid-603].mkv")
assert movies[0] == JellyfinMovie(
"a1", "Matrix", 1999, 603, "/m/Matrix (1999) [tmdbid-603].mkv"
)
assert movies[1].tmdb_id is None
@@ -74,9 +88,11 @@ class TestVerifyAgainstJellyfin:
@pytest.fixture
def file_manager(self, tmp_path, monkeypatch):
import src.core.config as config_module
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", tmp_path / ".gtag")
from src.core.tag_manager import TagManager
from src.core.file_manager import FileManager
from src.core.tag_manager import TagManager
fm = FileManager(TagManager())
fm.set_pool_dir(tmp_path / "pool")
return fm
@@ -88,7 +104,7 @@ class TestVerifyAgainstJellyfin:
file_manager.import_movie(tmp_path / "b.mkv", "Neznámý", year=2020) # no id
jf = [
JellyfinMovie("j1", "Matrix", 1999, 603, None), # matches a by tmdb
JellyfinMovie("j1", "Matrix", 1999, 603, None), # matches a by tmdb
JellyfinMovie("j2", "Cizí film", 2011, 999, None), # jellyfin-only
]
result = file_manager.verify_against_jellyfin(jf)
+142
View File
@@ -0,0 +1,142 @@
"""Tests for recovering ČSFD link / title / year from pre-index layouts."""
import json
from src.core.legacy_meta import LegacyMeta, read_legacy_meta
def _video(tmp_path, name="Matrix (1999).mkv"):
v = tmp_path / name
v.write_bytes(b"x")
return v
def test_reads_tagger_era_sidecar(tmp_path):
video = _video(tmp_path)
(tmp_path / f".{video.name}.!tag").write_text(
json.dumps(
{
"title": "Matrix",
"csfd_link": "https://www.csfd.cz/film/9499-matrix/",
"csfd_cache": {"year": 1999, "rating": 90},
}
),
encoding="utf-8",
)
meta = read_legacy_meta(video)
assert meta == LegacyMeta(
csfd_url="https://www.csfd.cz/film/9499-matrix/",
title="Matrix",
year="1999",
source="sidecar",
)
def test_sidecar_new_key_and_meta_block(tmp_path):
video = _video(tmp_path)
(tmp_path / f".{video.name}.!tag").write_text(
json.dumps(
{
"title": "Matrix",
"csfd_url": "https://www.csfd.cz/film/9499/",
"meta": {"year": "1999"},
}
),
encoding="utf-8",
)
meta = read_legacy_meta(video)
assert meta.csfd_url == "https://www.csfd.cz/film/9499/"
assert meta.year == "1999"
def test_reads_old_pool_index_by_relative_key(tmp_path):
pool = tmp_path / "old-pool"
movies_dir = pool / "Filmy"
movies_dir.mkdir(parents=True)
video = _video(movies_dir)
(pool / ".Curator.!index").write_text(
json.dumps(
{
"settings": {},
"movies": {
"Filmy/Matrix (1999).mkv": {
"title": "Matrix",
"csfd_link": "https://www.csfd.cz/film/9499-matrix/",
"csfd_cache": {"year": 1999},
}
},
}
),
encoding="utf-8",
)
meta = read_legacy_meta(video)
assert meta.csfd_url == "https://www.csfd.cz/film/9499-matrix/"
assert meta.title == "Matrix"
assert meta.year == "1999"
assert meta.source == "index"
def test_index_fallback_to_unique_basename_match(tmp_path):
pool = tmp_path / "pool"
pool.mkdir()
elsewhere = tmp_path / "loose"
elsewhere.mkdir()
video = _video(elsewhere)
(pool.parent / ".Curator.!index").write_text(
json.dumps(
{
"movies": {
"whatever/Matrix (1999).mkv": {
"csfd_url": "https://www.csfd.cz/film/9499/"
}
},
}
),
encoding="utf-8",
)
meta = read_legacy_meta(video)
assert meta is not None
assert meta.csfd_url == "https://www.csfd.cz/film/9499/"
def test_sidecar_wins_over_index(tmp_path):
video = _video(tmp_path)
(tmp_path / f".{video.name}.!tag").write_text(
json.dumps({"csfd_link": "https://www.csfd.cz/film/SIDECAR/"}), encoding="utf-8"
)
(tmp_path / ".Curator.!index").write_text(
json.dumps(
{
"movies": {
"Matrix (1999).mkv": {
"csfd_link": "https://www.csfd.cz/film/INDEX/"
}
},
}
),
encoding="utf-8",
)
assert read_legacy_meta(video).csfd_url == "https://www.csfd.cz/film/SIDECAR/"
def test_none_when_no_metadata(tmp_path):
assert read_legacy_meta(_video(tmp_path)) is None
def test_none_on_broken_sidecar(tmp_path):
video = _video(tmp_path)
(tmp_path / f".{video.name}.!tag").write_text("{not json", encoding="utf-8")
assert read_legacy_meta(video) is None
def test_record_without_useful_fields_is_none(tmp_path):
video = _video(tmp_path)
(tmp_path / f".{video.name}.!tag").write_text(
json.dumps({"new": True, "ignored": False, "tags": []}), encoding="utf-8"
)
assert read_legacy_meta(video) is None
+3 -2
View File
@@ -1,10 +1,11 @@
import tempfile
import tkinter as tk
from pathlib import Path
import pytest
from PIL import Image, ImageTk
from src.core.media_utils import load_icon
from PIL import Image, ImageTk
import tkinter as tk
@pytest.fixture(scope="module")
+14 -7
View File
@@ -1,4 +1,5 @@
"""Tests for pool → TMDb migration (src.core.migrate + FileManager)."""
import pytest
from src.core.migrate import classify, normalize
@@ -49,9 +50,11 @@ class TestFileManagerMigrate:
@pytest.fixture
def file_manager(self, tmp_path, monkeypatch):
import src.core.config as config_module
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", tmp_path / ".gtag")
from src.core.tag_manager import TagManager
from src.core.file_manager import FileManager
from src.core.tag_manager import TagManager
fm = FileManager(TagManager())
fm.set_pool_dir(tmp_path / "pool")
return fm
@@ -61,10 +64,13 @@ class TestFileManagerMigrate:
movie = file_manager.import_movie(tmp_path / "a.mkv", "Matrix", year=1999)
assert "[tmdbid" not in movie.file_path.name # not migrated yet
import src.core.tmdb as tmdb
from src.core import tmdb
monkeypatch.setattr(
tmdb, "search_movie",
lambda q, year=None, limit=5: [TmdbResult(603, "Matrix", "1999", "movie")])
tmdb,
"search_movie",
lambda q, year=None, limit=5: [TmdbResult(603, "Matrix", "1999", "movie")],
)
proposals = file_manager.match_pool_to_tmdb()
assert len(proposals) == 1
@@ -78,11 +84,12 @@ class TestFileManagerMigrate:
(tmp_path / "a.mkv").write_bytes(b"x")
file_manager.import_movie(tmp_path / "a.mkv", "Matrix", year=1999, tmdb_id=603)
import src.core.tmdb as tmdb
from src.core import tmdb
called = []
monkeypatch.setattr(
tmdb, "search_movie",
lambda q, year=None, limit=5: called.append(q) or [])
tmdb, "search_movie", lambda q, year=None, limit=5: called.append(q) or []
)
proposals = file_manager.match_pool_to_tmdb()
assert proposals == [] and called == [] # already has id → not searched
+224
View File
@@ -0,0 +1,224 @@
"""Tests for the movie object and the pool layout it derives (src.core.movie)."""
from pathlib import Path
import pytest
from src.core.movie import (
DEFAULT_VERSION_LABEL,
MediaFile,
Movie,
identity_key,
media_filename,
normalize_3d_label,
)
@pytest.mark.parametrize(
"label,expected",
[
("HSBS", "HSBS"),
("hsbs", "HSBS"),
("3D HSBS", "HSBS"),
("3d.hsbs", "HSBS"),
("3D_FTAB", "FTAB"),
("3d-mvc", "MVC"),
("4K", None),
("Director's Cut", None),
("", None),
(None, None),
],
)
def test_normalize_3d_label(label, expected):
assert normalize_3d_label(label) == expected
class TestMediaFilename:
def test_no_label_is_the_bare_stem(self):
assert (
media_filename("Matrix (1999)", "", ".mkv", grouped=False)
== "Matrix (1999).mkv"
)
def test_3d_label_uses_jellyfins_documented_form(self):
assert (
media_filename("Avatar (2009)", "3D HSBS", ".mkv", grouped=False)
== "Avatar (2009).3D.HSBS.mkv"
)
# same spelling grouped — "." is a valid separator after the folder name
assert (
media_filename("Avatar (2009)", "hsbs", ".mkv", grouped=True)
== "Avatar (2009).3D.HSBS.mkv"
)
def test_plain_label_uses_dash_form(self):
assert (
media_filename("Avatar (2009)", "4K", ".mkv", grouped=True)
== "Avatar (2009) - 4K.mkv"
)
def test_grouped_empty_label_gets_a_default(self):
assert (
media_filename("Avatar (2009)", "", ".mkv", grouped=True)
== f"Avatar (2009) - {DEFAULT_VERSION_LABEL}.mkv"
)
class TestIdentityKey:
def test_tmdb_id_wins(self):
assert identity_key(603, "Matrix", "1999") == "tmdb-603"
assert identity_key("603", "cokoli", None) == "tmdb-603"
def test_slug_fallback_without_id(self):
assert identity_key(None, "Matrix", "1999") == "name-matrix-1999"
def test_slug_folds_diacritics(self):
assert identity_key(None, "Pelíšky", "1999") == "name-pelisky-1999"
def test_slug_without_year(self):
assert identity_key(None, "Nějaký film", None) == "name-nejaky-film"
def _movie(**kwargs) -> Movie:
base = {
"key": "tmdb-19995",
"title": "Avatar",
"year": "2009",
"tmdb_id": 19995,
"library": "Filmy",
}
base.update(kwargs)
return Movie(**base)
class TestMovieShape:
def test_stem_is_the_canonical_form(self):
assert _movie().stem == "Avatar (2009) [tmdbid-19995]"
def test_stem_falls_back_to_title_without_year(self):
assert _movie(year=None, tmdb_id=None).stem == "Avatar"
def test_single_video_is_not_grouped(self):
m = _movie(media=[MediaFile(Path("/pool/Filmy/x.mkv"))])
assert m.is_grouped is False
def test_two_videos_are_grouped(self):
m = _movie(
media=[
MediaFile(Path("/a.mkv"), "3D HSBS"),
MediaFile(Path("/b.mkv"), "Výchozí"),
]
)
assert m.is_grouped is True
def test_primary_prefers_the_plain_version(self):
m = _movie(
media=[MediaFile(Path("/a.mkv"), "3D HSBS"), MediaFile(Path("/b.mkv"), "")]
)
assert m.primary.label == ""
class TestDesiredLayout:
def test_one_video_stays_flat_in_the_library(self, tmp_path):
m = _movie(media=[MediaFile(tmp_path / "kdekoli.mkv", "")])
((_media, target),) = m.desired_layout(tmp_path)
assert target == tmp_path / "Filmy" / "Avatar (2009) [tmdbid-19995].mkv"
def test_one_3d_video_keeps_the_3d_tag_flat(self, tmp_path):
m = _movie(media=[MediaFile(tmp_path / "x.mkv", "3D HSBS")])
((_m, target),) = m.desired_layout(tmp_path)
assert target.name == "Avatar (2009) [tmdbid-19995].3D.HSBS.mkv"
assert target.parent == tmp_path / "Filmy"
def test_two_videos_move_into_the_movie_folder(self, tmp_path):
m = _movie(
media=[
MediaFile(tmp_path / "a.mkv", ""),
MediaFile(tmp_path / "b.mkv", "3D HSBS"),
]
)
targets = {mf.label: path for mf, path in m.desired_layout(tmp_path)}
folder = tmp_path / "Filmy" / "Avatar (2009) [tmdbid-19995]"
assert (
targets[""]
== folder / f"Avatar (2009) [tmdbid-19995] - {DEFAULT_VERSION_LABEL}.mkv"
)
assert targets["3D HSBS"] == folder / "Avatar (2009) [tmdbid-19995].3D.HSBS.mkv"
def test_layout_follows_a_retitle(self, tmp_path):
"""Renaming the movie is just a different desired layout — the thing
that used to be impossible for a grouped movie."""
m = _movie(
title="Avatar: Nová verze",
media=[
MediaFile(tmp_path / "a.mkv", "A"),
MediaFile(tmp_path / "b.mkv", "B"),
],
)
folders = {path.parent for _mf, path in m.desired_layout(tmp_path)}
assert folders == {
tmp_path / "Filmy" / "Avatar: Nová verze (2009) [tmdbid-19995]"
}
def test_layout_follows_a_library_move(self, tmp_path):
m = _movie(library="Kids", media=[MediaFile(tmp_path / "a.mkv", "")])
((_m, target),) = m.desired_layout(tmp_path)
assert target.parent == tmp_path / "Kids"
class TestRecordRoundTrip:
def test_to_and_from_record(self, tmp_path):
m = _movie(
media=[MediaFile(tmp_path / "Filmy" / "a.mkv", "3D HSBS")],
csfd_url="https://www.csfd.cz/film/1/",
date="2026-01-01",
attributes={"collection_sort": "01"},
meta={"year": "2009"},
)
record = m.to_record(tmp_path)
assert record["media"] == [
{"role": "video", "label": "3D HSBS", "file": "Filmy/a.mkv"}
]
back = Movie.from_record("tmdb-19995", record, tmp_path)
assert back.title == "Avatar"
assert back.year == "2009"
assert back.tmdb_id == 19995
assert back.library == "Filmy"
assert back.csfd_url == "https://www.csfd.cz/film/1/"
assert back.attributes == {"collection_sort": "01"}
assert back.meta == {"year": "2009"}
assert back.media[0].path == tmp_path / "Filmy" / "a.mkv"
assert back.media[0].format_3d == "HSBS"
def test_from_record_builds_tags_via_tagmanager(self, tmp_path):
from src.core.tag_manager import TagManager
record = {"title": "Avatar", "media": [], "tags": ["Žánr/Sci-Fi"]}
back = Movie.from_record("tmdb-19995", record, tmp_path, TagManager())
assert {t.full_path for t in back.tags} == {"Žánr/Sci-Fi"}
@pytest.mark.parametrize(
"label,expected",
[
("part1", 1),
("Part 2", 2),
("pt3", 3),
("CD1", 1),
("disc-2", 2),
("dvd_4", 4),
("disk12", 12),
("4K", None),
("3D HSBS", None),
("", None),
("partition", None),
],
)
def test_normalize_part_label(label, expected):
from src.core.movie import normalize_part_label
assert normalize_part_label(label) == expected
+81 -11
View File
@@ -1,20 +1,32 @@
"""Tests for the pool naming convention helpers (src.core.naming)."""
import pytest
from src.core.naming import (
THREE_D_FORMATS,
canonical_pool_stem,
is_canonical_pool_stem,
is_valid_year,
parse_3d_format,
parse_pool_stem,
parse_tmdb_id,
three_d_format_from_filename,
year_from_filename,
)
@pytest.mark.parametrize("year,ok", [
("1968", True), (1968, True), ("968", False), ("19688", False),
("abcd", False), ("", False), (" 1999 ", True),
])
@pytest.mark.parametrize(
"year,ok",
[
("1968", True),
(1968, True),
("968", False),
("19688", False),
("abcd", False),
("", False),
(" 1999 ", True),
],
)
def test_is_valid_year(year, ok):
assert is_valid_year(year) is ok
@@ -79,12 +91,70 @@ def test_is_canonical_pool_stem():
assert not is_canonical_pool_stem("Matrix")
@pytest.mark.parametrize("name,year", [
("Matrix.1999.1080p.BluRay.mkv", "1999"),
("Šíleně smutná princezna (1968).mkv", "1968"),
("Movie 2001 2010 remux.mkv", "2010"), # last standalone year wins
("No Year Here.mkv", None),
("Film 12345.mkv", None), # not a bare 4-digit year
])
def test_canonical_pool_stem_with_3d_format():
assert (
canonical_pool_stem("Avatar", 2009, 19995, "hsbs")
== "Avatar (2009) [tmdbid-19995].3D.HSBS"
)
# upper-cased regardless of input case
assert canonical_pool_stem("Avatar", 2009, None, "fsbs") == "Avatar (2009).3D.FSBS"
# empty/None → no tag at all
assert canonical_pool_stem("Avatar", 2009, None, None) == "Avatar (2009)"
assert canonical_pool_stem("Avatar", 2009, None, "") == "Avatar (2009)"
def test_canonical_pool_stem_rejects_bad_3d_format():
with pytest.raises(ValueError):
canonical_pool_stem("Avatar", 2009, None, "bogus")
@pytest.mark.parametrize("fmt", THREE_D_FORMATS)
def test_parse_3d_format_roundtrip(fmt):
stem = canonical_pool_stem("Avatar", 2009, 19995, fmt)
assert parse_3d_format(stem) == fmt
assert parse_pool_stem(stem) == ("Avatar", 2009)
assert parse_tmdb_id(stem) == 19995
def test_parse_3d_format_case_insensitive_on_read():
assert parse_3d_format("Avatar (2009).3D.hsbs") == "HSBS"
def test_parse_3d_format_absent_is_none():
assert parse_3d_format("Avatar (2009) [tmdbid-19995]") is None
assert parse_3d_format("Avatar (2009)") is None
def test_canonical_pool_stem_3d_without_tmdb_id():
assert canonical_pool_stem("Avatar", 2009, None, "MVC") == "Avatar (2009).3D.MVC"
assert parse_3d_format("Avatar (2009).3D.MVC") == "MVC"
@pytest.mark.parametrize(
"name,year",
[
("Matrix.1999.1080p.BluRay.mkv", "1999"),
("Šíleně smutná princezna (1968).mkv", "1968"),
("Movie 2001 2010 remux.mkv", "2010"), # last standalone year wins
("No Year Here.mkv", None),
("Film 12345.mkv", None), # not a bare 4-digit year
],
)
def test_year_from_filename(name, year):
assert year_from_filename(name) == year
@pytest.mark.parametrize(
"name,fmt",
[
("Avatar.2009.3D.HSBS.BluRay.mkv", "HSBS"),
("Avatar.2009.3d.hsbs.mkv", "HSBS"),
("Avatar (2009) 3D_FTAB 1080p.mkv", "FTAB"),
("Avatar (2009) - 3D-MVC.mkv", "MVC"),
("Avatar (2009).mkv", None),
("Premium3D.HSBS.mkv", None), # "3D" glued to a word doesn't count
("Avatar.2009.HSBS.mkv", None), # no "3D" token at all
],
)
def test_three_d_format_from_filename(name, fmt):
assert three_d_format_from_filename(name) == fmt
+51 -6
View File
@@ -1,4 +1,5 @@
"""Tests for the NFO reader (src.core.nfo)."""
from src.core.nfo import NfoData, find_nfo, read_nfo
MOVIE_NFO = """<?xml version="1.0" encoding="utf-8"?>
@@ -26,14 +27,20 @@ def test_read_nfo_sibling(tmp_path):
nfo = read_nfo(video)
assert nfo == NfoData(
title="Matrix", year="1999", genres=["Akční", "Sci-Fi"],
countries=["USA"], rating=87, tmdb_id=603)
title="Matrix",
year="1999",
genres=["Akční", "Sci-Fi"],
countries=["USA"],
rating=87,
tmdb_id=603,
)
def test_rating_scaled_to_percent(tmp_path):
video = _movie(tmp_path)
video.with_suffix(".nfo").write_text(
"<movie><rating>7.3</rating></movie>", encoding="utf-8")
"<movie><rating>7.3</rating></movie>", encoding="utf-8"
)
assert read_nfo(video).rating == 73
@@ -41,17 +48,54 @@ def test_rating_from_ratings_block(tmp_path):
video = _movie(tmp_path)
video.with_suffix(".nfo").write_text(
"<movie><ratings><rating name='tmdb'><value>9.1</value></rating>"
"</ratings></movie>", encoding="utf-8")
"</ratings></movie>",
encoding="utf-8",
)
assert read_nfo(video).rating == 91
def test_tmdbid_from_tmdbid_tag(tmp_path):
video = _movie(tmp_path)
video.with_suffix(".nfo").write_text(
"<movie><tmdbid>603</tmdbid></movie>", encoding="utf-8")
"<movie><tmdbid>603</tmdbid></movie>", encoding="utf-8"
)
assert read_nfo(video).tmdb_id == 603
def test_csfd_url_from_uniqueid_bare_id(tmp_path):
video = _movie(tmp_path)
video.with_suffix(".nfo").write_text(
'<movie><uniqueid type="csfd">9499</uniqueid></movie>', encoding="utf-8"
)
assert read_nfo(video).csfd_url == "https://www.csfd.cz/film/9499/"
def test_csfd_url_from_uniqueid_full_url(tmp_path):
video = _movie(tmp_path)
video.with_suffix(".nfo").write_text(
'<movie><uniqueid type="csfd">https://www.csfd.cz/film/9499-matrix/'
"</uniqueid></movie>",
encoding="utf-8",
)
assert read_nfo(video).csfd_url == "https://www.csfd.cz/film/9499-matrix/"
def test_csfd_url_from_legacy_csfdid_tag(tmp_path):
video = _movie(tmp_path)
video.with_suffix(".nfo").write_text(
"<movie><csfdid>12345</csfdid></movie>", encoding="utf-8"
)
assert read_nfo(video).csfd_url == "https://www.csfd.cz/film/12345/"
def test_csfd_url_absent_is_none(tmp_path):
video = _movie(tmp_path)
video.with_suffix(".nfo").write_text(
"<movie><title>Matrix</title></movie>", encoding="utf-8"
)
assert read_nfo(video).csfd_url is None
def test_find_prefers_sibling_over_movie_nfo(tmp_path):
folder = tmp_path / "Matrix (1999)"
folder.mkdir()
@@ -86,9 +130,10 @@ def test_field_values():
def test_refresh_tags_from_nfo(tmp_path, monkeypatch):
import src.core.config as config_module
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", tmp_path / ".gtag")
from src.core.tag_manager import TagManager
from src.core.file_manager import FileManager
from src.core.tag_manager import TagManager
fm = FileManager(TagManager())
fm.set_pool_dir(tmp_path / "pool")
+175 -23
View File
@@ -1,48 +1,72 @@
import json
from src.core.pool_index import PoolIndex, INDEX_FILENAME
from src.core.file import File
from src.core.pool_index import INDEX_FILENAME, INDEX_VERSION, PoolIndex
from src.core.tag_manager import TagManager
class TestPoolIndex:
"""Testy pro unifikovaný metadata index poolu"""
"""Testy pro unifikovaný metadata index poolu (v2 — klíč = identita filmu)"""
def test_empty_index_when_no_file(self, tmp_path):
index = PoolIndex(tmp_path)
assert index.records == {}
assert index.get(tmp_path / "Filmy" / "x.mkv") is None
assert index.get_movie("tmdb-603") is None
def test_set_and_get_by_relative_key(self, tmp_path):
def test_set_and_get_movie_by_identity(self, tmp_path):
index = PoolIndex(tmp_path)
path = tmp_path / "Filmy" / "Matrix.mkv"
index.set(path, {"title": "Matrix"})
index.set_movie("tmdb-603", {"title": "Matrix", "media": []})
assert index.get(path) == {"title": "Matrix"}
# key is the pool-relative POSIX path
assert "Filmy/Matrix.mkv" in index.records
assert index.get_movie("tmdb-603") == {"title": "Matrix", "media": []}
assert "tmdb-603" in index.records
def test_set_persists_to_disk(self, tmp_path):
def test_set_persists_to_disk_with_version(self, tmp_path):
index = PoolIndex(tmp_path)
index.set(tmp_path / "Filmy" / "A.mkv", {"title": "A"})
index.set_movie("tmdb-603", {"title": "Matrix", "media": []})
on_disk = json.loads((tmp_path / INDEX_FILENAME).read_text(encoding="utf-8"))
assert on_disk["movies"]["Filmy/A.mkv"]["title"] == "A"
assert on_disk["version"] == INDEX_VERSION
assert on_disk["movies"]["tmdb-603"]["title"] == "Matrix"
def test_reload_reads_existing_index(self, tmp_path):
PoolIndex(tmp_path).set(tmp_path / "Filmy" / "A.mkv", {"title": "A"})
PoolIndex(tmp_path).set_movie("tmdb-603", {"title": "Matrix", "media": []})
reloaded = PoolIndex(tmp_path)
assert reloaded.get(tmp_path / "Filmy" / "A.mkv") == {"title": "A"}
assert reloaded.get_movie("tmdb-603")["title"] == "Matrix"
def test_delete_removes_record(self, tmp_path):
def test_delete_movie_removes_record(self, tmp_path):
index = PoolIndex(tmp_path)
path = tmp_path / "Filmy" / "A.mkv"
index.set(path, {"title": "A"})
index.delete(path)
index.set_movie("tmdb-603", {"title": "Matrix", "media": []})
index.delete_movie("tmdb-603")
assert index.get(path) is None
assert PoolIndex(tmp_path).get(path) is None
assert index.get_movie("tmdb-603") is None
assert PoolIndex(tmp_path).get_movie("tmdb-603") is None
def test_rekey_movie(self, tmp_path):
index = PoolIndex(tmp_path)
index.set_movie("name-matrix-1999", {"title": "Matrix", "media": []})
index.rekey_movie("name-matrix-1999", "tmdb-603")
assert index.get_movie("name-matrix-1999") is None
assert index.get_movie("tmdb-603")["title"] == "Matrix"
def test_find_movie_by_media(self, tmp_path):
index = PoolIndex(tmp_path)
index.set_movie(
"tmdb-603",
{
"title": "Matrix",
"media": [{"role": "video", "label": "", "file": "Filmy/Matrix.mkv"}],
},
)
found = index.find_movie_by_media(tmp_path / "Filmy" / "Matrix.mkv")
assert found is not None
key, record = found
assert key == "tmdb-603"
assert record["title"] == "Matrix"
assert index.find_movie_by_media(tmp_path / "Filmy" / "Jiny.mkv") is None
def test_corrupt_index_loads_empty(self, tmp_path):
(tmp_path / INDEX_FILENAME).write_text("{ not json", encoding="utf-8")
@@ -50,8 +74,116 @@ class TestPoolIndex:
assert index.records == {}
class TestV1Migration:
"""Starý path-klíčovaný index se převede na identity-klíčovaný (bez hýbání soubory)"""
def _write_v1(self, tmp_path, movies: dict) -> None:
(tmp_path / INDEX_FILENAME).write_text(
json.dumps({"settings": {"tag_schema": []}, "movies": movies}),
encoding="utf-8",
)
def test_flat_movie_becomes_identity_keyed_record(self, tmp_path):
self._write_v1(
tmp_path,
{
"Filmy/Matrix (1999) [tmdbid-603].mkv": {
"title": "Matrix",
"tags": ["Žánr/Akční"],
"csfd_url": "https://www.csfd.cz/film/9499/",
"date": "1999-03-31",
"added": "2026-01-01T00:00:00",
"attributes": {"collection_sort": "01"},
}
},
)
index = PoolIndex(tmp_path)
record = index.get_movie("tmdb-603")
assert record is not None
assert record["title"] == "Matrix"
assert record["year"] == "1999"
assert record["tmdb_id"] == 603
assert record["library"] == "Filmy"
assert record["media"] == [
{
"role": "video",
"label": "",
"file": "Filmy/Matrix (1999) [tmdbid-603].mkv",
}
]
assert record["tags"] == ["Žánr/Akční"]
assert record["csfd_url"] == "https://www.csfd.cz/film/9499/"
assert record["attributes"] == {"collection_sort": "01"}
def test_grouped_versions_merge_into_one_record(self, tmp_path):
base = "Filmy/Avatar (2009) [tmdbid-19995]"
self._write_v1(
tmp_path,
{
f"{base}/Avatar (2009) [tmdbid-19995] - Výchozí.mkv": {
"title": "Avatar"
},
f"{base}/Avatar (2009) [tmdbid-19995] - 3D HSBS.mkv": {
"title": "Avatar"
},
},
)
index = PoolIndex(tmp_path)
assert len(index.all_movies()) == 1
record = index.get_movie("tmdb-19995")
assert {m["label"] for m in record["media"]} == {"Výchozí", "3D HSBS"}
assert record["library"] == "Filmy"
def test_movie_without_tmdbid_gets_slug_key(self, tmp_path):
self._write_v1(tmp_path, {"Filmy/Pelíšky (1999).mkv": {"title": "Pelíšky"}})
index = PoolIndex(tmp_path)
assert index.get_movie("name-pelisky-1999") is not None
def test_migration_backs_up_and_bumps_version(self, tmp_path):
self._write_v1(tmp_path, {"Filmy/A (2000).mkv": {"title": "A"}})
PoolIndex(tmp_path)
backup = tmp_path / (INDEX_FILENAME + ".v1.bak")
assert backup.exists()
assert (
"Filmy/A (2000).mkv"
in json.loads(backup.read_text(encoding="utf-8"))["movies"]
)
on_disk = json.loads((tmp_path / INDEX_FILENAME).read_text(encoding="utf-8"))
assert on_disk["version"] == INDEX_VERSION
def test_migration_keeps_settings(self, tmp_path):
self._write_v1(tmp_path, {"Filmy/A (2000).mkv": {"title": "A"}})
index = PoolIndex(tmp_path)
assert index.get_setting("tag_schema") == []
def test_v2_index_is_not_migrated_again(self, tmp_path):
(tmp_path / INDEX_FILENAME).write_text(
json.dumps(
{
"version": INDEX_VERSION,
"settings": {},
"movies": {"tmdb-1": {"title": "X", "media": []}},
}
),
encoding="utf-8",
)
index = PoolIndex(tmp_path)
assert index.get_movie("tmdb-1")["title"] == "X"
assert not (tmp_path / (INDEX_FILENAME + ".v1.bak")).exists()
class TestFileWithIndex:
"""File používá index místo sidecar souboru, je-li injektován"""
"""File je pohled na jeden media záznam filmu v indexu"""
def test_index_backed_file_writes_no_sidecar(self, tmp_path):
index = PoolIndex(tmp_path)
@@ -62,7 +194,7 @@ class TestFileWithIndex:
f = File(movie, TagManager(), index=index)
assert not (movie.parent / ".Matrix.mkv.!tag").exists() # no sidecar
assert index.get(movie) is not None # record created in index
assert index.find_movie_by_media(movie) is not None # record created
assert f.tags == [] # no automatic tags
def test_index_backed_metadata_persists_across_reload(self, tmp_path):
@@ -94,4 +226,24 @@ class TestFileWithIndex:
f = File(movie, TagManager(), index=index)
f.delete_metadata()
assert index.get(movie) is None
assert index.find_movie_by_media(movie) is None
def test_two_versions_share_one_movie_record(self, tmp_path):
"""Obě verze čtou stejná metadata — to je smysl identity klíče."""
index = PoolIndex(tmp_path)
folder = tmp_path / "Filmy" / "Avatar (2009) [tmdbid-19995]"
folder.mkdir(parents=True)
a = folder / "Avatar (2009) [tmdbid-19995] - Výchozí.mkv"
b = folder / "Avatar (2009) [tmdbid-19995] - 3D HSBS.mkv"
a.write_bytes(b"x")
b.write_bytes(b"y")
tm = TagManager()
fa = File(a, tm, index=index)
fa.title = "Avatar"
fa.add_tag("Žánr/Sci-Fi")
fb = File(b, tm, index=index)
assert len(index.all_movies()) == 1
assert fb.title == "Avatar"
assert "Žánr/Sci-Fi" in {t.full_path for t in fb.tags}
+416 -39
View File
@@ -1,24 +1,34 @@
"""Tests for series naming/scanning (src.core.series) + FileManager episodes."""
import pytest
from src.core.series import (
Episode,
collect_episode_files,
episode_filename,
is_canonical_episode,
is_season_folder,
parse_episode,
parse_episode_code,
parse_episode_part,
sanitize_title,
scan_series,
season_folder_name,
series_folder_stem,
)
@pytest.mark.parametrize("name,expected", [
("Breaking Bad S01E02.mkv", (1, 2)),
("bb.s1e2.mkv", (1, 2)),
("Show 01x02 x264.mkv", (1, 2)),
("Series.S01E02E03.mkv", (1, 2)), # multi-ep → first
("Series S12E145.mkv", (12, 145)),
("No code here.mkv", None),
])
@pytest.mark.parametrize(
"name,expected",
[
("Breaking Bad S01E02.mkv", (1, 2)),
("bb.s1e2.mkv", (1, 2)),
("Show 01x02 x264.mkv", (1, 2)),
("Series.S01E02E03.mkv", (1, 2)), # multi-ep → first
("Series S12E145.mkv", (12, 145)),
("No code here.mkv", None),
],
)
def test_parse_episode(name, expected):
assert parse_episode(name) == expected
@@ -29,30 +39,112 @@ def test_season_folder_name():
assert season_folder_name(12) == "Season 12"
def test_episode_filename_keeps_original_name():
assert episode_filename(1, 2, "bb.pilot.hdtv", ".mkv") == "S01E02 - bb.pilot.hdtv.mkv"
# already-canonical stem → no doubled prefix
assert episode_filename(1, 2, "S01E02 - bb.pilot.hdtv", ".mkv") == \
"S01E02 - bb.pilot.hdtv.mkv"
# stem that was only the code → collapses to just the code
assert episode_filename(3, 4, "S03E04", ".mkv") == "S03E04.mkv"
def test_episode_filename_is_code_plus_series_title():
assert episode_filename(1, 2, "Most!", ".mkv") == "S01E02 - Most!.mkv"
assert episode_filename(0, 3, "Most!", ".mkv") == "S00E03 - Most!.mkv"
# release string is NOT kept — only the code + series title
assert (
episode_filename(12, 145, "Breaking Bad", ".mkv")
== "S12E145 - Breaking Bad.mkv"
)
# empty / unusable title → just the code
assert episode_filename(3, 4, "", ".mkv") == "S03E04.mkv"
assert episode_filename(3, 4, "/", ".mkv") == "S03E04.mkv"
def test_sanitize_title():
assert sanitize_title("Most!") == "Most!"
assert sanitize_title("A: B / C D") == "A B C D"
assert sanitize_title(" trailing. ") == "trailing"
def test_is_season_folder():
assert is_season_folder("Season 01")
assert is_season_folder("Season 1")
assert is_season_folder("Specials")
assert not is_season_folder("Extras")
assert not is_season_folder("Season")
def test_series_folder_stem():
assert series_folder_stem("Breaking Bad", 2008, 1396) == "Breaking Bad (2008) [tmdbid-1396]"
assert (
series_folder_stem("Breaking Bad", 2008, 1396)
== "Breaking Bad (2008) [tmdbid-1396]"
)
assert series_folder_stem("Neznámý", None, 42) == "Neznámý [tmdbid-42]"
assert series_folder_stem("Jen název", None, None) == "Jen název"
@pytest.mark.parametrize(
"name,expected",
[
("Show S01E02.pt2.mkv", 2),
("Show S01E01-part-1.mkv", 1),
("Show S01E01 part 1.mkv", 1),
("Show S01E01_cd1.mkv", 1),
("Show S01E01.disc2.mkv", 2),
("Show S01E01.mkv", None),
("Show Abcd2.mkv", None), # "cd2" mid-word — not a stacking suffix
("Show Part of Something.mkv", None), # no digit right after → not a match
],
)
def test_parse_episode_part(name, expected):
assert parse_episode_part(name) == expected
@pytest.mark.parametrize(
"text,expected",
[
("S01E02P3", (1, 2, 3)),
("s1e2p3", (1, 2, 3)),
("S01E02 pt3", (1, 2, 3)),
("S01E02.part.3", (1, 2, 3)),
("S01E02 cd2", (1, 2, 2)),
("S01E02", (1, 2, None)),
("1x02", (1, 2, None)),
("no code here", None),
],
)
def test_parse_episode_code(text, expected):
assert parse_episode_code(text) == expected
def test_episode_filename_with_part():
assert episode_filename(1, 2, "Most!", ".mkv", part=3) == "S01E02 - Most!.pt3.mkv"
assert episode_filename(1, 2, "", ".mkv", part=2) == "S01E02.pt2.mkv"
# no part → unchanged from the plain form
assert episode_filename(1, 2, "Most!", ".mkv", part=None) == "S01E02 - Most!.mkv"
def test_episode_code_property_includes_part():
assert Episode(None, 1, 2, part=3).code == "S01E02.pt3"
assert Episode(None, 1, 2).code == "S01E02"
assert Episode(None, None, None).code == "?"
def test_is_canonical_episode(tmp_path):
folder = tmp_path / "Breaking Bad (2008) [tmdbid-1396]" / "Season 01"
folder.mkdir(parents=True)
good = folder / "S01E02 - bb.pilot.mkv"
good = folder / "S01E02 - Breaking Bad.mkv"
good.write_bytes(b"x")
bad = folder / "bb.s01e02.mkv"
bad.write_bytes(b"x")
assert is_canonical_episode(good)
assert not is_canonical_episode(bad)
wrong_body = folder / "S01E02 - old release.mkv"
wrong_body.write_bytes(b"x")
assert is_canonical_episode(good, "Breaking Bad")
assert not is_canonical_episode(bad, "Breaking Bad")
assert not is_canonical_episode(wrong_body, "Breaking Bad")
def test_is_canonical_episode_with_part(tmp_path):
folder = tmp_path / "Show (2008) [tmdbid-1]" / "Season 01"
folder.mkdir(parents=True)
good = folder / "S01E02 - Show.pt1.mkv"
good.write_bytes(b"x")
old_style = folder / "S01E02-part-1.mkv" # recognised, but not our own form
old_style.write_bytes(b"x")
assert is_canonical_episode(good, "Show")
assert not is_canonical_episode(old_style, "Show")
def test_scan_series(tmp_path):
@@ -73,13 +165,51 @@ def test_scan_series(tmp_path):
assert list(seasons.keys()) == [1] and len(seasons[1]) == 2
def test_scan_series_reads_episode_parts(tmp_path):
lib = tmp_path / "Seriály"
s1 = lib / "Show (2008) [tmdbid-1]" / "Season 01"
s1.mkdir(parents=True)
(s1 / "Show S01E02.pt1.mkv").write_bytes(b"x")
(s1 / "Show S01E02.pt2.mkv").write_bytes(b"x")
ser = scan_series(lib, "Seriály")[0]
parts = sorted(ep.part for ep in ser.episodes)
assert parts == [1, 2]
assert {ep.code for ep in ser.episodes} == {"S01E02.pt1", "S01E02.pt2"}
def test_collect_episode_files_walks_subfolders(tmp_path):
root = tmp_path / "Breaking.Bad.COMPLETE"
(root / "Season 01").mkdir(parents=True)
(root / "Season 02").mkdir(parents=True)
(root / "Season 01" / "BB S01E01.mkv").write_bytes(b"x")
(root / "Season 01" / "BB S01E02.mkv").write_bytes(b"x")
(root / "Season 01" / "BB S01E02.srt").write_bytes(b"x") # sidecar → skipped
(root / "Season 02" / "BB S02E01.avi").write_bytes(b"x")
(root / "readme.nfo").write_bytes(b"x") # sidecar → skipped
files = collect_episode_files(root)
assert [f.name for f in files] == [
"BB S01E01.mkv",
"BB S01E02.mkv",
"BB S02E01.avi",
]
def test_collect_episode_files_missing_folder_is_empty(tmp_path):
assert collect_episode_files(tmp_path / "nope") == []
class TestFileManagerSeries:
@pytest.fixture
def file_manager(self, tmp_path, monkeypatch):
import src.core.config as config_module
monkeypatch.setattr(config_module, "GLOBAL_CONFIG_FILE", tmp_path / ".gtag")
from src.core.tag_manager import TagManager
from src.core.file_manager import FileManager
from src.core.tag_manager import TagManager
fm = FileManager(TagManager())
fm.set_pool_dir(tmp_path / "pool")
return fm
@@ -89,24 +219,42 @@ class TestFileManagerSeries:
(tmp_path / n).write_bytes(b"x")
result = file_manager.import_episodes(
[tmp_path / "bb.S01E01.mkv", tmp_path / "bb.S01E02.mkv"],
"Breaking Bad", year=2008, tmdb_id=1396)
"Breaking Bad",
year=2008,
tmdb_id=1396,
)
assert result["imported"] == 2
base = (tmp_path / "pool" / "Seriály"
/ "Breaking Bad (2008) [tmdbid-1396]" / "Season 01")
assert (base / "S01E01 - bb.mkv").exists()
assert (base / "S01E02 - bb.mkv").exists()
base = (
tmp_path
/ "pool"
/ "Seriály"
/ "Breaking Bad (2008) [tmdbid-1396]"
/ "Season 01"
)
assert (base / "S01E01 - Breaking Bad.mkv").exists()
assert (base / "S01E02 - Breaking Bad.mkv").exists()
def test_import_episodes_into_mixed_library(self, file_manager, tmp_path):
file_manager.set_libraries([{"name": "Hlavní", "kind": "mixed"}])
(tmp_path / "bb.S01E01.mkv").write_bytes(b"x")
result = file_manager.import_episodes(
[tmp_path / "bb.S01E01.mkv"], "Breaking Bad", year=2008, tmdb_id=1396,
library="Hlavní")
[tmp_path / "bb.S01E01.mkv"],
"Breaking Bad",
year=2008,
tmdb_id=1396,
library="Hlavní",
)
assert result["imported"] == 1
assert (tmp_path / "pool" / "Hlavní" / "Breaking Bad (2008) [tmdbid-1396]"
/ "Season 01" / "S01E01 - bb.mkv").exists()
assert (
tmp_path
/ "pool"
/ "Hlavní"
/ "Breaking Bad (2008) [tmdbid-1396]"
/ "Season 01"
/ "S01E01 - Breaking Bad.mkv"
).exists()
# a mixed library is scanned for series too
found = file_manager.load_series()
assert [s.title for s in found] == ["Breaking Bad"]
@@ -114,14 +262,173 @@ class TestFileManagerSeries:
def test_import_episodes_skips_uncoded(self, file_manager, tmp_path):
(tmp_path / "random.mkv").write_bytes(b"x")
result = file_manager.import_episodes(
[tmp_path / "random.mkv"], "Breaking Bad", year=2008)
[tmp_path / "random.mkv"], "Breaking Bad", year=2008
)
assert result["imported"] == 0
assert result["skipped_no_code"] == ["random.mkv"]
def test_remove_series_deletes_the_folder(self, file_manager, tmp_path):
(tmp_path / "bb.S01E01.mkv").write_bytes(b"x")
(tmp_path / "bb.S02E01.mkv").write_bytes(b"x")
file_manager.import_episodes(
[tmp_path / "bb.S01E01.mkv", tmp_path / "bb.S02E01.mkv"],
"Breaking Bad",
year=2008,
tmdb_id=1396,
)
(ser,) = file_manager.load_series()
assert ser.folder.is_dir()
file_manager.remove_series(ser)
assert not ser.folder.exists()
assert file_manager.load_series() == []
def test_remove_series_refuses_folder_outside_pool(self, file_manager, tmp_path):
from src.core.series import Series
outside = tmp_path / "not-the-pool" / "Show (2020)"
outside.mkdir(parents=True)
bogus = Series(outside, "Show", "2020", None, "Seriály")
with pytest.raises(ValueError):
file_manager.remove_series(bogus)
assert outside.is_dir() # untouched
def test_remove_episodes_deletes_files_and_subtitles(self, file_manager, tmp_path):
(tmp_path / "bb.S01E01.mkv").write_bytes(b"x")
(tmp_path / "bb.S01E02.mkv").write_bytes(b"x")
file_manager.import_episodes(
[tmp_path / "bb.S01E01.mkv", tmp_path / "bb.S01E02.mkv"],
"Breaking Bad",
year=2008,
tmdb_id=1396,
)
(ser,) = file_manager.load_series()
season = ser.seasons()[1]
e1 = next(e for e in season if e.episode == 1)
e2 = next(e for e in season if e.episode == 2)
srt = e1.path.with_suffix(".srt")
srt.write_bytes(b"x")
file_manager.remove_episodes([e1])
assert not e1.path.exists()
assert not srt.exists()
assert e2.path.exists() # untouched
assert e1.path.parent.is_dir() # not empty — e2 is still there
def test_remove_episodes_cleans_up_the_now_empty_season_folder(
self, file_manager, tmp_path
):
(tmp_path / "bb.S01E01.mkv").write_bytes(b"x")
file_manager.import_episodes(
[tmp_path / "bb.S01E01.mkv"], "Breaking Bad", year=2008, tmdb_id=1396
)
(ser,) = file_manager.load_series()
(ep,) = ser.episodes
season_folder = ep.path.parent
file_manager.remove_episodes([ep])
assert not ep.path.exists()
assert not season_folder.exists()
def test_remove_season_deletes_every_episode_in_it(self, file_manager, tmp_path):
(tmp_path / "bb.S01E01.mkv").write_bytes(b"x")
(tmp_path / "bb.S01E02.mkv").write_bytes(b"x")
(tmp_path / "bb.S02E01.mkv").write_bytes(b"x")
file_manager.import_episodes(
[
tmp_path / "bb.S01E01.mkv",
tmp_path / "bb.S01E02.mkv",
tmp_path / "bb.S02E01.mkv",
],
"Breaking Bad",
year=2008,
tmdb_id=1396,
)
(ser,) = file_manager.load_series()
season1_folder = ser.seasons()[1][0].path.parent
file_manager.remove_season(ser, 1)
assert not season1_folder.exists()
(ser_after,) = file_manager.load_series()
assert [ep.season for ep in ser_after.episodes] == [2]
def test_remove_season_raises_for_a_season_with_no_episodes(
self, file_manager, tmp_path
):
(tmp_path / "bb.S01E01.mkv").write_bytes(b"x")
file_manager.import_episodes(
[tmp_path / "bb.S01E01.mkv"], "Breaking Bad", year=2008, tmdb_id=1396
)
(ser,) = file_manager.load_series()
with pytest.raises(ValueError):
file_manager.remove_season(ser, 9)
def test_import_episodes_uses_explicit_codes_override(self, file_manager, tmp_path):
uncoded = tmp_path / "pilot.mkv"
uncoded.write_bytes(b"x")
misparsed = tmp_path / "bb 2008 e07.mkv" # "2008" would parse as 20x08
misparsed.write_bytes(b"x")
result = file_manager.import_episodes(
[uncoded, misparsed],
"Breaking Bad",
year=2008,
tmdb_id=1396,
codes={uncoded: (1, 1), misparsed: (1, 7)},
)
assert result["imported"] == 2
base = (
tmp_path
/ "pool"
/ "Seriály"
/ "Breaking Bad (2008) [tmdbid-1396]"
/ "Season 01"
)
assert (base / "S01E01 - Breaking Bad.mkv").exists()
assert (base / "S01E07 - Breaking Bad.mkv").exists()
assert result["skipped_no_code"] == []
def test_import_episodes_uses_explicit_part_override(self, file_manager, tmp_path):
tape1 = tmp_path / "tape1.mkv"
tape1.write_bytes(b"x")
tape2 = tmp_path / "tape2.mkv"
tape2.write_bytes(b"x")
result = file_manager.import_episodes(
[tape1, tape2],
"Breaking Bad",
year=2008,
tmdb_id=1396,
codes={tape1: (1, 1, 1), tape2: (1, 1, 2)},
)
assert result["imported"] == 2
base = (
tmp_path
/ "pool"
/ "Seriály"
/ "Breaking Bad (2008) [tmdbid-1396]"
/ "Season 01"
)
assert (base / "S01E01 - Breaking Bad.pt1.mkv").exists()
assert (base / "S01E01 - Breaking Bad.pt2.mkv").exists()
def test_import_episodes_reads_part_from_filename(self, file_manager, tmp_path):
tape = tmp_path / "Show.S01E01.pt2.mkv"
tape.write_bytes(b"x")
result = file_manager.import_episodes([tape], "Show", year=2020, tmdb_id=5)
base = tmp_path / "pool" / "Seriály" / "Show (2020) [tmdbid-5]" / "Season 01"
assert (base / "S01E01 - Show.pt2.mkv").exists()
assert result["imported"] == 1
def test_rename_episodes_to_canonical(self, file_manager, tmp_path):
# a mis-named episode sitting in the wrong place
folder = (tmp_path / "pool" / "Seriály"
/ "Breaking Bad (2008) [tmdbid-1396]")
folder = tmp_path / "pool" / "Seriály" / "Breaking Bad (2008) [tmdbid-1396]"
folder.mkdir(parents=True)
(folder / "bb.1x03.HDTV.mkv").write_bytes(b"x")
@@ -129,20 +436,90 @@ class TestFileManagerSeries:
assert len(series_list) == 1
result = file_manager.rename_episodes_to_canonical(series_list[0])
assert result["renamed"] == [("bb.1x03.HDTV.mkv", "S01E03 - bb.HDTV.mkv")]
assert (folder / "Season 01" / "S01E03 - bb.HDTV.mkv").exists()
assert result["renamed"] == [("bb.1x03.HDTV.mkv", "S01E03 - Breaking Bad.mkv")]
assert (folder / "Season 01" / "S01E03 - Breaking Bad.mkv").exists()
def test_apply_series_edits_renames_folder_and_episodes(self, file_manager, tmp_path):
def test_rename_episodes_to_canonical_keeps_the_part_tag(
self, file_manager, tmp_path
):
folder = tmp_path / "pool" / "Seriály" / "Breaking Bad (2008) [tmdbid-1396]"
folder.mkdir(parents=True)
(folder / "bb.1x03.HDTV-part2.mkv").write_bytes(b"x")
result = file_manager.rename_episodes_to_canonical(
file_manager.load_series()[0]
)
assert result["renamed"] == [
("bb.1x03.HDTV-part2.mkv", "S01E03 - Breaking Bad.pt2.mkv")
]
assert (folder / "Season 01" / "S01E03 - Breaking Bad.pt2.mkv").exists()
def test_rename_episodes_to_canonical_carries_subtitle_along(
self, file_manager, tmp_path
):
folder = tmp_path / "pool" / "Seriály" / "Breaking Bad (2008) [tmdbid-1396]"
folder.mkdir(parents=True)
(folder / "bb.1x03.HDTV.mkv").write_bytes(b"x")
(folder / "bb.1x03.HDTV.cs.srt").write_bytes(b"x")
result = file_manager.rename_episodes_to_canonical(
file_manager.load_series()[0]
)
assert result["renamed"] == [("bb.1x03.HDTV.mkv", "S01E03 - Breaking Bad.mkv")]
assert (folder / "Season 01" / "S01E03 - Breaking Bad.cs.srt").exists()
assert not (folder / "bb.1x03.HDTV.cs.srt").exists()
def test_rename_all_series_to_canonical_bulk(self, file_manager, tmp_path):
seri = tmp_path / "pool" / "Seriály"
# series A: mis-named episodes in a non-padded season folder
(seri / "Fargo (2014) [tmdbid-60622]" / "Season 1").mkdir(parents=True)
(
seri
/ "Fargo (2014) [tmdbid-60622]"
/ "Season 1"
/ "fargo.1x01.1080p.WEB.mkv"
).write_bytes(b"x")
# series B: one good, one without a code
(seri / "Most! (2019) [tmdbid-90000]" / "Season 01").mkdir(parents=True)
(
seri / "Most! (2019) [tmdbid-90000]" / "Season 01" / "S01E02 - Most!.mkv"
).write_bytes(b"x")
(
seri / "Most! (2019) [tmdbid-90000]" / "Season 01" / "necoZvlastniho.mkv"
).write_bytes(b"x")
result = file_manager.rename_all_series_to_canonical()
assert result["series"] == 2
assert ("Fargo", "fargo.1x01.1080p.WEB.mkv", "S01E01 - Fargo.mkv") in result[
"renamed"
]
assert (
seri / "Fargo (2014) [tmdbid-60622]" / "Season 01" / "S01E01 - Fargo.mkv"
).exists()
# emptied non-padded season folder swept
assert not (seri / "Fargo (2014) [tmdbid-60622]" / "Season 1").exists()
# the already-canonical one is untouched, the code-less one is reported
assert ("Most!", "necoZvlastniho.mkv") in result["skipped_no_code"]
assert (
seri / "Most! (2019) [tmdbid-90000]" / "Season 01" / "S01E02 - Most!.mkv"
).exists()
def test_apply_series_edits_renames_folder_and_episodes(
self, file_manager, tmp_path
):
folder = tmp_path / "pool" / "Seriály" / "Wrong Name"
(folder / "Season 01").mkdir(parents=True)
(folder / "Season 01" / "old S01E01.mkv").write_bytes(b"x")
ser = file_manager.load_series()[0]
result = file_manager.apply_series_edits(
ser, title="Breaking Bad", year=2008, tmdb_id=1396)
ser, title="Breaking Bad", year=2008, tmdb_id=1396
)
new_folder = (tmp_path / "pool" / "Seriály"
/ "Breaking Bad (2008) [tmdbid-1396]")
new_folder = tmp_path / "pool" / "Seriály" / "Breaking Bad (2008) [tmdbid-1396]"
assert result["folder"] == new_folder
assert (new_folder / "Season 01" / "S01E01 - old.mkv").exists()
assert (new_folder / "Season 01" / "S01E01 - Breaking Bad.mkv").exists()
assert not folder.exists()
+170
View File
@@ -0,0 +1,170 @@
"""Tests for external subtitle sidecars (src.core.subtitles)."""
import pytest
from src.core.subtitles import (
SubtitleFile,
attach_subtitle,
find_subtitles,
rename_subtitles_with,
subtitle_filename,
)
def _video(tmp_path, name="Matrix (1999) [tmdbid-603].mkv"):
v = tmp_path / name
v.write_bytes(b"x")
return v
class TestSubtitleFilename:
def test_bare_no_language(self):
assert subtitle_filename("Matrix (1999)", ".srt") == "Matrix (1999).srt"
def test_with_language(self):
assert (
subtitle_filename("Matrix (1999)", ".srt", "cs") == "Matrix (1999).cs.srt"
)
def test_with_language_and_flag(self):
assert (
subtitle_filename("Matrix (1999)", ".srt", "cs", "forced")
== "Matrix (1999).cs.forced.srt"
)
def test_uppercase_input_is_lowered(self):
assert (
subtitle_filename("Matrix (1999)", ".SRT", "CS", "FORCED")
== "Matrix (1999).cs.forced.srt"
)
class TestFindSubtitles:
def test_finds_bare_language_and_flagged(self, tmp_path):
video = _video(tmp_path)
(tmp_path / "Matrix (1999) [tmdbid-603].srt").write_bytes(b"x")
(tmp_path / "Matrix (1999) [tmdbid-603].cs.srt").write_bytes(b"x")
(tmp_path / "Matrix (1999) [tmdbid-603].en.forced.srt").write_bytes(b"x")
found = {s.path.name: s for s in find_subtitles(video)}
assert set(found) == {
"Matrix (1999) [tmdbid-603].srt",
"Matrix (1999) [tmdbid-603].cs.srt",
"Matrix (1999) [tmdbid-603].en.forced.srt",
}
assert found["Matrix (1999) [tmdbid-603].srt"].language is None
assert found["Matrix (1999) [tmdbid-603].cs.srt"].language == "cs"
assert found["Matrix (1999) [tmdbid-603].en.forced.srt"].language == "en"
assert found["Matrix (1999) [tmdbid-603].en.forced.srt"].flag == "forced"
def test_ignores_unrelated_and_overlapping_stem(self, tmp_path):
video = _video(tmp_path, "Matrix.mkv")
(tmp_path / "Matrix Reloaded.srt").write_bytes(b"x") # overlapping prefix
(tmp_path / "Matrix.nfo").write_bytes(b"x") # not a subtitle ext
(tmp_path / "poster.jpg").write_bytes(b"x")
assert find_subtitles(video) == []
def test_ignores_other_movies_subtitle(self, tmp_path):
video = _video(tmp_path, "Matrix.mkv")
(tmp_path / "Other Movie.cs.srt").write_bytes(b"x")
assert find_subtitles(video) == []
def test_missing_folder_is_empty(self, tmp_path):
assert find_subtitles(tmp_path / "nope" / "video.mkv") == []
def test_label_property(self):
assert SubtitleFile(path=None, language="cs").label == "cs"
assert (
SubtitleFile(path=None, language="cs", flag="forced").label == "cs (forced)"
)
assert SubtitleFile(path=None).label == "?"
class TestAttachSubtitle:
def test_copies_and_names_to_match_video(self, tmp_path):
video = _video(tmp_path)
source = tmp_path / "downloaded.srt"
source.write_bytes(b"subtitle content")
target = attach_subtitle(video, source, language="cs")
assert target == tmp_path / "Matrix (1999) [tmdbid-603].cs.srt"
assert target.read_bytes() == b"subtitle content"
assert source.exists() # copy, not move
def test_move_removes_source(self, tmp_path):
video = _video(tmp_path)
source = tmp_path / "downloaded.srt"
source.write_bytes(b"x")
target = attach_subtitle(video, source, language="en", move=True)
assert target.exists()
assert not source.exists()
def test_overwrites_same_language_slot(self, tmp_path):
video = _video(tmp_path)
old = tmp_path / "Matrix (1999) [tmdbid-603].cs.srt"
old.write_bytes(b"old")
source = tmp_path / "new.srt"
source.write_bytes(b"new")
target = attach_subtitle(video, source, language="cs")
assert target == old
assert target.read_bytes() == b"new"
def test_rejects_unsupported_extension(self, tmp_path):
video = _video(tmp_path)
source = tmp_path / "notes.txt"
source.write_bytes(b"x")
with pytest.raises(ValueError):
attach_subtitle(video, source)
class TestRenameSubtitlesWith:
def test_moves_every_sidecar_preserving_lang_and_flag(self, tmp_path):
old_video = _video(tmp_path, "Matrix.mkv")
(tmp_path / "Matrix.srt").write_bytes(b"x")
(tmp_path / "Matrix.cs.srt").write_bytes(b"x")
(tmp_path / "Matrix.en.forced.srt").write_bytes(b"x")
new_video = tmp_path / "Matrix (1999) [tmdbid-603].mkv"
moved = rename_subtitles_with(old_video, new_video)
names = {p.name for p in moved}
assert names == {
"Matrix (1999) [tmdbid-603].srt",
"Matrix (1999) [tmdbid-603].cs.srt",
"Matrix (1999) [tmdbid-603].en.forced.srt",
}
for name in names:
assert (tmp_path / name).exists()
assert not (tmp_path / "Matrix.srt").exists()
def test_moves_across_folders(self, tmp_path):
old_dir = tmp_path / "Filmy"
old_dir.mkdir()
old_video = old_dir / "Matrix.mkv"
old_video.write_bytes(b"x")
(old_dir / "Matrix.cs.srt").write_bytes(b"x")
new_dir = tmp_path / "Kids"
new_dir.mkdir()
new_video = new_dir / "Matrix (1999).mkv"
moved = rename_subtitles_with(old_video, new_video)
assert moved == [new_dir / "Matrix (1999).cs.srt"]
assert (new_dir / "Matrix (1999).cs.srt").exists()
assert not (old_dir / "Matrix.cs.srt").exists()
def test_noop_when_path_unchanged(self, tmp_path):
video = _video(tmp_path, "Matrix.mkv")
(tmp_path / "Matrix.cs.srt").write_bytes(b"x")
assert rename_subtitles_with(video, video) == []
assert (tmp_path / "Matrix.cs.srt").exists()
def test_no_subtitles_is_noop(self, tmp_path):
old_video = _video(tmp_path, "Matrix.mkv")
new_video = tmp_path / "Matrix (1999).mkv"
assert rename_subtitles_with(old_video, new_video) == []
-1
View File
@@ -1,4 +1,3 @@
import pytest
from src.core.tag import Tag
+3 -2
View File
@@ -1,6 +1,7 @@
import pytest
from src.core.tag_manager import TagManager, DEFAULT_TAGS
from src.core.tag import Tag
from src.core.tag_manager import DEFAULT_TAGS, TagManager
class TestTagManager:
@@ -55,7 +56,7 @@ class TestTagManager:
def test_add_tag_creates_category(self, tag_manager):
"""Test že add_tag vytvoří kategorii pokud neexistuje"""
tag = tag_manager.add_tag("NovaKategorie", "Tag")
tag_manager.add_tag("NovaKategorie", "Tag")
assert "NovaKategorie" in tag_manager.tags_by_category
def test_add_multiple_tags_same_category(self, tag_manager):
+91 -14
View File
@@ -1,4 +1,5 @@
"""Tests for the TMDb client (src.core.tmdb). Network is monkeypatched."""
from types import SimpleNamespace
import pytest
@@ -13,8 +14,9 @@ def _with_key(monkeypatch):
def _resp(status=200, payload=None):
return SimpleNamespace(status_code=status, ok=(200 <= status < 300),
json=lambda: (payload or {}))
return SimpleNamespace(
status_code=status, ok=(200 <= status < 300), json=lambda: payload or {}
)
def test_api_available_reflects_key(monkeypatch):
@@ -24,36 +26,56 @@ def test_api_available_reflects_key(monkeypatch):
def test_lookup_movie_returns_title_and_year(monkeypatch):
monkeypatch.setattr(tmdb.requests, "get", lambda *a, **k: _resp(
200, {"id": 603, "title": "Matrix", "release_date": "1999-03-31"}))
monkeypatch.setattr(
tmdb.requests,
"get",
lambda *a, **k: _resp(
200, {"id": 603, "title": "Matrix", "release_date": "1999-03-31"}
),
)
result = tmdb.lookup_movie(603)
assert result == TmdbResult(603, "Matrix", "1999", "movie")
def test_lookup_movie_falls_back_to_original_title(monkeypatch):
monkeypatch.setattr(tmdb.requests, "get", lambda *a, **k: _resp(
200, {"id": 1, "original_title": "Solyaris", "release_date": "1972-01-01"}))
monkeypatch.setattr(
tmdb.requests,
"get",
lambda *a, **k: _resp(
200, {"id": 1, "original_title": "Solyaris", "release_date": "1972-01-01"}
),
)
assert tmdb.lookup_movie(1).title == "Solyaris"
def test_lookup_movie_missing_date_gives_no_year(monkeypatch):
monkeypatch.setattr(tmdb.requests, "get", lambda *a, **k: _resp(
200, {"id": 2, "title": "Bez data", "release_date": ""}))
monkeypatch.setattr(
tmdb.requests,
"get",
lambda *a, **k: _resp(200, {"id": 2, "title": "Bez data", "release_date": ""}),
)
assert tmdb.lookup_movie(2).year is None
def test_lookup_tv_uses_name_and_first_air(monkeypatch):
monkeypatch.setattr(tmdb.requests, "get", lambda *a, **k: _resp(
200, {"id": 1396, "name": "Breaking Bad", "first_air_date": "2008-01-20"}))
monkeypatch.setattr(
tmdb.requests,
"get",
lambda *a, **k: _resp(
200, {"id": 1396, "name": "Breaking Bad", "first_air_date": "2008-01-20"}
),
)
result = tmdb.lookup(1396, kind="tv")
assert result == TmdbResult(1396, "Breaking Bad", "2008", "tv")
def test_search_movie_maps_results(monkeypatch):
payload = {"results": [
{"id": 603, "title": "Matrix", "release_date": "1999-03-31"},
{"id": 605, "title": "Matrix Revolutions", "release_date": "2003-11-05"},
]}
payload = {
"results": [
{"id": 603, "title": "Matrix", "release_date": "1999-03-31"},
{"id": 605, "title": "Matrix Revolutions", "release_date": "2003-11-05"},
]
}
monkeypatch.setattr(tmdb.requests, "get", lambda *a, **k: _resp(200, payload))
results = tmdb.search_movie("Matrix", year="1999")
assert [r.tmdb_id for r in results] == [603, 605]
@@ -63,10 +85,64 @@ def test_search_movie_maps_results(monkeypatch):
def test_search_movie_empty_query_skips_network(monkeypatch):
def boom(*a, **k):
raise AssertionError("should not hit network")
monkeypatch.setattr(tmdb.requests, "get", boom)
assert tmdb.search_movie(" ") == []
def test_movie_details_maps_fields(monkeypatch):
payload = {
"id": 603,
"title": "Matrix",
"release_date": "1999-03-31",
"vote_average": 8.3,
"genres": [
{"id": 28, "name": "Akční"},
{"id": 878, "name": "Vědeckofantastický"},
],
"production_countries": [
{"iso_3166_1": "US", "name": "United States of America"}
],
}
monkeypatch.setattr(tmdb.requests, "get", lambda *a, **k: _resp(200, payload))
d = tmdb.movie_details(603)
assert d.genres == ["Akční", "Vědeckofantastický"]
assert d.countries == ["United States of America"]
assert d.rating == 83 and d.year == "1999"
assert d.field_values("genres") == ["Akční", "Vědeckofantastický"]
assert d.field_values("rating") == ["83"]
def test_refresh_tags_from_tmdb(tmp_path, monkeypatch):
monkeypatch.setattr("src.core.config.GLOBAL_CONFIG_FILE", tmp_path / ".gtag")
from src.core.file_manager import FileManager
from src.core.tag_manager import TagManager
from src.core.tmdb import TmdbDetails
fm = FileManager(TagManager())
fm.set_pool_dir(tmp_path / "pool")
(tmp_path / "a.mkv").write_bytes(b"x")
(tmp_path / "b.mkv").write_bytes(b"y")
matched = fm.import_movie(tmp_path / "a.mkv", "Matrix", year=1999, tmdb_id=603)
fm.import_movie(tmp_path / "b.mkv", "Bez ID", year=2000) # no tmdbid
import src.core.tmdb as tmdb_mod
monkeypatch.setattr(
tmdb_mod,
"movie_details",
lambda i: TmdbDetails(
int(i), "Matrix", "1999", ["Akční", "Sci-Fi"], ["USA"], 83
),
)
result = fm.refresh_tags_from_tmdb()
assert result == {"ok": True, "updated": 1, "without_id": 1, "errors": []}
paths = {t.full_path for t in matched.tags}
assert {"Žánr/Akční", "Žánr/Sci-Fi", "Rok/1999", "Země původu/USA"} <= paths
assert matched.meta == {"year": "1999", "rating": 83}
def test_missing_key_raises(monkeypatch):
monkeypatch.delenv("TMDB_API_KEY", raising=False)
with pytest.raises(TmdbError):
@@ -84,6 +160,7 @@ def test_network_error_raises(monkeypatch):
def boom(*a, **k):
raise requests.ConnectionError("down")
monkeypatch.setattr(tmdb.requests, "get", boom)
with pytest.raises(TmdbError):
tmdb.lookup_movie(603)
+4 -2
View File
@@ -1,5 +1,7 @@
import pytest
from pathlib import Path
import pytest
from src.core.utils import list_files
@@ -171,7 +173,7 @@ class TestUtils:
files = list_files(temp_dir)
# Najdeme soubor v subdir2
file5 = [f for f in files if f.name == "file5.txt"][0]
file5 = next(f for f in files if f.name == "file5.txt")
# Cesta by měla obsahovat obě složky
assert "subdir1" in str(file5)
Generated
+763
View File
@@ -0,0 +1,763 @@
version = 1
revision = 5
requires-python = ">=3.14"
resolution-markers = [
"python_full_version >= '3.15'",
"python_full_version < '3.15'",
]
[[package]]
name = "altgraph"
version = "0.17.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/f8/97fdf103f38fed6792a1601dbc16cc8aac56e7459a9fff08c812d8ae177a/altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7", size = 48428, upload-time = "2025-11-21T20:35:50.583Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" },
]
[[package]]
name = "ast-serialize"
version = "0.12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c2/1c/7257e6ec9382843915ce475558ce4492ccb5ed39122c256bb369c27e2ebf/ast_serialize-0.12.1.tar.gz", hash = "sha256:5285a390caf1c44368ae270f037f797b91427d138b7d43cad0f1fda4c83518d9", size = 954408, upload-time = "2026-10-03T12:25:00.221Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/f7/e976169da322c009bb083a52d21e88fbfe5f071e1806e8c8361ab4ac477a/ast_serialize-0.12.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:e73255c9227fd74eac8a9b55c4049e8ad7b66d1f690bf827c98a86b2e594def7", size = 897232, upload-time = "2026-10-03T12:23:21.945Z" },
{ url = "https://files.pythonhosted.org/packages/e1/89/5545f6f4d38dd41b4e2a20050967ccd722508bc90fab0dfba463d8c8b994/ast_serialize-0.12.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:4655ef993e69e01bb47d2d99647de9bbb74af03938438656832cd010d95de348", size = 1235397, upload-time = "2026-10-03T12:23:23.835Z" },
{ url = "https://files.pythonhosted.org/packages/22/19/e9b839ef9b57626e15e20dd7cf764a9a6b50f9750f86d0a49bc3a971fb72/ast_serialize-0.12.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bac5a99a2c91dd823be9b8c44645694fccbb0750773cc5b27889a9f6f22fce89", size = 1216331, upload-time = "2026-10-03T12:23:25.557Z" },
{ url = "https://files.pythonhosted.org/packages/26/2a/d054d4ff8ba42472a22e3da6eb6dee0e69a32c477b5077eefdbada99f554/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6485e681625ed7a094221f16a7ff2ef154946112266a05cf83bde50c959ef345", size = 1281194, upload-time = "2026-10-03T12:23:27.349Z" },
{ url = "https://files.pythonhosted.org/packages/7e/0c/c73eddfa180a7a4c1613c0f3d3ef020b05dca9b922ac08212463c33ad11f/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a513bc6f60980d01767f7cbe39b17ce0373e722824a74ce28d6cea49ee3c8460", size = 1287170, upload-time = "2026-10-03T12:23:29.333Z" },
{ url = "https://files.pythonhosted.org/packages/94/77/39dc75d8b718844859b64a9067c9df0cfce218ca45ea215fb24a1fda3cf7/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:82866f3523d53ffca8d2a69a750bec52908b69f728012e40959bebce2620453c", size = 1531238, upload-time = "2026-10-03T12:23:30.954Z" },
{ url = "https://files.pythonhosted.org/packages/b9/c0/6a6a6f94f45a288c4bac2eb8379a3d9654574a0f9249380ce3b07f6d64bb/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:243054a05a5190f5d087b5c8b16423e7f1cefa4991bac26e9c8ace18074b75b6", size = 1307154, upload-time = "2026-10-03T12:23:32.645Z" },
{ url = "https://files.pythonhosted.org/packages/f9/3d/80f843892bd0f7c0d95ec5422ba3dc315c1ce011e6f08b06d5f71bd82c25/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d9fbe5a3e8acddfc2fddff3dbbc7ea0e9798b3df3428f851b8abc52a3806f31", size = 1301506, upload-time = "2026-10-03T12:23:34.63Z" },
{ url = "https://files.pythonhosted.org/packages/df/cc/49a5fe852706f545e3e005584c5be89456bc637a8c9179aeaa8b9f26e8e4/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:d3d516da3463071d27e64caf54d88cba25cf4ad4afcc807e0bcf67743719f03e", size = 1297616, upload-time = "2026-10-03T12:23:36.377Z" },
{ url = "https://files.pythonhosted.org/packages/49/5c/1208c91d6e00cc43cc276bd6233c40c9b4ec3ef8537c83281dd5372cbdb8/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d6711adf11136c77e3a35517de9488a5081d1012874fae99c2876b64f4daace", size = 1354884, upload-time = "2026-10-03T12:23:38.035Z" },
{ url = "https://files.pythonhosted.org/packages/a9/80/2b5fc912ff0be64d8d61ff5dc7dc405c6311297a0e2039b848b7d14333f2/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cbe239bee4bd609186daf60b95b7b0f47146c7f7f55f6da83807d747d6fe753f", size = 1457911, upload-time = "2026-10-03T12:23:39.679Z" },
{ url = "https://files.pythonhosted.org/packages/b2/f8/d720429bf8933efbd0cc2038c0a50b6267a585d503500845c44bc6c8ff66/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:5bbf582286c9dc6b4c544ef645dc99e4b3aa09db28892bc60344141f6926641f", size = 1563023, upload-time = "2026-10-03T12:23:41.585Z" },
{ url = "https://files.pythonhosted.org/packages/7e/0a/99e6cc92bdbae5db60f84a14a0fb1ae77b6087e451d77808d87558162c9a/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:99e33c93efb5254a70c525b46038212371dfe5693d48eb2d0d5f17d936a263d7", size = 1556601, upload-time = "2026-10-03T12:23:43.361Z" },
{ url = "https://files.pythonhosted.org/packages/7a/05/59de9e16a2e333da534f30776d0f5e426034b64c67c17843425e3cc827d1/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:aa6c17a2b7f07e81fa8cfcc4aa7c832b3e57733853aebea113ab502f9b0963db", size = 1665039, upload-time = "2026-10-03T12:23:45.257Z" },
{ url = "https://files.pythonhosted.org/packages/33/83/35ed67a127167b484b42a071df440f84b14c0d20ea8f69dbed5cc96bfd98/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f896fa38e0af38821e1ab1425c5dee89e359623e165765bdeae7d0eb6909e76d", size = 1472263, upload-time = "2026-10-03T12:23:46.811Z" },
{ url = "https://files.pythonhosted.org/packages/c4/b0/3ab8613bbb690297f1bb687d780a248c486df0f4131b6a82044fcb49e438/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:af699e81fd7ce80b8b03945826d8ea23dd36d072f10d4613402da597ba4ee9c6", size = 1499242, upload-time = "2026-10-03T12:23:48.531Z" },
{ url = "https://files.pythonhosted.org/packages/9d/a0/a28894d3b06f8775cea8989f371bd9f72ce562c32bc807770e7fb920ce17/ast_serialize-0.12.1-cp314-cp314t-win32.whl", hash = "sha256:10b59afc108eb285146acb23d1b5ec0fc58bb3c09cb2ab8876402df06c373c3b", size = 1119488, upload-time = "2026-10-03T12:23:50.419Z" },
{ url = "https://files.pythonhosted.org/packages/c1/b2/0c44952f4ba4e14bb7f60a5858e2960dfefb9884e6ff007aaf64337dba5c/ast_serialize-0.12.1-cp314-cp314t-win_amd64.whl", hash = "sha256:72e871f6995a066c1b19104f8a6b5832b1163adb9a8267c2aa4711fbb0f4d1f3", size = 1157434, upload-time = "2026-10-03T12:23:52.383Z" },
{ url = "https://files.pythonhosted.org/packages/1f/1e/cb594c63f46a01d53629af1c4f9e42cd02afcea1c2fe483e12f22743ebac/ast_serialize-0.12.1-cp314-cp314t-win_arm64.whl", hash = "sha256:3398e458047d21c9bc1b323fe5aab77c608dc9ddb65b2d44deebaff503a1f1eb", size = 1129094, upload-time = "2026-10-03T12:23:54.133Z" },
{ url = "https://files.pythonhosted.org/packages/16/05/ca16884f9498386f3646bb18be59f0e31d44e992d252d7d6f5e4f8ae1ee2/ast_serialize-0.12.1-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:410233de149ab8414cb27c6fc73e9d2baa35d6f971672d540d752060d980ffb4", size = 1235686, upload-time = "2026-10-03T12:23:55.863Z" },
{ url = "https://files.pythonhosted.org/packages/29/f2/34e87ed30e292cf365523712c4bcfef1967d9c3c2749de21b1f93b1fe0f3/ast_serialize-0.12.1-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:b9a2310845302f1a6bd45ae8a67d5760211103a8d66410b854bfa440d107e093", size = 1215598, upload-time = "2026-10-03T12:23:57.48Z" },
{ url = "https://files.pythonhosted.org/packages/f8/dc/c498f41c957b6ff31b97ed8ceccf3a84f85af7debca1125183cab95bb58b/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ff65f40f49d5e1a1a043ba366081d59a4e26a9f5c1b07eb1170e172115da7ca", size = 1281911, upload-time = "2026-10-03T12:23:58.954Z" },
{ url = "https://files.pythonhosted.org/packages/dc/60/70ccefae9d88058c4c234bf0aed93f54aca36eb74087736e76e9515aee96/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:536d783c4d91331f094e0a892221e619be5ffbe6fb6640885f14d7f226ec90ca", size = 1287296, upload-time = "2026-10-03T12:24:00.429Z" },
{ url = "https://files.pythonhosted.org/packages/08/e9/4fc697879c7128e29f9dab2ed19a9b586a56b621e5ea4aee2ae28c18e116/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c42d2d65f388d1960c5796231eb9bf5a988c46228633eb489605c4549ad16c52", size = 1532339, upload-time = "2026-10-03T12:24:02.053Z" },
{ url = "https://files.pythonhosted.org/packages/8a/9e/9e2bd489731602a94dbd0c576ebe1cc487a2d0f6127be743f44711166f0d/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a5628a12acc875fe7a167910f18d101dd101c2a7b1e6c2b6f7289ffaff25805c", size = 1308988, upload-time = "2026-10-03T12:24:03.61Z" },
{ url = "https://files.pythonhosted.org/packages/3f/69/e9cae837bd766a66db6953ffb5fc7f04b1945e02b0a9e4c6a0b6acb08f17/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e855adfa5bb982b2e6fe09056b2d584f6dd4fce085d91a07d1155683751b6b5", size = 1303412, upload-time = "2026-10-03T12:24:05.62Z" },
{ url = "https://files.pythonhosted.org/packages/b2/1e/5ef8c62d5031d93187ed0d8dade5d942de9920c3fbd678c7652362b9a7a2/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:fafe1471e8aca6c87b4913b7b54ff97197adf702fbe28692284b929dfa62ff96", size = 1298466, upload-time = "2026-10-03T12:24:07.242Z" },
{ url = "https://files.pythonhosted.org/packages/c2/f3/25ded60844a1a437edc840e597b6f81daf91dc4a26035416e14298d3a091/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a5cac246474d2a703147d1605a6ac5ba0fa9e0a443cf1cf42513adf4df02686f", size = 1355617, upload-time = "2026-10-03T12:24:09.125Z" },
{ url = "https://files.pythonhosted.org/packages/05/68/a0d3cc8d8042208a2cbef7b26483f4941b44dd5dd717bb19f20e4a4c0d66/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6e25cd319fb0d7b39fcac666784ec86708ccbc78d07a698b1400cf5ed40c045b", size = 1458949, upload-time = "2026-10-03T12:24:10.772Z" },
{ url = "https://files.pythonhosted.org/packages/df/a0/5e4d355c48a9f125b8bec7b1b98d4d2dcd8324ff1d4dfcb03678a03c1414/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:657a7354ea16ed4d29f8127ed477c6fee3915c111d135f020ca835a991438e90", size = 1562328, upload-time = "2026-10-03T12:24:12.404Z" },
{ url = "https://files.pythonhosted.org/packages/8a/5f/3d40f6a7908200f2f0ed9ce1bad130d00e06d0405baa7918d4a4299b25dd/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:9eb9de7e59621acdb3e66984f374272b33d56d15a04763e2fd0604211e1c8303", size = 1557751, upload-time = "2026-10-03T12:24:14.455Z" },
{ url = "https://files.pythonhosted.org/packages/3f/13/d53e5a7e299d6dbaeab23a424eea2c98c821b46ed7b05abbe14743beeb63/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:09cc4d3103c1fc97f6845ba307af1db9cde5226bef47f8843220dde83f2276ba", size = 1666238, upload-time = "2026-10-03T12:24:16.171Z" },
{ url = "https://files.pythonhosted.org/packages/37/5b/7638ee3ae35a64e4467160a37dc7565cddfe2a87f06cef2fd07c93cfd503/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:c9e2a592706fd791c2271ce9c8f4e38c98d3ea0b4a86b511e09a4fe3ac44ab37", size = 1472620, upload-time = "2026-10-03T12:24:18.053Z" },
{ url = "https://files.pythonhosted.org/packages/fc/e7/6e9e621e0e4a3be5a9a5f8b6961982964d2367013dbe64feb5adaa43e56d/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ac042e95a575432c1730ca4cb9183066a2074886a599f46c1f1b0955fb8198", size = 1500141, upload-time = "2026-10-03T12:24:20.036Z" },
{ url = "https://files.pythonhosted.org/packages/4a/4f/3217da5b671711c09cc6be580095839cad539983662a6599405bd75c1a19/ast_serialize-0.12.1-cp315-abi3.abi3t-win32.whl", hash = "sha256:b3cd105995942cc6163a229674a86161ba1305f646493efd59bd6723a357ee14", size = 1118382, upload-time = "2026-10-03T12:24:21.599Z" },
{ url = "https://files.pythonhosted.org/packages/80/1e/6074cf29dca8ceff27d50e845c88e7a2eaaa7f0b6f3972909e878d844737/ast_serialize-0.12.1-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:a9cd24a26126088693ca054547ea0a391398a29cf1a3a2bec1009b4b6acc8b82", size = 1158034, upload-time = "2026-10-03T12:24:23.316Z" },
{ url = "https://files.pythonhosted.org/packages/92/a0/81ce428f9f3f1ca45f8b62c9711c30452bf8190476e8685cea0f72d8d008/ast_serialize-0.12.1-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:9649cd903db0dc047906c6dd740784a2ba665d54f7e43ba31457edbce76c9493", size = 1129437, upload-time = "2026-10-03T12:24:25.044Z" },
{ url = "https://files.pythonhosted.org/packages/3a/d9/1c08adb90728607d0d07d188df4558ae863d688b4458087efe9fafeca458/ast_serialize-0.12.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:5ef62601db3ce5c23445132262a193075e211fb2fc87b46b7550dd351fac0976", size = 897419, upload-time = "2026-10-03T12:24:26.699Z" },
{ url = "https://files.pythonhosted.org/packages/80/fb/1eabd2c0673283054468b1c6cb539aeb877636d6c84b280279f2d7a177a9/ast_serialize-0.12.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:98d91cd3a6cb76a39512ee090a539d1e3206b732ad8150eb38918cffa1ddf515", size = 1241515, upload-time = "2026-10-03T12:24:28.493Z" },
{ url = "https://files.pythonhosted.org/packages/1b/d7/c56955934a431a0fa3e4e9aa7af4a53ceab2a61241005427545208945eb4/ast_serialize-0.12.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:8a32f184ce3e4b1d0b06d642a1243281cf55b99e0323680b1b8f904029fd7700", size = 1228048, upload-time = "2026-10-03T12:24:30.556Z" },
{ url = "https://files.pythonhosted.org/packages/1e/4e/2b2ca4602baf92f842316ea617423402089df4fbd2ea42571ba28725ba46/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e62126ac2be2d9340ac1b3ee7a0466a883ecbed634cff0929c88ca0b671483b7", size = 1292087, upload-time = "2026-10-03T12:24:32.45Z" },
{ url = "https://files.pythonhosted.org/packages/d9/49/9ebd05218a87ca31f4f855d5e3df14239bba3c58f2aed9d02c7cba5d94f5/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0f93a70fa9826c04ea9f2c3a880f87f4cca09a828144ed5084682abd28110980", size = 1295355, upload-time = "2026-10-03T12:24:34.423Z" },
{ url = "https://files.pythonhosted.org/packages/dc/09/6db7c4327e7a56aba805f7190d377a159fc0bf6bdefb410dc7860624dfa3/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1858887be56a64a2aea899423dfe43787c34c75c18b0d7497de8e618d54b2790", size = 1541231, upload-time = "2026-10-03T12:24:36.05Z" },
{ url = "https://files.pythonhosted.org/packages/ed/85/7ab6097e5fe23cd4657b0e5a2fabb4f789f91e441a3ee40b3ca8b79be238/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:89a2bc39a820bc7785b60c53a5742b4e8dd4c1a599294e2dd68fae545883d44a", size = 1317324, upload-time = "2026-10-03T12:24:37.737Z" },
{ url = "https://files.pythonhosted.org/packages/c0/60/58961e7fd129e226ce36788fe328d20034f3105f5d3380df690050517737/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50a9eaedf1db4857dad7cc47dd757ed70bfcc40b89d44d516c4a2f0d5033bd76", size = 1311809, upload-time = "2026-10-03T12:24:39.306Z" },
{ url = "https://files.pythonhosted.org/packages/15/c7/09d973db87d4575cba470fd80a3fa489322f7d6882546e43a4b21012468e/ast_serialize-0.12.1-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:c30b609e8fea426b310543126de876592236a25aa8ebd59f1e2b323dd52a4085", size = 1306678, upload-time = "2026-10-03T12:24:40.891Z" },
{ url = "https://files.pythonhosted.org/packages/84/27/84f69c22bcdaa5256b4fe43ff972fc117668fff8e68495807d5792eadcce/ast_serialize-0.12.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b1ad06513022cfa1337744959255af0ef16119d2beb1e547b67f37ad9433d4a", size = 1362009, upload-time = "2026-10-03T12:24:42.809Z" },
{ url = "https://files.pythonhosted.org/packages/43/46/76ee342ef22cd6d82ccd6089d5e2f7163246de73d1816ccb4b6ec0550db6/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6add54b495e37ae3cf3a1f0d5eaba364814eb72e93026adc41b7791e4b0d45d3", size = 1467959, upload-time = "2026-10-03T12:24:44.496Z" },
{ url = "https://files.pythonhosted.org/packages/31/4d/18e48154bbf6058eed8d9b54fcebb2e130f8a380db1a2a202b4faac48626/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:5fc136cd08001b817ad0b3e7426f50a7d2b8982dc7c6491f0af78af4c3dd8672", size = 1571157, upload-time = "2026-10-03T12:24:46.156Z" },
{ url = "https://files.pythonhosted.org/packages/34/76/6b16ddf0510e713613a5f5441b13407c1bde6158df04946b9f1fdc65add3/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:45a9e6b700bbd973d49942668a5cdbeffa693f2e250b8a5409abd1fa9d351854", size = 1566873, upload-time = "2026-10-03T12:24:48.542Z" },
{ url = "https://files.pythonhosted.org/packages/75/33/9f6169ae7f60c2da4baec03450073d3f1edb39e95ab538be0d25a7d2f72e/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:a1d8267f83c613ea0a31f2518df074bd62e98a4b3a4892f6a529d74e08e02dba", size = 1673030, upload-time = "2026-10-03T12:24:50.566Z" },
{ url = "https://files.pythonhosted.org/packages/11/51/0d78755bd61d6cf8980f0cfdc7fa8ede38df46a5423c9f7a3da0cff587ec/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:9a0cbab9796e6ce841197feeba4008faa96b4cc7741129542fd81c882d7a4f01", size = 1480052, upload-time = "2026-10-03T12:24:52.277Z" },
{ url = "https://files.pythonhosted.org/packages/01/ae/ad4c0e5129991f2761f388420c5ded37cb134ec5882e3e59043d33c1ad87/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b4282695f1d51a3c6ef76560351bad5af880eff7d755aefea325bffb9bf68c25", size = 1508940, upload-time = "2026-10-03T12:24:53.846Z" },
{ url = "https://files.pythonhosted.org/packages/38/6b/3299182794d38815ae6e9c7ede9bb8f2e4aa93c3578bed201c1ea746643a/ast_serialize-0.12.1-cp39-abi3-win32.whl", hash = "sha256:119d1b0cadaba4a6e475f9bbe79eecc79351e373142eabe7c353f0250d70aebb", size = 1125618, upload-time = "2026-10-03T12:24:55.405Z" },
{ url = "https://files.pythonhosted.org/packages/86/14/5d4fb733c18a1d69e237c067b183842f3a7ea1c999a51ddc87093a281c88/ast_serialize-0.12.1-cp39-abi3-win_amd64.whl", hash = "sha256:3d6ed63d4fc1ec867b8cb522d58c36df0e8f05e487bea0ffd102043a37636d72", size = 1165798, upload-time = "2026-10-03T12:24:57.052Z" },
{ url = "https://files.pythonhosted.org/packages/f1/f4/b54123680025c0b7253117418f023d1b2487f1102552acbdd9d8ee96b622/ast_serialize-0.12.1-cp39-abi3-win_arm64.whl", hash = "sha256:610a41351de68199de9a1434499083b4256c0df7658ec1cfc0a0a7b20b08d317", size = 1136560, upload-time = "2026-10-03T12:24:58.689Z" },
]
[[package]]
name = "beautifulsoup4"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "soupsieve" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" },
]
[[package]]
name = "certifi"
version = "2026.7.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.5.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/ca/7aa91362a2f77ac8e9e28a9b902a74f7d0e11a851ef0d27a74308da8cd90/charset_normalizer-3.5.2-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187", size = 226010, upload-time = "2026-09-30T04:36:27.669Z" },
{ url = "https://files.pythonhosted.org/packages/a8/cf/ac8878d0322cf88a1aad4c7b147db32ca0bd806eb0060957b2e31486dbe6/charset_normalizer-3.5.2-cp314-cp314-android_24_x86_64.whl", hash = "sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad", size = 239266, upload-time = "2026-09-30T04:36:29.434Z" },
{ url = "https://files.pythonhosted.org/packages/c9/6d/9a08d7e0b29b7208e2c6c01dc56c8e0520e7c7beadbbfb024b58fd69c8a5/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf", size = 206783, upload-time = "2026-09-30T04:36:30.872Z" },
{ url = "https://files.pythonhosted.org/packages/82/44/b0aa350280e6ff5a5492d17cf10460dd39d5ee848f872f7ba2df10607f60/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995", size = 210449, upload-time = "2026-09-30T04:36:32.625Z" },
{ url = "https://files.pythonhosted.org/packages/7c/8a/40db9aa9f5907bb0e6f8b6d64064bf8852fb33d4b813ff9414911df7647c/charset_normalizer-3.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424", size = 367815, upload-time = "2026-09-30T04:36:34.197Z" },
{ url = "https://files.pythonhosted.org/packages/7f/72/9c5e7707b57c8ddfa9ddf7b0b1d009d7fbab9e9e887d5b721060f37e307d/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13", size = 247095, upload-time = "2026-09-30T04:36:35.803Z" },
{ url = "https://files.pythonhosted.org/packages/83/09/71e453691e927de4ddf792770cfaab3f49d494e222f66ea5e404bbd5e39c/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d", size = 233942, upload-time = "2026-09-30T04:36:37.407Z" },
{ url = "https://files.pythonhosted.org/packages/9f/86/85c84e4da8b27dd409577d9437926ff581c5f9d3c66038dc68c1a526de51/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4", size = 273762, upload-time = "2026-09-30T04:36:38.904Z" },
{ url = "https://files.pythonhosted.org/packages/92/08/564955a4b5f2ccb410ab480bbe8c6a18063ff27f2d35458731c4a5335df9/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438", size = 269514, upload-time = "2026-09-30T04:36:40.469Z" },
{ url = "https://files.pythonhosted.org/packages/18/24/bad3ac4271589df29cf5ce2f5ae490518a5739358052bd0d61209e6fea54/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a", size = 255056, upload-time = "2026-09-30T04:36:42.02Z" },
{ url = "https://files.pythonhosted.org/packages/d6/3e/350d89ad49916b86554d6f5f2d03ec1152148f87e5ff735106c6a03b1a36/charset_normalizer-3.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56", size = 251311, upload-time = "2026-09-30T04:36:43.577Z" },
{ url = "https://files.pythonhosted.org/packages/56/5b/4970a2d154df502e133402906dd04e3ae7cada7b3011283c88d0479a2585/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd", size = 250190, upload-time = "2026-09-30T04:36:45.185Z" },
{ url = "https://files.pythonhosted.org/packages/88/8c/f1a91bddc8fb47c2889e29ea7ea49a194eb0d9868675d786806519c00d76/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204", size = 237098, upload-time = "2026-09-30T04:36:46.689Z" },
{ url = "https://files.pythonhosted.org/packages/24/0e/bb5dace3cc7e79068425386a6589c19b5a2ab5fefc2a46abea6919683332/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7", size = 274228, upload-time = "2026-09-30T04:36:48.31Z" },
{ url = "https://files.pythonhosted.org/packages/9d/79/b849ad523017ea9f5a45581bbebed91439e0cf42fd2860a6f64e358eb5a6/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd", size = 252401, upload-time = "2026-09-30T04:36:50.091Z" },
{ url = "https://files.pythonhosted.org/packages/89/8c/75469d690cf47200bce8f6cad7655724fc23148e147abfc5ce78b5f65863/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc", size = 271846, upload-time = "2026-09-30T04:36:51.719Z" },
{ url = "https://files.pythonhosted.org/packages/26/cd/6d52d3c7437cdcf2e310ce9f28f282e733d4ef60ed19105d1819c356255f/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874", size = 258151, upload-time = "2026-09-30T04:36:53.234Z" },
{ url = "https://files.pythonhosted.org/packages/f7/4c/070b38bdb5f49a70199fce923ec0726a49536a63ab262abbfcaaf351110b/charset_normalizer-3.5.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655", size = 143570, upload-time = "2026-09-30T04:36:54.816Z" },
{ url = "https://files.pythonhosted.org/packages/81/84/9ebfc8ed6c8c4fcd8e726ff6bf220cc8deb3966e31dce9be8dd8aa017e64/charset_normalizer-3.5.2-cp314-cp314-win32.whl", hash = "sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0", size = 186412, upload-time = "2026-09-30T04:36:56.643Z" },
{ url = "https://files.pythonhosted.org/packages/d1/78/5ed86f743d4bc350db307e7636419a0a5ee1d91806d30c7f667bd5c80dae/charset_normalizer-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c", size = 210965, upload-time = "2026-09-30T04:36:58.205Z" },
{ url = "https://files.pythonhosted.org/packages/53/94/a3a7698e9b1a395e1eb99ccd9a324be9347973bff4e72db2a06496d7cd27/charset_normalizer-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253", size = 200406, upload-time = "2026-09-30T04:36:59.764Z" },
{ url = "https://files.pythonhosted.org/packages/c1/48/c5dd00d5ef7791f02666de250a5bb6071e29b7e133cf4b835800b6d3bc27/charset_normalizer-3.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709", size = 396871, upload-time = "2026-09-30T04:37:01.543Z" },
{ url = "https://files.pythonhosted.org/packages/12/c8/8379554b42e8368161d898476686947a0fdbd3e8865170d7909dcabfdee8/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084", size = 249711, upload-time = "2026-09-30T04:37:03.111Z" },
{ url = "https://files.pythonhosted.org/packages/4a/eb/2ddb1035d17320caa9f41682935123a9a250277b261c3efc86b2d2a21343/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb", size = 231146, upload-time = "2026-09-30T04:37:04.721Z" },
{ url = "https://files.pythonhosted.org/packages/4a/24/2ecb4bde104322cd7859d6594fcfa74649f8d90b3221c9feecbef149875b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f", size = 271552, upload-time = "2026-09-30T04:37:06.295Z" },
{ url = "https://files.pythonhosted.org/packages/3f/98/9d5f6ebc3aee9fef5d30b4aff11fb2ab7a1222b4064f8ef2c7c87cde217a/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09", size = 269810, upload-time = "2026-09-30T04:37:07.905Z" },
{ url = "https://files.pythonhosted.org/packages/09/e1/a3b06a10461b1b7628853c934c644e03bc28e42767116afb52f19a56519b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80", size = 259845, upload-time = "2026-09-30T04:37:09.554Z" },
{ url = "https://files.pythonhosted.org/packages/fd/d3/6f561f74a296cf27d61775a1dc665ad13f3bff6a798810ca05907f37a7c4/charset_normalizer-3.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c", size = 252825, upload-time = "2026-09-30T04:37:11.274Z" },
{ url = "https://files.pythonhosted.org/packages/26/9f/69e13ca3b18f43e0eafcd34c04a45b732ae22a43b54a5fc9e119103356eb/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f", size = 252659, upload-time = "2026-09-30T04:37:12.941Z" },
{ url = "https://files.pythonhosted.org/packages/73/a9/ace29806a0dae18939919c76ba526472d83214afa101105fabff2cf30625/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03", size = 238819, upload-time = "2026-09-30T04:37:14.659Z" },
{ url = "https://files.pythonhosted.org/packages/f8/c1/6116d52a2e3311ec80f21f5fb5e17b27405f10b9608af8f6e69516841a1b/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604", size = 272973, upload-time = "2026-09-30T04:37:16.346Z" },
{ url = "https://files.pythonhosted.org/packages/19/aa/9955c7e93bba10a9c7e8f7a5031b7ced66f3a1883a55c00712b8d5850ff3/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8", size = 253692, upload-time = "2026-09-30T04:37:18.212Z" },
{ url = "https://files.pythonhosted.org/packages/bb/33/2a6ae7fdc1b10cb581cef91addd8cdfc5f40d50abb5702309369d5834579/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93", size = 268757, upload-time = "2026-09-30T04:37:19.877Z" },
{ url = "https://files.pythonhosted.org/packages/a2/22/80992720a0282cd39bba1db35868e6b9c22f41281160143a836544bc1d8a/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915", size = 262220, upload-time = "2026-09-30T04:37:21.583Z" },
{ url = "https://files.pythonhosted.org/packages/92/9f/181fd07e1bffea1d95cd80c84ac537354f50699c22cfc4d3c02b6fc16208/charset_normalizer-3.5.2-cp314-cp314t-win32.whl", hash = "sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5", size = 197797, upload-time = "2026-09-30T04:37:23.235Z" },
{ url = "https://files.pythonhosted.org/packages/49/1c/25d8415ec1c4f2f41f1680435e4c87cfb378ff2f677d950946f2a45d0632/charset_normalizer-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc", size = 222765, upload-time = "2026-09-30T04:37:24.891Z" },
{ url = "https://files.pythonhosted.org/packages/3e/b4/46b48f013dadfc0d0d33b375438e31bdf5a989dc68389c6bf627054d4df9/charset_normalizer-3.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105", size = 210985, upload-time = "2026-09-30T04:37:26.634Z" },
{ url = "https://files.pythonhosted.org/packages/ca/e9/34e597dee616d0b8ee4b34d29399e85c2204ade174157a48505d42baa4ff/charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26", size = 367676, upload-time = "2026-09-30T04:37:28.329Z" },
{ url = "https://files.pythonhosted.org/packages/60/9f/a5d1c91c0263745e2cd344c5a4415d787c575501ab1d449f1148ac6b495d/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364", size = 248414, upload-time = "2026-09-30T04:37:30.167Z" },
{ url = "https://files.pythonhosted.org/packages/26/79/e697f77464748a3ee3cf490c83d592459400d4898380d66c38366b03080c/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253", size = 244775, upload-time = "2026-09-30T04:37:31.964Z" },
{ url = "https://files.pythonhosted.org/packages/ca/87/3d42a42e18ea066e2513936fd678a00696e77878b5ae04528976abdbcb83/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0", size = 273877, upload-time = "2026-09-30T04:37:33.661Z" },
{ url = "https://files.pythonhosted.org/packages/c3/76/8a28136f3938ba9836f84280ce0c4d61ed1cf15a036b2034900c62634162/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc", size = 269455, upload-time = "2026-09-30T04:37:35.573Z" },
{ url = "https://files.pythonhosted.org/packages/a0/a1/4fbf5d0f0f1b2a080474c1cf9a2f12c4c6531bb0e8ba591055e846d2b4e9/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229", size = 257336, upload-time = "2026-09-30T04:37:37.397Z" },
{ url = "https://files.pythonhosted.org/packages/ba/a2/8b50aa320adb880ad579518e6f718f24944804b42a88b83d267d5d444125/charset_normalizer-3.5.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5", size = 257584, upload-time = "2026-09-30T04:37:39.522Z" },
{ url = "https://files.pythonhosted.org/packages/a5/57/50e3fed84e175f40349bd0da7a4fce94c87f0378f52d74f511d89e0bdc20/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98", size = 251854, upload-time = "2026-09-30T04:37:41.23Z" },
{ url = "https://files.pythonhosted.org/packages/d6/54/f7fbb3493c9f49091213b9c2d6dd65800696f1ce1a3f196a4205f50417b1/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3", size = 248767, upload-time = "2026-09-30T04:37:42.883Z" },
{ url = "https://files.pythonhosted.org/packages/d9/37/b3a6385acc5a1e45b39ae9c90bfb9cf838a09b9dd37ef2740ab4c6b4a2eb/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2", size = 274479, upload-time = "2026-09-30T04:37:44.658Z" },
{ url = "https://files.pythonhosted.org/packages/89/44/809913e2cfd279e635a9294fdbbfb1b1dc62a8189d473d561f649fce98d8/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf", size = 258310, upload-time = "2026-09-30T04:37:46.529Z" },
{ url = "https://files.pythonhosted.org/packages/af/a2/f28400ab13359d91bd39179df8e149376b9bf36588e739a3a4f9de2b84b2/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95", size = 271100, upload-time = "2026-09-30T04:37:48.399Z" },
{ url = "https://files.pythonhosted.org/packages/e9/89/9bab37955edf0adb3b66f8a3a6617d9f2f487e0d56f295a6a286cb640aa6/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d", size = 259936, upload-time = "2026-09-30T04:37:50.023Z" },
{ url = "https://files.pythonhosted.org/packages/23/b5/4459e08d45a679f903d50fea08bc52cfa728cca4d7bd02c757b5e5abda2e/charset_normalizer-3.5.2-cp315-cp315-win32.whl", hash = "sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847", size = 186301, upload-time = "2026-09-30T04:37:51.722Z" },
{ url = "https://files.pythonhosted.org/packages/98/e8/55d5fd3935b4bce6da4fe0df61898e8c82653e317e677bd58aceb9c60f13/charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8", size = 210858, upload-time = "2026-09-30T04:37:53.427Z" },
{ url = "https://files.pythonhosted.org/packages/a9/5b/974423c2fd8e524c7a7f64318c1e02240ef954912fa2b4d70344107b9c68/charset_normalizer-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a", size = 200309, upload-time = "2026-09-30T04:37:55.015Z" },
{ url = "https://files.pythonhosted.org/packages/ee/f9/00ee0195db1013d8f7c416fd770fbeb560bb46eb2e36b054d05cb56f6cfa/charset_normalizer-3.5.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1", size = 395343, upload-time = "2026-09-30T04:37:56.743Z" },
{ url = "https://files.pythonhosted.org/packages/04/3a/c00b50e94c964cf934c7899cd47c97952fc11dad71cc5884b3c61795b09b/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b", size = 250114, upload-time = "2026-09-30T04:37:58.607Z" },
{ url = "https://files.pythonhosted.org/packages/50/27/d102dc880bbcffd0479ab64dfc1fb96777a854355a55e2bda72a71efadcb/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f", size = 239997, upload-time = "2026-09-30T04:38:00.511Z" },
{ url = "https://files.pythonhosted.org/packages/a5/4a/bf7ef45794dd293fab5f98a9309817977fbb845b9998f171b8cc5d8437a3/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3", size = 272313, upload-time = "2026-09-30T04:38:02.509Z" },
{ url = "https://files.pythonhosted.org/packages/e8/ee/008a2837737991474c5754bb3191010007663860979701990982a502cbaf/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e", size = 269986, upload-time = "2026-09-30T04:38:04.435Z" },
{ url = "https://files.pythonhosted.org/packages/93/ad/bd74a283940dc910c5b14f8e4f80a248082bc9c0fcbe1f54530cb6d9cc5e/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9", size = 260202, upload-time = "2026-09-30T04:38:06.549Z" },
{ url = "https://files.pythonhosted.org/packages/8a/7b/ed341c66f69f688723501fac752be3d63c7159ca0d0d4174fc611e5710bb/charset_normalizer-3.5.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a", size = 259083, upload-time = "2026-09-30T04:38:08.311Z" },
{ url = "https://files.pythonhosted.org/packages/cc/9d/e41588b777965e5031a43128a1e96173ebb35ac75fc53ec3b517e7c21cd4/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115", size = 253081, upload-time = "2026-09-30T04:38:10.402Z" },
{ url = "https://files.pythonhosted.org/packages/81/35/b761eb6d8c1eb218b9b42b9b4d5ac902afdc399fb6dac6f9a9aac7bda589/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c", size = 248712, upload-time = "2026-09-30T04:38:12.317Z" },
{ url = "https://files.pythonhosted.org/packages/4d/2c/147169a041b747759f37405c0a97157e8e92de967968373101ff14915cba/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d", size = 274176, upload-time = "2026-09-30T04:38:14.138Z" },
{ url = "https://files.pythonhosted.org/packages/f0/2d/0ff8db0d373ba8538db686db11cd7e8912031490b9e4f383b41912e8d594/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d", size = 259413, upload-time = "2026-09-30T04:38:15.841Z" },
{ url = "https://files.pythonhosted.org/packages/8a/8e/b4a085fb47c9d3a7e43576a4784fdd8fe23f907514a972de8086edaf7a48/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4", size = 269434, upload-time = "2026-09-30T04:38:17.626Z" },
{ url = "https://files.pythonhosted.org/packages/83/1c/d8d8d7322a7c3eecdf3237a4a419cf41d2eaad8e006ce7dfdd9d4c8fa2eb/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b", size = 262716, upload-time = "2026-09-30T04:38:19.214Z" },
{ url = "https://files.pythonhosted.org/packages/a0/16/0e4c6ba9b44e97a2da150e52d331e8f9c968b21b358fbffa6c856cebcd89/charset_normalizer-3.5.2-cp315-cp315t-win32.whl", hash = "sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800", size = 197159, upload-time = "2026-09-30T04:38:21.037Z" },
{ url = "https://files.pythonhosted.org/packages/be/33/e90bc2b1374f7f36ef106f56620de5a783907e19ca857efe2277e31cac3e/charset_normalizer-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21", size = 221322, upload-time = "2026-09-30T04:38:22.886Z" },
{ url = "https://files.pythonhosted.org/packages/66/89/dfa6dcb08c200b7830ab56439e8c1890f2971d51aafbb3937894a2e7fcfc/charset_normalizer-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58", size = 209192, upload-time = "2026-09-30T04:38:24.648Z" },
{ url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" },
{ url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" },
{ url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" },
{ url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121, upload-time = "2026-09-30T04:38:31.462Z" },
{ url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350, upload-time = "2026-09-30T04:38:33.239Z" },
{ url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430, upload-time = "2026-09-30T04:38:34.865Z" },
{ url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612, upload-time = "2026-09-30T04:38:36.649Z" },
{ url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083, upload-time = "2026-09-30T04:38:38.26Z" },
{ url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738, upload-time = "2026-09-30T04:38:39.81Z" },
{ url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703, upload-time = "2026-09-30T04:38:41.346Z" },
{ url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622, upload-time = "2026-09-30T04:38:42.937Z" },
{ url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500, upload-time = "2026-09-30T04:38:44.604Z" },
{ url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100, upload-time = "2026-09-30T04:38:46.289Z" },
{ url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499, upload-time = "2026-09-30T04:38:47.999Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092, upload-time = "2026-09-30T04:38:49.707Z" },
{ url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363, upload-time = "2026-09-30T04:38:51.312Z" },
{ url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "curator"
version = "1.13.0"
source = { virtual = "." }
dependencies = [
{ name = "beautifulsoup4" },
{ name = "loguru" },
{ name = "pillow" },
{ name = "pyside6" },
{ name = "python-dotenv" },
{ name = "requests" },
{ name = "truststore" },
]
[package.dev-dependencies]
dev = [
{ name = "mypy" },
{ name = "pyinstaller" },
{ name = "pytest" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "beautifulsoup4", specifier = ">=4.15.0" },
{ name = "loguru", specifier = ">=0.7.3" },
{ name = "pillow", specifier = ">=12.3.0" },
{ name = "pyside6", specifier = ">=6.12.0" },
{ name = "python-dotenv", specifier = ">=1.2.4" },
{ name = "requests", specifier = ">=2.34.2" },
{ name = "truststore", specifier = ">=0.10.4" },
]
[package.metadata.requires-dev]
dev = [
{ name = "mypy", specifier = ">=2.4.0" },
{ name = "pyinstaller", specifier = ">=6.22.3" },
{ name = "pytest", specifier = ">=9.1.1" },
{ name = "ruff", specifier = ">=0.17.0" },
]
[[package]]
name = "idna"
version = "3.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
]
[[package]]
name = "librt"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/f5/9dc696772d241814bacac7880bac32f2930b5a6ebc1f85317b83161a011c/librt-0.16.0.tar.gz", hash = "sha256:ac38d6d8d66bf3d744148dbbc0b8e193e195a51e364ed55e224631f5721891fc", size = 219839, upload-time = "2026-09-29T00:55:32.891Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/93/9e0cf7da129a93c3dc7f45bc3cd4a660f2aaa995aa8a6f95c2583ef41239/librt-0.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:931a0bb0fcac88f263e269e46eb30ba8e21402cd3c62ca40cb97034c0693fab1", size = 149844, upload-time = "2026-09-29T00:53:31.391Z" },
{ url = "https://files.pythonhosted.org/packages/8f/26/8a90d2a8f2b2e471bb486b7aec117b8ae622715ed6c39853aec48ea20073/librt-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1bc17e54e5305f8d40b7ca203671ff5a9e59c1d0f8ea0f625dcca53a3984de11", size = 154111, upload-time = "2026-09-29T00:53:32.718Z" },
{ url = "https://files.pythonhosted.org/packages/35/ce/67abb46258da4d3e42ff5b141db6f38c59357bef84c7979f183b22f924f1/librt-0.16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:877698bf6bca5721d8be345f2fe09778e40ecadea8b58c73075f2b1a53666bf2", size = 494234, upload-time = "2026-09-29T00:53:34.466Z" },
{ url = "https://files.pythonhosted.org/packages/12/f9/ea7162414a16f8f1bbd3b493ad6d22b926c5471916e078350bdbca8c4e5f/librt-0.16.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5981c011b306781ce561e18e14230a14524a3d8109b97553666c942c18f31a96", size = 491173, upload-time = "2026-09-29T00:53:36.124Z" },
{ url = "https://files.pythonhosted.org/packages/b1/09/9b3e869060dd33f9989b80ba4fea306f6db8cecefcdfe6d7346ef0603f65/librt-0.16.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:afced3dfc17cd805ecf7a3d77996a71cf5f2c75aa66eb0c21a9930f4fc992f86", size = 505547, upload-time = "2026-09-29T00:53:37.686Z" },
{ url = "https://files.pythonhosted.org/packages/50/07/79007d2165f649ea93e08c0962d1d62c70af9cde77965255095bf9d96f9a/librt-0.16.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ca8052401c55d7511dda6760719fda7618067e83535d7d0010096d216c34b667", size = 523057, upload-time = "2026-09-29T00:53:39.301Z" },
{ url = "https://files.pythonhosted.org/packages/61/0c/8fbaff66d0ba376d8864653f5acce1569bae27e89648671f26f7eca67ab8/librt-0.16.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1e511762a074005bb0aa569166779834e75e438370226930d0ce1866d4b6a33b", size = 515158, upload-time = "2026-09-29T00:53:41.012Z" },
{ url = "https://files.pythonhosted.org/packages/df/2e/23ff0dece76f07a4124413a57682efa0bbeb5765ac0122bc0955513f82ca/librt-0.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1e8591bd8a5a628cd7f07954c6a1592359a878bf032957a8e9057a41d644311", size = 534183, upload-time = "2026-09-29T00:53:42.451Z" },
{ url = "https://files.pythonhosted.org/packages/26/c4/e11dea21d9a29486eba78887380374189d472734fa32cc50cb37ca44d3d0/librt-0.16.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4aaefb4ba6c07e1aeebb2795c8958148f1d6f9af3b555b53d23d766edb6d67a", size = 540663, upload-time = "2026-09-29T00:53:44.272Z" },
{ url = "https://files.pythonhosted.org/packages/44/75/e873ae158a8b7f5359be33e7fd6c1fbe02d9a78a3e89a77de6b0e837f476/librt-0.16.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d92db7a0f6aee44f1baee94750457e8d2d1c6ccea41842de6268d34e8dc7eddd", size = 545908, upload-time = "2026-09-29T00:53:45.812Z" },
{ url = "https://files.pythonhosted.org/packages/ba/36/8939d3f6a93e11bd9592e6fe28d2b44f1c2dc4bed6e22e72359a91e18ffb/librt-0.16.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:378dfaffb38e59c24a87cde5713cd865d51ff7383fa12947f3907f306ea1ca55", size = 523302, upload-time = "2026-09-29T00:53:47.596Z" },
{ url = "https://files.pythonhosted.org/packages/71/14/35309f44a077f0f42ade0e2e7cd88c0cea760c661af205c01ea90d3c0e1f/librt-0.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3e0c39bdc85370422e8b637be76eb1fd07d30967551b03e62267dd156f553152", size = 565911, upload-time = "2026-09-29T00:53:49.278Z" },
{ url = "https://files.pythonhosted.org/packages/78/0c/df6255b94967f3159ebc46f08d8e783c12da6ee269ddb74b2efed63c640c/librt-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:1b384b90ab79a7bc30b566895809a636e0666f21f3cf12b54823d025b7e83839", size = 79119, upload-time = "2026-09-29T00:53:50.938Z" },
{ url = "https://files.pythonhosted.org/packages/6b/44/d30d5a5461378c9d33f36736c6791c3b4c4ba4b1ffe0da7350aedcb2c9a0/librt-0.16.0-cp314-cp314-win32.whl", hash = "sha256:52327da75a94012e7f932f913d20d3876bed3c102be00e6c3e8600ff7bdd58a7", size = 100140, upload-time = "2026-09-29T00:53:52.205Z" },
{ url = "https://files.pythonhosted.org/packages/c2/98/769712f356a1e897df3581bb0c3100375d054a000de26099360ea65b5111/librt-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:3f0b8114c44b2ac06ff5dacd08e07e8e807ff4f46083f2a1602685122559be41", size = 120464, upload-time = "2026-09-29T00:53:53.495Z" },
{ url = "https://files.pythonhosted.org/packages/bf/d3/ae2abccc8bdc8b063c1613a77686e17b74e9d3d60cfe6f12c63fa2821b9f/librt-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:8caf96a4ef8fb27d0ac0d1ad8337d26a240acd4a02fe4345d0a8f264753e8f99", size = 110889, upload-time = "2026-09-29T00:53:54.817Z" },
{ url = "https://files.pythonhosted.org/packages/e4/26/0737d4be058dd6376eade7dd8b380d4b869b6394cee929393a5a431c45bb/librt-0.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:953107e2f68d0f3512c48f898b0dbf0ce5cc52bba0f318d847c985dc555ee4cc", size = 159838, upload-time = "2026-09-29T00:53:56.22Z" },
{ url = "https://files.pythonhosted.org/packages/01/96/9bc96531d7c620e9949af904470f02e3fa8f35129ab8e8f281c51eaa3788/librt-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad37d5b9abd49c9a655dcda7ea52a8a752884062ef1ee71ae17c2f2a0f81fe6a", size = 162018, upload-time = "2026-09-29T00:53:57.532Z" },
{ url = "https://files.pythonhosted.org/packages/0e/fa/b0289dcb186eb3f97221ba00da5f4bd3ba7fa5e99752d48aa8d336615334/librt-0.16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c4aa329c17bd1aaea4f6e89335d8ccd494b3a5830b6654462273e50e11023f0", size = 703983, upload-time = "2026-09-29T00:53:59.024Z" },
{ url = "https://files.pythonhosted.org/packages/d9/ab/05ebbbde7530fc5eeb58fbd1581522a9f64f132fa703c4c595eed6e14760/librt-0.16.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0ead24d2562a49473dddd9efef8581f020007eb0054389c3ee3ffad38b1ca4c9", size = 683847, upload-time = "2026-09-29T00:54:00.671Z" },
{ url = "https://files.pythonhosted.org/packages/a3/75/f52aeecd4dbadbddf80725ba7de126d8bd5d0eb66247eae17a81ed90dd4d/librt-0.16.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02118f56a9c36ddd07dfd9b919d9ecc117ba20a90987d56aa4c429fa34509188", size = 698045, upload-time = "2026-09-29T00:54:02.247Z" },
{ url = "https://files.pythonhosted.org/packages/e6/55/fa277a835cd6eb42380591ceb85f48c5b4d2b2d7e2cb9869e1e17d24d237/librt-0.16.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e29522c62e28595ff7e324c6834ade51127707f0e255b18d1c1cf03d39c1048", size = 724640, upload-time = "2026-09-29T00:54:04.078Z" },
{ url = "https://files.pythonhosted.org/packages/25/e4/2cf64354f3fde8ebd591b3b48f96510bee24ac5f7d1e567adbbe210abdbd/librt-0.16.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ff4b2367926b69c6215635902cccb04048e73094e9862900d27cb2c6bbff143", size = 731748, upload-time = "2026-09-29T00:54:05.741Z" },
{ url = "https://files.pythonhosted.org/packages/f8/c9/c180af3e94e01aa529fa93d7733fec2abc47f222345400cf21d5481d5f8a/librt-0.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c6f1b27bf1632a7e016af9f145f82be95e1edd7721a646505c21059257cb5a04", size = 754960, upload-time = "2026-09-29T00:54:07.38Z" },
{ url = "https://files.pythonhosted.org/packages/95/d6/01073aa78c58f356b10d9c57b3fe9abb143df338142bcd316df417b89db0/librt-0.16.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:5696d7f52e7b37217cb3a8f92c744fe835942602fdd4c1a8bc4741d3bfdce15e", size = 747000, upload-time = "2026-09-29T00:54:09.06Z" },
{ url = "https://files.pythonhosted.org/packages/f7/d8/1de3783908658d697a8cfc00582f61299ffba7796d7260c112e4700b1109/librt-0.16.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:6072e92dd876ff6ceeb6cf371e35e51f479349837391341f479b08df4564242b", size = 749569, upload-time = "2026-09-29T00:54:10.702Z" },
{ url = "https://files.pythonhosted.org/packages/b7/32/e817f66c96d6caa8bb8435ff93c4c220624d59efc506be59ab98fcd01d0c/librt-0.16.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:39ca4f2f2fe05de8e63493da592d84311adabe5bef52b193851981da9816b302", size = 729483, upload-time = "2026-09-29T00:54:12.25Z" },
{ url = "https://files.pythonhosted.org/packages/7c/c9/23992ccd2b9d22798fdd0f61353183a47414e651da782ad83eb680f50833/librt-0.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9807485a908f00355820f18e91e045ffdcdc5adb68aaec40a1e2b88c5f7bba1", size = 776584, upload-time = "2026-09-29T00:54:13.837Z" },
{ url = "https://files.pythonhosted.org/packages/67/3b/e8af957f08e6e2e8e566b099e43d2748418aa565df6ee1d9d3331209fdfe/librt-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:94be5cb7bca4df6201f4183e9e4fa2086c655283d20b38cd84500a69057575a7", size = 104402, upload-time = "2026-09-29T00:54:15.568Z" },
{ url = "https://files.pythonhosted.org/packages/ca/1c/946e6443d7cd32347a086043395e421e52fd603c9163d2ec970ceab8eed6/librt-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d46ca272b251d033dd4527b0dec5f261a28a52bd5fa0f99c117b0a1f8588cc2d", size = 126227, upload-time = "2026-09-29T00:54:17.165Z" },
{ url = "https://files.pythonhosted.org/packages/c2/a3/bc4f9959d3c62bcbf9e5fd3470a8bbd8bf33224ed4c25d7fba173201b8ac/librt-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b9d6d4b14e92d876f8026b54c20c445f36425214c1081dc76f74e40db386b82b", size = 116493, upload-time = "2026-09-29T00:54:18.567Z" },
{ url = "https://files.pythonhosted.org/packages/b6/4b/10fdb42dfab4c1533e1570e686b18e86ff4328b406361b39fb3016667638/librt-0.16.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:6fe436af2eaf630474f491af5d032cbe45f93fcff5c3b9fe4ab194a7255b20ff", size = 149829, upload-time = "2026-09-29T00:54:19.933Z" },
{ url = "https://files.pythonhosted.org/packages/8e/30/a90ca13f1d3d91af1680000a4038536907018fbd51764767287a05d28b8b/librt-0.16.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:8ff5d26c529336be9bd7ae04483235d77778ee7d6444a95353102b542601ce81", size = 154522, upload-time = "2026-09-29T00:54:21.306Z" },
{ url = "https://files.pythonhosted.org/packages/5c/dd/bcf364eacfa070bb1fc88d503111ae7177914197ba1fa717c748926e7930/librt-0.16.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:909d8e3c1faee44cb762b1c519ff8613dcc5ceae5c99987a00917b5a31fd1d6a", size = 497593, upload-time = "2026-09-29T00:54:22.798Z" },
{ url = "https://files.pythonhosted.org/packages/18/c1/2c4e81e347bdabfe8346bfc6dc37ae5e154d61c88e30848ec0606025a5e0/librt-0.16.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:6d4a64283ee61824b5790de882bc68e2d9d7a5143537cb7a966f7354f71646d4", size = 480459, upload-time = "2026-09-29T00:54:24.364Z" },
{ url = "https://files.pythonhosted.org/packages/ab/d7/fef2a3cb8400701be496f6e459876f650b3451f407e30cb243de571ae615/librt-0.16.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5810ba811297fdf37a1531a57667cb8ace0842013ca8606bf9eb7c24cf4be154", size = 507814, upload-time = "2026-09-29T00:54:25.997Z" },
{ url = "https://files.pythonhosted.org/packages/e1/6f/53762927a32e9dc9eb1d1c1f3528da281290c86d96929a8af652f671266e/librt-0.16.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e56aaf8c167548dc8e5d6f3bd0f48dcdd299a23c73be3f744aab79d99e9c7f5d", size = 525114, upload-time = "2026-09-29T00:54:27.747Z" },
{ url = "https://files.pythonhosted.org/packages/6c/67/0b9d031f303c4e8c691a9a8ef9d272f13b530c11cc563b819df621b6a348/librt-0.16.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f36c58e33b304b525c6c9c5076399c6ebf1109e17b9051a05a407b091b9215b", size = 520465, upload-time = "2026-09-29T00:54:29.331Z" },
{ url = "https://files.pythonhosted.org/packages/ae/d5/2056a3a85864e882eb17a203a10ddb26fa748bc9718ec67e79059ab46cae/librt-0.16.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:242e00b3d4fa37c3d3c1ca5f5c9adb7d909ddb1eac9c41f2787320d00caa0af2", size = 537257, upload-time = "2026-09-29T00:54:30.915Z" },
{ url = "https://files.pythonhosted.org/packages/54/57/e0d79790c163cbc0909e209a6f62e30bb713b647f905a176cdc64848fd4d/librt-0.16.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:0253721561787b8df8443eb347b7a6461015354e5bdd37ee38a41fef220d2bb0", size = 527442, upload-time = "2026-09-29T00:54:32.478Z" },
{ url = "https://files.pythonhosted.org/packages/aa/50/1c0c95aba7af51f4752ea34fbf2eb79b36e7cae3a72536248e8c735de735/librt-0.16.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:3e483a8d69ede8067db70c0e83007423b6925de6fd53afed01d66160f2e9398c", size = 548139, upload-time = "2026-09-29T00:54:34.183Z" },
{ url = "https://files.pythonhosted.org/packages/98/91/a8a43dd5138d4f55f88846b8f0c85454a4fad69952cebfcff9948f831290/librt-0.16.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:69ba927445cfaaffb4081003ef5224c55a5c2ab67ef956f416ef744916e44121", size = 529708, upload-time = "2026-09-29T00:54:35.761Z" },
{ url = "https://files.pythonhosted.org/packages/2d/41/d5226881ab2b7c20d9d587b37bdd4a0ec8775a96d00ca87ac9f385587db4/librt-0.16.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:d6a365f2ab45a984d0e00eee0dd17f599ceab8cadab6ea07b6111c8132fc0e42", size = 567781, upload-time = "2026-09-29T00:54:37.409Z" },
{ url = "https://files.pythonhosted.org/packages/bb/bf/2345ba57a626e8c78c4ddcc724636a8df5a593fe46c1ee76bbf477e32b0d/librt-0.16.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:f01f3805f2dae4781c0c34b440e31740d082950bdaf89a6f601ad589a28af57a", size = 79177, upload-time = "2026-09-29T00:54:38.792Z" },
{ url = "https://files.pythonhosted.org/packages/1e/40/99e77936cc9207f629b077bf7cbc5e2f0827cddd7c29792f04ef881bd2c3/librt-0.16.0-cp315-cp315-win32.whl", hash = "sha256:b0e3e721c75d2e79a76d4422c79d7ba705fe1bbafec907037fe7a657a480a0e3", size = 100127, upload-time = "2026-09-29T00:54:40.251Z" },
{ url = "https://files.pythonhosted.org/packages/56/1e/801fe26bc622061b9dfd010e166d94142cb774b733217e6d98d1c0cf2638/librt-0.16.0-cp315-cp315-win_amd64.whl", hash = "sha256:bc02954b1295de798bbdb0b4e2d8a28c2117de8b5c73dcbeb27dc32572dfb971", size = 120480, upload-time = "2026-09-29T00:54:41.722Z" },
{ url = "https://files.pythonhosted.org/packages/bb/a9/d533983055bd36e112627384c2c038845d1df882540b8bcefb566475640b/librt-0.16.0-cp315-cp315-win_arm64.whl", hash = "sha256:c5db585d43449a5f54303d4b2774e45e1babd975cfe1630a3d708c0b80c3e560", size = 110915, upload-time = "2026-09-29T00:54:43.052Z" },
{ url = "https://files.pythonhosted.org/packages/55/fe/d62238fa9c653b0e0613290349467cb65711b5800e8e474f45e942a0ad95/librt-0.16.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f06c689cb14afd9b612727553a5ec5a40febf113ca41c4413a2b0b334285884b", size = 159714, upload-time = "2026-09-29T00:54:44.497Z" },
{ url = "https://files.pythonhosted.org/packages/f3/ac/f31efe7818700be72ba4f9af8a80fa67c39808c8d26dacc55dc1f6f35172/librt-0.16.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:13b4e8aba90b0b1c82474e9844aa9ffe7ad3faa484350e1da64cb8188d903134", size = 162086, upload-time = "2026-09-29T00:54:45.968Z" },
{ url = "https://files.pythonhosted.org/packages/d9/16/4d7487bf86a9d7e8e18f37ba5538789233ea693637379b75701bd35ec9de/librt-0.16.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a269c46ae327d8e6f8c1f85f7516cb52c0fa48127565a1105a4f4a05ff2a0b4", size = 712727, upload-time = "2026-09-29T00:54:47.486Z" },
{ url = "https://files.pythonhosted.org/packages/77/74/50cd550ccc1a517b9ed62347625c01ddf8c4afad66490312677c011458f6/librt-0.16.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a33e0dae1f8592146a4764d54ce842b278732d21a84e17c3bbe6b1bc158a2248", size = 681106, upload-time = "2026-09-29T00:54:49.126Z" },
{ url = "https://files.pythonhosted.org/packages/df/5d/7293f712975ee6fdd2251411fd9ef1c62bc99c7b83ecbe62dbc999b1fab7/librt-0.16.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:47ada6ea32636492c61aa8ad27ae3b9404bfe7a97e3ba946d1984236cc741da0", size = 705921, upload-time = "2026-09-29T00:54:50.905Z" },
{ url = "https://files.pythonhosted.org/packages/67/f7/8aab946f11d59d1bece9ffc65d994b200c789a8ca5a95c17e17e609f912a/librt-0.16.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43bd6e642d8a248c114327f98dd25ac5a7cb5aa168ef02f0559b91874df16b8", size = 731786, upload-time = "2026-09-29T00:54:52.584Z" },
{ url = "https://files.pythonhosted.org/packages/43/76/1c42ab31e7cb8384ebf6d3af607213c495222474f4940443ae7639ab7685/librt-0.16.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c3d1bb7841a816ace6449bb26d3f9560dbfa20e71c568d23f0f62bf1e68f50b1", size = 744964, upload-time = "2026-09-29T00:54:54.217Z" },
{ url = "https://files.pythonhosted.org/packages/6f/2e/4b19982d933d2dfced671e840b219e4c1cd3f507df6e6dabb51bbd4e3850/librt-0.16.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3931f7a3db322e7f44e02a280e3949326ce9579ad388ee8d691dc7c76da9fb70", size = 765305, upload-time = "2026-09-29T00:54:55.815Z" },
{ url = "https://files.pythonhosted.org/packages/b8/1b/e872583de2dcb3ac7746e7a2321aeeb168274f3952a87dc66e05ddb29faf/librt-0.16.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:f4462528b6000afe8f16907b5c7c2553abf1df005ba5140e6eb394541c3624c3", size = 744959, upload-time = "2026-09-29T00:54:57.532Z" },
{ url = "https://files.pythonhosted.org/packages/10/de/a18c6bcfb297af2674233e90b3c3661c3a0af0f1434a0c966d2ef708a835/librt-0.16.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:80039ba9b6a7d5f1a0175a4cca6bbefead87bd854c80abad1cb30afe47a830db", size = 758138, upload-time = "2026-09-29T00:54:59.484Z" },
{ url = "https://files.pythonhosted.org/packages/22/1c/0df1d732539c297bb1a093e1fe3204d2faf76a9022cf4e1d1c2fce059790/librt-0.16.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:7a1d272724b581bb6bc769dfdafed6da2ecc9886ba2450311de55a4ac2e1e9cd", size = 744268, upload-time = "2026-09-29T00:55:01.179Z" },
{ url = "https://files.pythonhosted.org/packages/a2/f7/ccaf31331f20c91a5bd9bd48ffc3f743f9c81bb5720cc7b2a720ca204f0a/librt-0.16.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:fbe4fb8c5445f7496d7f7f6bb0807875d09d47e6771ffa175fb2df2895fb86ba", size = 785783, upload-time = "2026-09-29T00:55:02.938Z" },
{ url = "https://files.pythonhosted.org/packages/c1/ea/7421d9e6db894cd6cd788b604b3394a163ded6b942052b6f96c23ccf08e1/librt-0.16.0-cp315-cp315t-win32.whl", hash = "sha256:375bfe6b572a8f6cfc398709356046173bf27e64c4c5edaf5f7062f051fb4bf9", size = 104355, upload-time = "2026-09-29T00:55:04.533Z" },
{ url = "https://files.pythonhosted.org/packages/85/6d/7c31a506eb847bc58aeb2402d58e17605e821f68ab5df5e66fe274a6df41/librt-0.16.0-cp315-cp315t-win_amd64.whl", hash = "sha256:bd3150023d3dc2bc70f3784e59ffa1140d56ddba3d8125b3d6f9f85221279bfc", size = 126134, upload-time = "2026-09-29T00:55:06.017Z" },
{ url = "https://files.pythonhosted.org/packages/36/69/7a5d10ac409c4da0355e054a14371871da9b5557fcc42772cd00181c6cce/librt-0.16.0-cp315-cp315t-win_arm64.whl", hash = "sha256:8ceafb70f2a4f0826f11031942e59c0728fd98da112dc346d4352bde1e486866", size = 116391, upload-time = "2026-09-29T00:55:07.484Z" },
]
[[package]]
name = "loguru"
version = "0.7.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "win32-setctime", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" },
]
[[package]]
name = "macholib"
version = "1.16.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "altgraph" },
]
sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117, upload-time = "2025-11-22T08:28:36.939Z" },
]
[[package]]
name = "mypy"
version = "2.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ast-serialize" },
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
{ name = "mypy-extensions" },
{ name = "pathspec" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/34/4e/64300736cf0a0373a27b94a91b664ee7382e36f77b0621bae6381da3e180/mypy-2.4.0.tar.gz", hash = "sha256:77bdaebd452f43fcfc4cc3ba94352a3ea537cd01e3f2d0879f48673d2ec00d6e", size = 4064425, upload-time = "2026-10-01T20:40:39.229Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/66/924be0b653372ed31ad5c48e26044cc00840e591943a56e61621cf05b60e/mypy-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:058165f564ccf559c68c70fec2091fca5891110480210c22594635e3f6683437", size = 14234230, upload-time = "2026-10-01T20:39:44.535Z" },
{ url = "https://files.pythonhosted.org/packages/56/39/c4f176880a4177123576de6cec6309feea8f42fca2bf2f6584e88054f656/mypy-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fa247e02b505a45a2775f69df38d360d197e3790bc60f717595db9eda358b6e", size = 14392944, upload-time = "2026-10-01T20:40:05.78Z" },
{ url = "https://files.pythonhosted.org/packages/bc/1c/26e16977e25ef2494a74f8ffc872a76ec2c3aa43c3156057cbdf88f352c5/mypy-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20e9a5cd875837520c43db98dea0b6d0c2197833d95c30127d8f570fb9b1f00b", size = 15441463, upload-time = "2026-10-01T20:40:32.412Z" },
{ url = "https://files.pythonhosted.org/packages/31/9c/9e4b049f0ecefbfd6817ca2a16eea55dd74a07950268edc6cffd29ccfe98/mypy-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9f03a7828cca2b0adcd6662aee8f2711ff8830e1027641fdea3ab0b787483566", size = 15705406, upload-time = "2026-10-01T20:40:34.844Z" },
{ url = "https://files.pythonhosted.org/packages/4a/5e/e861b5f6c5ef9ee6cd24683aed1edecf82a26dbd536049f9b7850b586267/mypy-2.4.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9279488933040b638c0ab739084c0ca100efeea6db581bf5d7628d8e89de53fe", size = 7897523, upload-time = "2026-10-01T20:39:39.861Z" },
{ url = "https://files.pythonhosted.org/packages/3a/87/61ffee58b25a956532a6006fae84918a2de849ed25f397492b2e815fe7b3/mypy-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:2106b55105ba5ea9be4f53a24517fc5fa927ff1585edc9bc1a975abb72caef89", size = 11576345, upload-time = "2026-10-01T20:39:41.553Z" },
{ url = "https://files.pythonhosted.org/packages/a1/88/a331c20698971c2ce8d1c30f317fd61b5be13a85b1f053e12dfa22ac6568/mypy-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:528c8744b8b5e3ecb8774f86af38d2376216816e9908317ad055f3c9c2d74799", size = 11172062, upload-time = "2026-10-01T20:38:59.534Z" },
{ url = "https://files.pythonhosted.org/packages/47/c0/4f7daa73270dced8e86c6f4a911c68082d03a84e6c1ec9bd916028d66131/mypy-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0bb95cf34899e4619c61ab0a8667804e139e580b30d5df12af2102dfe44d0c97", size = 15711208, upload-time = "2026-10-01T20:39:30.597Z" },
{ url = "https://files.pythonhosted.org/packages/2f/05/f1afa303c678be24cf7a266d38fb24b3de4599a024c2f9ba0d5905a3efa3/mypy-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86d616fe84c6eab8026f8c50ab5bcb90db780d2ccd233d971e34e92bede9b359", size = 15935442, upload-time = "2026-10-01T20:39:22.781Z" },
{ url = "https://files.pythonhosted.org/packages/9d/d6/6a1a45459b63716e0d035f4892a926d3054f0a8cbfa02551d228a9946083/mypy-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3f86fd1313dd69d013e265f1fdcd12ea7a9d606f9875b2a3db946cd334555f3", size = 17240044, upload-time = "2026-10-01T20:39:32.95Z" },
{ url = "https://files.pythonhosted.org/packages/87/85/ae33bee66c13f98d421964d87bf0888be941063bc75c1204a4cf142cf1cc/mypy-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:720434d48542ecfe84d32d287b727569d3fc8f5769acd39051130e490a5c295c", size = 17545856, upload-time = "2026-10-01T20:39:08.827Z" },
{ url = "https://files.pythonhosted.org/packages/df/de/eeff209b65c3e267818d79333bb9f058da6e4e73761461bd94748a2eb628/mypy-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a6e851b82c0661f69f1630fc16172c68787a6a9cf0991e7c6437d60976cdcd76", size = 12369342, upload-time = "2026-10-01T20:40:10.516Z" },
{ url = "https://files.pythonhosted.org/packages/2b/43/e62d8d5c1dd737aa248968302ff7d6eabf997c3301773ed8bcb64932ca77/mypy-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:3bd0e340f0ebe65c548210f53be3fd8192e83964760caf0c28bef368e68b0d37", size = 11849099, upload-time = "2026-10-01T20:39:51.834Z" },
{ url = "https://files.pythonhosted.org/packages/2e/5f/335b8980055118dc131355155883fb676bb2161a0d97e08658e479c776fb/mypy-2.4.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:afa89837d9be67e0cadfa33bca3bb7efdda98c3b07e74dc3b635ebfb1c8a926a", size = 14241302, upload-time = "2026-10-01T20:39:13.227Z" },
{ url = "https://files.pythonhosted.org/packages/18/37/1482fdc49332b145828912b15f16eee0c4ca70a8bce0f6514ece79b14680/mypy-2.4.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb443e81057896132d3642d6be219e6efd158691ac7883e3ba8fcb469865f05d", size = 14408200, upload-time = "2026-10-01T20:38:52.576Z" },
{ url = "https://files.pythonhosted.org/packages/04/09/dce2e8f6c1b31053c430ef6963f6f7a38ccb49b90c5e01e37ed0129b7d5a/mypy-2.4.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7f38f57d344f8b6accb40e01c3d83cfc590498231724d16c07ffb7940f157818", size = 15444409, upload-time = "2026-10-01T20:39:18.1Z" },
{ url = "https://files.pythonhosted.org/packages/43/c5/91b68306da4cd280cb15be35dbb5ffc343cabd67c4b121b6625bf2d13177/mypy-2.4.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e76172710bd4e5eeae061abfd68347e5264632e02778be61784671ae3a2132f5", size = 15731186, upload-time = "2026-10-01T20:40:23.426Z" },
{ url = "https://files.pythonhosted.org/packages/64/71/2d0340182a8f27352fb1e25531355951108b506097c433937e9eec452ffd/mypy-2.4.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:f83353e47ab520bf6fd4df8f5897d9fe081211f2fbc4b7d37736a3e3c166cbcf", size = 7897838, upload-time = "2026-10-01T20:38:50.779Z" },
{ url = "https://files.pythonhosted.org/packages/3e/08/32703c117e134c02efa2a82705bb40cee91eaedea10b6530e20eba651b1f/mypy-2.4.0-cp315-cp315-win_amd64.whl", hash = "sha256:970b221ed5842213d98e3c480c08f795ace4b1f81fb21e1b126bd0476bce1c34", size = 11575700, upload-time = "2026-10-01T20:39:53.982Z" },
{ url = "https://files.pythonhosted.org/packages/ab/08/08bb269feafdaad031046ee2d771528a64467e6a180f962afc28c4a3ccd8/mypy-2.4.0-cp315-cp315-win_arm64.whl", hash = "sha256:502b94b0b331f7dafe32fd6b151797ddbb4f32385b362e722c783a025e5954a3", size = 11172908, upload-time = "2026-10-01T20:40:12.91Z" },
{ url = "https://files.pythonhosted.org/packages/fb/3f/c5c92626006ca92c7686adfbece47fc6a0daa0e54753952a9ad13ce0561c/mypy-2.4.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c9de622fd397495695d0598ddc789222bfcfec9d7c9ec3a1e385c855e3bc5e01", size = 15705970, upload-time = "2026-10-01T20:40:17.79Z" },
{ url = "https://files.pythonhosted.org/packages/10/f3/863365f7997a76a5a1dd42d7902ab05afa4124e8419089cbca1ce2554db1/mypy-2.4.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f459f0b4f0596d9d51fe7716b404b35287b99e77da98a7af90a65dd5fd61141", size = 15942234, upload-time = "2026-10-01T20:40:37.243Z" },
{ url = "https://files.pythonhosted.org/packages/f6/30/2f45b1f425a2c95dbe1a3f4d076bfd42b76e9615dba5906230703b14e4eb/mypy-2.4.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9b028548b3af480e2b1ed8df14ccaac86f99c9f600d1580770ab7ba3dcd40f0", size = 17218364, upload-time = "2026-10-01T20:39:06.497Z" },
{ url = "https://files.pythonhosted.org/packages/43/8b/5b2bbfc69e84800b78fa2dba16d995b003f93b573558e412c5490d6e6c37/mypy-2.4.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:cb734b2668c1f40d07ce093bbeb4407e9527c67901627b0e1679825be3f09975", size = 17557934, upload-time = "2026-10-01T20:38:46.077Z" },
{ url = "https://files.pythonhosted.org/packages/df/c0/1a5dc601c22041a7bbfe1ff71cf097fbbd4fb49930867c6789baf5102106/mypy-2.4.0-cp315-cp315t-win_amd64.whl", hash = "sha256:172e30b8fea631fe310f0c665477f52d9ea40bb4e99e0c81dc30118563b13710", size = 12350955, upload-time = "2026-10-01T20:40:01.52Z" },
{ url = "https://files.pythonhosted.org/packages/1c/bc/697e9e26fc2a86c094ad67ee1e419971b7f01ded66ee66231b09e9f3e12e/mypy-2.4.0-cp315-cp315t-win_arm64.whl", hash = "sha256:5786ef987b3767e51aaa53f20aec104c0252b42ecda7aef8e8b4cbae279b05c5", size = 11842738, upload-time = "2026-10-01T20:40:30.026Z" },
{ url = "https://files.pythonhosted.org/packages/81/12/46ae8670c98a3cd0286ca5645c2f918f8f6be65edfed81b916010619f668/mypy-2.4.0-py3-none-any.whl", hash = "sha256:d01c5d26a352acc6d5cf3128225477e1e8465e8d3029d4c345807fbf7f3cf093", size = 2800734, upload-time = "2026-10-01T20:39:26.837Z" },
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
name = "packaging"
version = "26.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "pathspec"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
]
[[package]]
name = "pefile"
version = "2024.8.26"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/03/4f/2750f7f6f025a1507cd3b7218691671eecfd0bbebebe8b39aa0fe1d360b8/pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632", size = 76008, upload-time = "2024-08-26T20:58:38.155Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" },
]
[[package]]
name = "pillow"
version = "12.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" },
{ url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" },
{ url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" },
{ url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" },
{ url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" },
{ url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" },
{ url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" },
{ url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" },
{ url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" },
{ url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" },
{ url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" },
{ url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" },
{ url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" },
{ url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" },
{ url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" },
{ url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" },
{ url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" },
{ url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" },
{ url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" },
{ url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" },
{ url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" },
{ url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" },
{ url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" },
{ url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" },
{ url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" },
{ url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" },
{ url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" },
{ url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" },
{ url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" },
{ url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" },
{ url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" },
{ url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" },
{ url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" },
{ url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" },
{ url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" },
{ url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" },
{ url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" },
{ url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" },
{ url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" },
{ url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" },
{ url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" },
{ url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pygments"
version = "2.21.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "pyinstaller"
version = "6.22.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "altgraph" },
{ name = "macholib", marker = "sys_platform == 'darwin'" },
{ name = "packaging" },
{ name = "pefile", marker = "sys_platform == 'win32'" },
{ name = "pyinstaller-hooks-contrib" },
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
{ name = "setuptools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/63/41/f90302845945abd4ed647933ff5ee7c6ac93983187be67f897b6cb613331/pyinstaller-6.22.3.tar.gz", hash = "sha256:05eb2f5615503e72939a7224d68b4aff572c6b0438ee4a17d0a4b481f399362d", size = 4437180, upload-time = "2026-09-12T21:54:09.669Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/af/898f70947e27f8e65d55183c780e77112e023c0b4a6fba6255d4d563a27d/pyinstaller-6.22.3-py3-none-macosx_10_13_universal2.whl", hash = "sha256:052f4a1cd4f81092ccb7a18fe8ebc9ecf832a5917317e39fb3775af4e959b253", size = 1071847, upload-time = "2026-09-12T21:53:01.711Z" },
{ url = "https://files.pythonhosted.org/packages/c4/96/99f5184985141c2b024bfa61335e9142ae444555224f303186e59b7bb438/pyinstaller-6.22.3-py3-none-manylinux2014_aarch64.whl", hash = "sha256:312da84b4b31ab9750066a823734c11b7c68757fdd67c9f038bf19c330a954e4", size = 758172, upload-time = "2026-09-12T21:53:06.171Z" },
{ url = "https://files.pythonhosted.org/packages/69/9f/9ccb12cc1e6617042cb028fe79f0a756f5f03305bd14b584a4e043fe3844/pyinstaller-6.22.3-py3-none-manylinux2014_i686.whl", hash = "sha256:020d4fe0627f5c73dfbcfad1a6578ef354d5fe4bdccd9d8e5a615528f5a45d2b", size = 772135, upload-time = "2026-09-12T21:53:10.291Z" },
{ url = "https://files.pythonhosted.org/packages/c1/43/4b1d5193a88628c052db89f064433681076959a9f41d0d9f660001b49079/pyinstaller-6.22.3-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:9e4f7bd64b2103f14b4a6240fb9155e9fe7bc61391b4043bbddb7d3ebc8ab0fa", size = 770461, upload-time = "2026-09-12T21:53:14.592Z" },
{ url = "https://files.pythonhosted.org/packages/cb/83/f173f8757c618d83e116fa2b8e57c5cd796e5ad09792e664eabd6ac5edae/pyinstaller-6.22.3-py3-none-manylinux2014_s390x.whl", hash = "sha256:f82d0fb89f21c3ed6f482ee16f341bc59e8a31dbabb9bae9ef04e53ade4dc0d9", size = 764997, upload-time = "2026-09-12T21:53:19.079Z" },
{ url = "https://files.pythonhosted.org/packages/7b/b6/dd21d61fc5e25377be24eabf13e99e4e46548a5abe2bf7b15d8b3d9cc74a/pyinstaller-6.22.3-py3-none-manylinux2014_x86_64.whl", hash = "sha256:451a4ae14b719365bf1a2f0a99dae7b3463060061c3a394c70d5264cfb439528", size = 764974, upload-time = "2026-09-12T21:53:23.176Z" },
{ url = "https://files.pythonhosted.org/packages/6c/fd/d9590942972b1fc205519f7ccffb0d5cd8eb1d6fa61edad319c71d25baed/pyinstaller-6.22.3-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:ea240b5dfa831ff673c97505eee0ec24670bccf71396c83109440348944c83d0", size = 764450, upload-time = "2026-09-12T21:53:27.419Z" },
{ url = "https://files.pythonhosted.org/packages/72/d6/f22c55d1768433b65c460b19a3136dd9769b6c74b786149b829344654c7d/pyinstaller-6.22.3-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:a2b6fc26601e8c2596c3d54981566934a3382e85d05d0fe91b55b6a8dcc6b282", size = 763863, upload-time = "2026-09-12T21:53:31.609Z" },
{ url = "https://files.pythonhosted.org/packages/c5/87/cf28cfd42ceca889171e6dd5e7f1553d4372db83d047049ffb7cf95fcbd4/pyinstaller-6.22.3-py3-none-win32.whl", hash = "sha256:46a3e30a118341a1ec20667a5a95aa7e0aa8c4fa129893c0151b3eb3f801a722", size = 1390905, upload-time = "2026-09-12T21:53:38.133Z" },
{ url = "https://files.pythonhosted.org/packages/24/83/c5422204772a27aef643280fdc887b7ef55679989096b21dceb1b9625fc4/pyinstaller-6.22.3-py3-none-win_amd64.whl", hash = "sha256:500bd58c7bf7e584a8435adccbd763a0b918d5c12b08d74ff50fd79b2915458b", size = 1454907, upload-time = "2026-09-12T21:53:44.812Z" },
{ url = "https://files.pythonhosted.org/packages/67/2e/ebc93968bf63f81b122e205da116b16036b653b7ce2cc8ad86e3ddd19586/pyinstaller-6.22.3-py3-none-win_arm64.whl", hash = "sha256:31df3cc4261e804a53d358b7897c293ec8b88b167a7cca06c78018085c855f97", size = 1402956, upload-time = "2026-09-12T21:53:51.369Z" },
]
[[package]]
name = "pyinstaller-hooks-contrib"
version = "2026.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/3b/fab1a12a21bf9223af012e5dd002b7d4265683d21d9fabbd0ccb347316e4/pyinstaller_hooks_contrib-2026.8.tar.gz", hash = "sha256:4d825786ad7a9b7dbcc52d612748a34562fe273461bcfd88f4d549c594bbf8f9", size = 176955, upload-time = "2026-09-30T07:53:52.56Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/f1/3eee0263b487e571aed9204257b45bc2a0b6fec34d0a1adade4d8d8a968c/pyinstaller_hooks_contrib-2026.8-py3-none-any.whl", hash = "sha256:6d21d65323ea4467753afc21f52d7d0e69416ae8101dd88269bf273ad48db084", size = 462145, upload-time = "2026-09-30T07:53:51.167Z" },
]
[[package]]
name = "pyside6"
version = "6.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyside6-addons" },
{ name = "pyside6-essentials" },
{ name = "pyside6-pdf" },
{ name = "pyside6-webengine" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/db/0722bbb81c3515dd0847fd66a7fb4760b13ad59c89e40b27b362ab5624e2/pyside6-6.12.0-cp310-abi3-macosx_14_0_universal2.whl", hash = "sha256:efea4c2f2aa54403c45de825865a902a1960a1cc9d5aaa92d4ebb8f4c4cd0708", size = 577475, upload-time = "2026-10-08T09:11:15.222Z" },
{ url = "https://files.pythonhosted.org/packages/2f/53/db0cabed1e70d0bc4666b8cbc2611b72bff2f9d2c4813820182621a8c6ee/pyside6-6.12.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d13ed4d0053cc20f3e4e0fdbf12790a9b7f958d7e22c0843411ecfa8c799e2a9", size = 577709, upload-time = "2026-10-08T09:11:17.345Z" },
{ url = "https://files.pythonhosted.org/packages/4d/8c/dfd4f339285d2e6c31067789b9b7dd2cee26c098ecc78ac7200fb04aa344/pyside6-6.12.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:8920e163f60493e18822d20f1e01c7c4bcf9c1c5cfbc68284788896f692594f8", size = 577708, upload-time = "2026-10-08T09:11:18.705Z" },
{ url = "https://files.pythonhosted.org/packages/a1/da/8e8514fc29ea916c3cd71a524480b77409e19ed12d709e8a5d73be089b81/pyside6-6.12.0-cp310-abi3-win_amd64.whl", hash = "sha256:31a8d2893f2f28ab483b1534dde0438f27af6e234593de30425cb4729052d8f8", size = 578958, upload-time = "2026-10-08T09:11:20.179Z" },
{ url = "https://files.pythonhosted.org/packages/5f/2e/3ebafaf7f4ccea6f6ea9d017f7fc7d9484b79e6b31d1cb92f5f5262055f0/pyside6-6.12.0-cp310-abi3-win_arm64.whl", hash = "sha256:22e2ec6407f32b61955a28f91ab55676e60017210285c28f0c381bfeed389414", size = 562743, upload-time = "2026-10-08T09:11:21.552Z" },
]
[[package]]
name = "pyside6-addons"
version = "6.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyside6-essentials" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/c3/16/c401d6455dbf49df37a8b142883e12d446abf51919c3e0ef5d9570e29160/pyside6_addons-6.12.0-cp310-abi3-macosx_14_0_universal2.whl", hash = "sha256:1d9bcd294ce1c9829194c7d13b01f771a2254b86cb3462cc3399321d2797fdac", size = 90545550, upload-time = "2026-10-08T09:08:14.778Z" },
{ url = "https://files.pythonhosted.org/packages/ff/3e/0d6c5934de575286ac0699a701d4155048185035b8da0ee78d57a38bc7ef/pyside6_addons-6.12.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4ab38c0017f0453671a9313d3a53ca1c0e378924f54b07e596bcea94a9642391", size = 59192702, upload-time = "2026-10-08T09:08:21.13Z" },
{ url = "https://files.pythonhosted.org/packages/94/14/b6c5683614601448c3ea8f19613ca378db75158ba594b662a8a8041e3e5f/pyside6_addons-6.12.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:48cd0b639c06c9f79685f340ccae4319614b66d6efcdf06c2ce3836790d8c46e", size = 63646547, upload-time = "2026-10-08T09:08:26.921Z" },
{ url = "https://files.pythonhosted.org/packages/02/41/ba3d52fe2acd942ebf71eb02b3bdf0897f7f6f4ce8f52289cb50228e48d3/pyside6_addons-6.12.0-cp310-abi3-win_amd64.whl", hash = "sha256:98d8d62010da6605344cee83e5c96fb846170a1f2d924523f10f5d92078b02b0", size = 40522402, upload-time = "2026-10-08T09:08:31.678Z" },
{ url = "https://files.pythonhosted.org/packages/ea/73/860ab764368cd59b1a09cb891a9a4519425395ad1f6c47d83fde908fe603/pyside6_addons-6.12.0-cp310-abi3-win_arm64.whl", hash = "sha256:3c079cbaad2ad28bc5bfa92fd6bac4ba28d622b825825e3b4220f0682599b2db", size = 35940682, upload-time = "2026-10-08T09:08:35.424Z" },
]
[[package]]
name = "pyside6-essentials"
version = "6.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "shiboken6" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/90/d528f8dd3f9f20712bc719e10c698d27c998e1dd76ed3f33b6fb606d0972/pyside6_essentials-6.12.0-cp310-abi3-macosx_14_0_universal2.whl", hash = "sha256:0867b709a724db28b161227c7d6ecb5ed10cc09226a53542417ac6dee1308239", size = 116002775, upload-time = "2026-10-08T09:09:54.453Z" },
{ url = "https://files.pythonhosted.org/packages/7b/3c/7597d8dcb36aa8315427635ec6ec9d850ffbad4ce78b9118ac854889f2d3/pyside6_essentials-6.12.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:16f4b5e41daf49235ae048458243da9efe5f3d8040f9cc5aa454ce69893d3bd9", size = 84346433, upload-time = "2026-10-08T09:10:02.825Z" },
{ url = "https://files.pythonhosted.org/packages/5e/4e/f4fd4c016719be832ec49d3c87940459e87fd666179a0a199a3998d07916/pyside6_essentials-6.12.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:08c5841063fc1df69b7fa1ee405f539e66576a83fc48004be3e2c5e35ccbfa69", size = 83810102, upload-time = "2026-10-08T09:10:09.13Z" },
{ url = "https://files.pythonhosted.org/packages/03/61/9a7e32a37b224f68506785bfb0f288d516098ec71681782d72b695a6c95d/pyside6_essentials-6.12.0-cp310-abi3-win_amd64.whl", hash = "sha256:c9a95102aa23c1f86516a30d5a9be37552d324eee7e567eb7508973f1218cb42", size = 82153795, upload-time = "2026-10-08T09:10:15.104Z" },
{ url = "https://files.pythonhosted.org/packages/92/11/43afb3d0e7c25dd09f0eb7e44f1283dadb5fa8d8b517249fe3fde02e6702/pyside6_essentials-6.12.0-cp310-abi3-win_arm64.whl", hash = "sha256:c76aa689989bf9bb9b45535e0a81c6733294df720b0eed1308fa30d2606b98cc", size = 62073456, upload-time = "2026-10-08T09:10:20.202Z" },
]
[[package]]
name = "pyside6-pdf"
version = "6.12.0.140"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyside6-essentials" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/33/23949c3b48af4b96b618e0502b7313ac52a782615db8dceae5bc2c516831/pyside6_pdf-6.12.0.140-cp310-abi3-macosx_14_0_universal2.whl", hash = "sha256:74287989dd24a82ac1604e27a2d4530a0ab71ff03cdf878c16fdbcf5144373fb", size = 8644803, upload-time = "2026-10-08T09:07:36.716Z" },
{ url = "https://files.pythonhosted.org/packages/b4/f0/9b4f3cb0506641ea53210c7b635703f1c2c1e81c27074438105ff118f8e7/pyside6_pdf-6.12.0.140-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0c94370b6b9d65fbfe54f9d6d25df0677db0cdcfd0a842a63958a3603a42007c", size = 2843877, upload-time = "2026-10-08T09:07:39.208Z" },
{ url = "https://files.pythonhosted.org/packages/39/cb/4a8cf3edb3f1b2396935573c02255be75f5605dcc77e9e3a201a6d6f26d7/pyside6_pdf-6.12.0.140-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:2b63ee1f8ce3d3b2b648be2ebda483dd0fbca8d44c92a6c6f924033ac7fbde8c", size = 2837165, upload-time = "2026-10-08T09:07:40.756Z" },
{ url = "https://files.pythonhosted.org/packages/0e/8a/3f5eab9eb9e6486f0334b100322bbd0aff432dd90ad7977a3910979a163f/pyside6_pdf-6.12.0.140-cp310-abi3-win_amd64.whl", hash = "sha256:4971ba1a0ca15e7c29cfd808e9600aeb46b2305dad01456eb09fa3197166268e", size = 2983516, upload-time = "2026-10-08T09:07:42.312Z" },
{ url = "https://files.pythonhosted.org/packages/b2/87/5c29bdaf47bf0b0cee7f467e86ffeb07be5bf8aa2afd2ff6f0785d0515b4/pyside6_pdf-6.12.0.140-cp310-abi3-win_arm64.whl", hash = "sha256:ce6e4d858df1eb4e2adcbd8ba212f3611ce60f9978881fe10d417f36013a6ff9", size = 2758117, upload-time = "2026-10-08T09:07:43.936Z" },
]
[[package]]
name = "pyside6-webengine"
version = "6.12.0.140"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyside6-addons" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/08/37/3b50067bdcba5c22e20a8d7a029c919850c6d94f8398175b330c3b8765ad/pyside6_webengine-6.12.0.140-cp310-abi3-macosx_14_0_universal2.whl", hash = "sha256:9a1de2da360d1817c08e9400a2a2fc5234beb3022a22123e15355169e183074e", size = 249778242, upload-time = "2026-10-08T09:06:03.602Z" },
{ url = "https://files.pythonhosted.org/packages/ce/e0/cb8b68b962afc253709ed61555d0a4f297fd6786c0e77921d2e951504210/pyside6_webengine-6.12.0.140-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c041c9921d7c91c9e51a2c027f434d05c3c59baa2c8f17b71dc8194e77791f06", size = 116741921, upload-time = "2026-10-08T09:06:13.408Z" },
{ url = "https://files.pythonhosted.org/packages/1b/34/75944ed126293868fcbcc589fa2254e3310016920e5d32d21c29be021524/pyside6_webengine-6.12.0.140-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:66a842ccb7f06fa7e357f6d4d63bc2739e3cbc1f93583f314e38c43024fe5f74", size = 114053128, upload-time = "2026-10-08T09:06:21.275Z" },
{ url = "https://files.pythonhosted.org/packages/21/0d/260c7c286f2d4e8a49c099152a385414a872f022117f937824ca0a3f38e5/pyside6_webengine-6.12.0.140-cp310-abi3-win_amd64.whl", hash = "sha256:f9b61b1d457a591ee590a6b80515f25a3e61e2576a2a0a70566380f9d9de4ab0", size = 131697708, upload-time = "2026-10-08T09:06:29.606Z" },
{ url = "https://files.pythonhosted.org/packages/76/34/7a2d1d7b95c96adf173c77d1862c2587789f7afb8754430fcfc2c9e4e47a/pyside6_webengine-6.12.0.140-cp310-abi3-win_arm64.whl", hash = "sha256:44d1fac2a057a71b8f947fc5177a95fab8a0ea4e2fda068f223a00fd0193a8c7", size = 65723, upload-time = "2026-10-08T09:06:32.559Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/74/26/2fbeedb218a787a5eea551c7532cac4e009f83d689dd2faa0d0353473f86/python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0", size = 60824, upload-time = "2026-10-01T05:36:10Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" },
]
[[package]]
name = "pywin32-ctypes"
version = "0.2.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" },
]
[[package]]
name = "requests"
version = "2.34.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
]
[[package]]
name = "ruff"
version = "0.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e9/a7/70debb024dfacda67b8e560cc7511f52b34b9348a8cc8c1ec23036dcd51d/ruff-0.17.0.tar.gz", hash = "sha256:5cd03240d8208a557c2a9655a5cb07ebe36aa6bb35065f97d48c1f6adef5a322", size = 5320554, upload-time = "2026-10-09T19:47:29.248Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/f8/ee5ab9da6089eae2a33e6008b01deb1eda19992c1c8e10661e98cee1640f/ruff-0.17.0-py3-none-linux_armv6l.whl", hash = "sha256:0e271826af9a20d18c6cfae8c51e82959167c24859686ddd3eb9a7f0842ce81e", size = 10148907, upload-time = "2026-10-09T19:46:38.695Z" },
{ url = "https://files.pythonhosted.org/packages/9f/d9/2f81fb5a9d580afbb11b1c8ff915233a11f2a1b27405d7991f183c5e1976/ruff-0.17.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5f0ca4a40f81403689c04f12966e22f44e329ae362072d8f1587b7bda87f603b", size = 10224208, upload-time = "2026-10-09T19:46:41.711Z" },
{ url = "https://files.pythonhosted.org/packages/a7/20/643f3c8f75594f937b2bf74801241c56a2e2b8e139d24dff8b66b28cdd7f/ruff-0.17.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cbf7149e0927dc3295d5d64679a4765576eef71b00782b2ae969ef82274d6bb9", size = 9273324, upload-time = "2026-10-09T19:46:44.323Z" },
{ url = "https://files.pythonhosted.org/packages/ec/91/627700b233d367736cb274f1bd0b47d1f2b12f68878192812bd875adadc3/ruff-0.17.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13ee90156522998c3037059d8f66885c8adeeaf7643bdce2caceee196ecd23e0", size = 9953190, upload-time = "2026-10-09T19:46:47.155Z" },
{ url = "https://files.pythonhosted.org/packages/cd/92/91f7b5ed39490f89d6cbf56e1f543c383667a725efa8e2c2dee0f01f5591/ruff-0.17.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3d8e4a002a94cd9d0dc48b51dc69d807a172b5b9bf2b668e656424dc5b55ead1", size = 9961746, upload-time = "2026-10-09T19:46:50.098Z" },
{ url = "https://files.pythonhosted.org/packages/87/c5/7310f9fc63ce11ff6394edbd5e85433dfb0e14c9fbf6ccc97f1538491bc7/ruff-0.17.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c0b8a60c06a218c337e1161638d34757f83449243e2db161483ddf948e53ad14", size = 10625483, upload-time = "2026-10-09T19:46:53.379Z" },
{ url = "https://files.pythonhosted.org/packages/a5/8d/97443f0dca4a03a0bc7629fd396fd494a1cb6666121e38c5075acb217d8f/ruff-0.17.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a330178bdffc4205dbf3bda11d93e059e388fd6546f8cdd304501a9160363c0d", size = 11350129, upload-time = "2026-10-09T19:46:56.486Z" },
{ url = "https://files.pythonhosted.org/packages/9c/0a/c525efd9777be4b6b012e6969a3012648468e7e6c4b3e5b46af69f46e8eb/ruff-0.17.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7bb08489e234876fa2da67ae3ea938e9a2156da80293e0e4365abd6973d98329", size = 10906223, upload-time = "2026-10-09T19:47:00.263Z" },
{ url = "https://files.pythonhosted.org/packages/2c/3c/4a01195d93420cad1175bedad13a515dc8a56f95a6e39789b92e582682f5/ruff-0.17.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc73e7c133e82d55b5f15897b2a442d72c0cb4a0c886c46801ce3c247150b60c", size = 10386314, upload-time = "2026-10-09T19:47:03.057Z" },
{ url = "https://files.pythonhosted.org/packages/c7/72/1a3951665485a921f6375f91e754a1854d5a645d41acc3642668064ff64d/ruff-0.17.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:db4f74c533403ab70fe4007873f6ae0c9f94a8b03158cf48d78788e47cdbe399", size = 10565464, upload-time = "2026-10-09T19:47:05.831Z" },
{ url = "https://files.pythonhosted.org/packages/90/8c/539b4d8c082f57e18db8ae2be85a460d77861c79dcd5798e32b536a6a06f/ruff-0.17.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3d8cc360e666d1914e47b0777c6906d70cf18891a55532bd0a16844195d70859", size = 10079468, upload-time = "2026-10-09T19:47:08.617Z" },
{ url = "https://files.pythonhosted.org/packages/e0/b8/84286966db79434e8c26b585b0a0f6897cb3ab1c51a4aa4df10c28488b62/ruff-0.17.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d66de796b726c4801e05fa99a2a8d7a780e107be222486c304ab61765561e866", size = 9958556, upload-time = "2026-10-09T19:47:11.324Z" },
{ url = "https://files.pythonhosted.org/packages/69/50/27b6eed27b83fcdd5bfa0d52b83231e29094754374698da404d094487ae3/ruff-0.17.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c3f268baf004aea944f040623327119527ea231af15f7fb7890e82cea0679589", size = 10352173, upload-time = "2026-10-09T19:47:14.188Z" },
{ url = "https://files.pythonhosted.org/packages/2a/fa/955399fd13044cd827862044117d784a59e3196f6cce7424908ac9a7f914/ruff-0.17.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:864b6c1acb6b0bccf94b5a3938a1531fd09aaca5e5659a2e7bf0f3cf2a685540", size = 10791761, upload-time = "2026-10-09T19:47:16.931Z" },
{ url = "https://files.pythonhosted.org/packages/ae/bf/024e01e1f6aec87768696725e648ed5b438941341eea8f8100beb681961f/ruff-0.17.0-py3-none-win32.whl", hash = "sha256:5e50aa5b84decd9fe5b0bb0e6f71c3b592f1767ed09faa4b7207d933961e35cd", size = 9904112, upload-time = "2026-10-09T19:47:19.75Z" },
{ url = "https://files.pythonhosted.org/packages/cc/77/1ee73df41dcc8d1cdb686ee4bc46ea29704ea175feb6b95c78420f631ab8/ruff-0.17.0-py3-none-win_amd64.whl", hash = "sha256:8ab76bcda86dfd28e13776cb5de3c7bcdcf1ae3d37ed761113d1a5a415dc134c", size = 10115585, upload-time = "2026-10-09T19:47:22.698Z" },
{ url = "https://files.pythonhosted.org/packages/fd/71/eb4f0ccc844aece56963e8578df9c95d4c00f580547d52329d4035d3af18/ruff-0.17.0-py3-none-win_arm64.whl", hash = "sha256:c154c73ff43f9854395e24cac507af13078962e53d2b511605058d22af1fdb88", size = 9872035, upload-time = "2026-10-09T19:47:26.306Z" },
]
[[package]]
name = "setuptools"
version = "84.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" },
]
[[package]]
name = "shiboken6"
version = "6.12.0"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/45/11536fba5f50a57671eb14441c6098096f3e57074e54c9d7d66039a5831e/shiboken6-6.12.0-cp310-abi3-macosx_14_0_universal2.whl", hash = "sha256:47105e05baf57d35453e07d240bbe59cb4b8d9565377ea5134ff9fb37d4f5c02", size = 479653, upload-time = "2026-10-08T09:10:41.506Z" },
{ url = "https://files.pythonhosted.org/packages/49/c2/a04fa003b4360e99d8e3fc2829abacf37396ace63ea1cd616402209ec7c6/shiboken6-6.12.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3bde565bb0890044b5c7f63808adddede5abc241e9395a2bfed61919884d6240", size = 279268, upload-time = "2026-10-08T09:10:42.982Z" },
{ url = "https://files.pythonhosted.org/packages/97/ca/a4415e6218e0dff764fe2b2d30db2fc8f499a08ace07634232ade3f73adc/shiboken6-6.12.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:ff72a72b3277b418902d562b987b33a6f6818bed9fe40a49d634d09bc67f620e", size = 276562, upload-time = "2026-10-08T09:10:44.25Z" },
{ url = "https://files.pythonhosted.org/packages/2a/b6/bee97e7a2edeab72aafabb46ee5adc939790a638d107860b4cad7a20e407/shiboken6-6.12.0-cp310-abi3-win_amd64.whl", hash = "sha256:a1906cb8116869178c64b6bab52623bbc8b4ceed7b30bfc3c9aa0f6e67f6438e", size = 1265476, upload-time = "2026-10-08T09:10:45.627Z" },
{ url = "https://files.pythonhosted.org/packages/8c/d5/5894d35f8a470ca8acff0d1cd8f15b1c94cbfdfe0ce4a6ec0fcf10b95bd1/shiboken6-6.12.0-cp310-abi3-win_arm64.whl", hash = "sha256:da382e68f0815b31b6dc2bce11a465b148c4f42c37b91546a361de1576567d10", size = 1838851, upload-time = "2026-10-08T09:10:47.123Z" },
]
[[package]]
name = "soupsieve"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5e/77/2dcfa996b01702ab8fd0763d84098f6a640d6162a328f1c04c2697579a1a/soupsieve-3.0.3.tar.gz", hash = "sha256:7dcf6022eed0399eb9934a75e020148f7a2024c37b7dfcd3cf2c5505d69c364e", size = 115923, upload-time = "2026-10-12T13:21:17.696Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/ca/f639c80449997b88aba7bc9705d25dd76cc0844f45f187862fd8f8bb18fa/soupsieve-3.0.3-py3-none-any.whl", hash = "sha256:fa30e3ba4809cb81ce1f3209f2fbe3e779fc445f0439bc147a0d7c4601743f21", size = 41705, upload-time = "2026-10-12T13:21:16.474Z" },
]
[[package]]
name = "truststore"
version = "0.10.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
[[package]]
name = "urllib3"
version = "2.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
]
[[package]]
name = "win32-setctime"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" },
]